录制视频UI别离

我正在参与「启航计划」

前言

Android 开发常见的录制视频的逻辑,往常咱们用的最多的便是直接跳转到体系的视频 App 里面。让体系帮咱们录制,这肯定是最便利作用最好的。

可是有些情况下咱们就被需求约束了,例如需求双端统一UI,例如有些兼容性问题导致时长无法最大约束,有些时分咱们难免就需求自界说录制视频的逻辑。

而网上一些的资源大多都是一些老的项目,页面与 Camera 逻辑耦合了,例如有的项目用的Camera1,有的用的Camera2, 有的用的之前的谷歌兼容库 CameraView 之类的,后面出了 CameraX 又怎么与咱们的录制视频页面绑定呢?又要重写一套,相对比较复杂。

所以就需求把 UI 与 Camera 逻辑别离出来,本文的 UI 作用是基于老版的微信录制页面仿制的

作用如下:

Android录制视频,UI与Camera分离之UI的抽取

Android录制视频,UI与Camera分离之UI的抽取

Android录制视频,UI与Camera分离之UI的抽取

gif图片太大传不上来,咱们应该能理解这样的作用,和微信比较类似。

分化之后咱们需求做的步骤就分录制的按钮制作与动画,集成整个控件与Camera的封装控件,录制结束之后的播映逻辑。

接下来咱们一步步的往下走。

一、录制按钮

咱们的录制按钮其实便是分为一个外圈,一个内圈,一个进展圆环三个东西。

默许的状况外圈与内圈相差5个dp,咱们能够把全体的布局先测量并制作出来:

 @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        if (measuredWidth == -1) {
            measuredWidth = getMeasuredWidth();
            radius1 = measuredWidth * zoom / 2;
            radius2 = measuredWidth * zoom / 2 - dp5;
            oval.left = dp5 / 2;
            oval.top = dp5 / 2;
            oval.right = measuredWidth - dp5 / 2;
            oval.bottom = measuredWidth - dp5 / 2;
        }
    }
    @Override
    protected void onDraw(Canvas canvas) {
        //制作外圈
        paint.setColor(colorGray);
        canvas.drawCircle(measuredWidth / 2, measuredWidth / 2, radius1, paint);
        //制作内圈
        paint.setColor(Color.WHITE);
        canvas.drawCircle(measuredWidth / 2, measuredWidth / 2, radius2, paint);
        //制作进展
        canvas.drawArc(oval, 270, girthPro, false, paintProgress);
    }

要点便是咱们点击按钮的时分有扩大的逻辑,结束录制有缩小的逻辑。所以咱们需求界说动画,在动画的回调中依据缩放的值动态的设置外圈与内圈的两个 radius 。

 public void startAnim(float start, float end) {
        if (buttonAnim == null || !buttonAnim.isRunning()) {
            buttonAnim = ValueAnimator.ofFloat(start, end).setDuration(animTime);
            buttonAnim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
                @Override
                public void onAnimationUpdate(ValueAnimator animation) {
                    float value = (float) animation.getAnimatedValue();
                    radius1 = measuredWidth * (zoom + value) / 2;
                    radius2 = measuredWidth * (zoom - value) / 2 - dp5;
                    value = 1 - zoom - value;
                    oval.left = measuredWidth * value / 2 + dp5 / 2;
                    oval.top = measuredWidth * value / 2 + dp5 / 2;
                    oval.right = measuredWidth * (1 - value / 2) - dp5 / 2;
                    oval.bottom = measuredWidth * (1 - value / 2) - dp5 / 2;
                    invalidate();
                }
            });
            buttonAnim.start();
        }
    }

对于进展的制作,咱们是经过 setProgress 动态的设置当时的进展值,然后经过改写结束进展的展现。

    public void setProgress(float progress) {
        this.progress = progress;
        float ratio = progress / max;
        girthPro = 365 * ratio;
        postInvalidate();
    }

它自己自身是不做动画与页面逻辑的,仅仅提供了方法供对方调用,因为咱们之前温习过自界说 View 的制作,所以这儿代码逻辑并不复杂,悉数代码如下:

public class RecordedButton extends View {
    private int measuredWidth = -1;
    private Paint paint;
    private int colorGray;
    private float radius1;
    private float radius2;
    private float zoom = 0.8f; //初始化缩放比例
    private int dp5;
    private Paint paintProgress;
    private int colorBlue;
    /**
     * 当时进展 以视点为单位
     */
    private float girthPro;
    private RectF oval;
    private int max;
    private int animTime = 400;   //动画履行的时刻
    private Paint paintSplit;
    private boolean isDeleteMode;
    private Paint paintDelete;
    private ValueAnimator buttonAnim;
    private float progress;
    public RecordedButton(Context context) {
        super(context);
        init();
    }
    public RecordedButton(Context context, AttributeSet attrs) {
        super(context, attrs);
        init();
    }
    public RecordedButton(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init();
    }
    private void init() {
        dp5 = (int) getResources().getDimension(R.dimen.d_5dp);
        colorGray = getResources().getColor(R.color.gray);
        colorBlue = getResources().getColor(R.color.picture_color_blue);
        paint = new Paint();
        paint.setAntiAlias(true);
        paintProgress = new Paint();
        paintProgress.setAntiAlias(true);
        paintProgress.setColor(colorBlue);
        paintProgress.setStrokeWidth(dp5);
        paintProgress.setStyle(Paint.Style.STROKE);
        paintSplit = new Paint();
        paintSplit.setAntiAlias(true);
        paintSplit.setColor(Color.WHITE);
        paintSplit.setStrokeWidth(dp5);
        paintSplit.setStyle(Paint.Style.STROKE);
        paintDelete = new Paint();
        paintDelete.setAntiAlias(true);
        paintDelete.setColor(Color.RED);
        paintDelete.setStrokeWidth(dp5);
        paintDelete.setStyle(Paint.Style.STROKE);
        //设置制作巨细
        oval = new RectF();
    }
    /**
     * 开端动画,按钮的打开和缩回
     */
    public void startAnim(float start, float end) {
        if (buttonAnim == null || !buttonAnim.isRunning()) {
            buttonAnim = ValueAnimator.ofFloat(start, end).setDuration(animTime);
            buttonAnim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
                @Override
                public void onAnimationUpdate(ValueAnimator animation) {
                    float value = (float) animation.getAnimatedValue();
                    radius1 = measuredWidth * (zoom + value) / 2;
                    radius2 = measuredWidth * (zoom - value) / 2 - dp5;
                    value = 1 - zoom - value;
                    oval.left = measuredWidth * value / 2 + dp5 / 2;
                    oval.top = measuredWidth * value / 2 + dp5 / 2;
                    oval.right = measuredWidth * (1 - value / 2) - dp5 / 2;
                    oval.bottom = measuredWidth * (1 - value / 2) - dp5 / 2;
                    invalidate();
                }
            });
            buttonAnim.start();
        }
    }
    /**
     * 设置最大进展
     */
    public void setMax(int max) {
        this.max = max;
    }
    /**
     * 设置进展
     */
    public void setProgress(float progress) {
        this.progress = progress;
        float ratio = progress / max;
        girthPro = 365 * ratio;
        postInvalidate();
    }
    /**
     * 清除残留的进展
     */
    public void clearProgress() {
        setProgress(0);
    }
    /**
     * 获取到当时按钮的动画
     */
    public ValueAnimator getButtonAnim() {
        return buttonAnim;
    }
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        if (measuredWidth == -1) {
            measuredWidth = getMeasuredWidth();
            radius1 = measuredWidth * zoom / 2;
            radius2 = measuredWidth * zoom / 2 - dp5;
            oval.left = dp5 / 2;
            oval.top = dp5 / 2;
            oval.right = measuredWidth - dp5 / 2;
            oval.bottom = measuredWidth - dp5 / 2;
        }
    }
    @Override
    protected void onDraw(Canvas canvas) {
        //制作外圈
        paint.setColor(colorGray);
        canvas.drawCircle(measuredWidth / 2, measuredWidth / 2, radius1, paint);
        //制作内圈
        paint.setColor(Color.WHITE);
        canvas.drawCircle(measuredWidth / 2, measuredWidth / 2, radius2, paint);
        //制作进展
        canvas.drawArc(oval, 270, girthPro, false, paintProgress);
    }
}

其中的一些特点都是固定的,后期咱们也能够抽取出来作为可装备选项。

二、自界说View封装

对一些录制状况的判别,录制页面的展现,录制按钮的操控等逻辑,咱们统一封装到一个独自的 View 中

咱们大致界说的布局如下:

Android录制视频,UI与Camera分离之UI的抽取

预览的布局如下:

Android录制视频,UI与Camera分离之UI的抽取

在一个录制的页面咱们分为几种状况,录制前,录制中,录制后。

录制前,咱们要隐藏显现对应的布局,初始化各种资源,对录制按钮做监听

录制中,咱们经过倒计时,经过定时改写的操作来调用 Camera 来录制,手动的结束录制或许抵达最大录制时长,就走到录制后逻辑。

录制后,咱们需求隐藏显现对应布局,预览已录制的视频,并开释摄像头与录制的资源。

录制前:

        TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.RecorderVideoView, defStyle, 0);
        int mWidth = a.getInteger(R.styleable.RecorderVideoView_record_width, 320);// 默许320
        int mHeight = a.getInteger(R.styleable.RecorderVideoView_record_height, 240);// 默许240
        mRecordMaxTime = a.getInteger(R.styleable.RecorderVideoView_record_max_time, 10);// 默许为10秒
        a.recycle();
        //todo 设置自界说特点给CameraAction
        mCameraAction.setupCustomParams(mWidth, mHeight, mRecordMaxTime);
        /*
         * 自界说录像控件填充自界说的布局
         */
        LayoutInflater.from(context).inflate(R.layout.recorder_video_view, this);
        //找到其他的控件
        mVideoPlay = (MyVideoView) findViewById(R.id.vv_play);
        mRlbottom = (RelativeLayout) findViewById(R.id.rl_bottom);
        mIvfinish = (ImageView) findViewById(R.id.iv_finish);
        mIvclose = (ImageView) findViewById(R.id.iv_close);
        mShootBtn = (RecordedButton) findViewById(R.id.shoot_button);
        ViewGroup flCameraContrainer = findViewById(R.id.fl_camera_contrainer);
        // 初始化并添加Camera载体
        flCameraContrainer.addView(mCameraAction.initCamera(getContext()));
        createRecordDir();
        initListener();

然后咱们需求对事情做监听,结束录制的逻辑:

    private void initListener() {
        mShootBtn.setMax(mRecordMaxTime);
        mShootBtn.setOnTouchListener(new View.OnTouchListener() {
            @Override
            public boolean onTouch(View v, MotionEvent event) {
                if (event.getAction() == MotionEvent.ACTION_DOWN) {
                    mShootBtn.startAnim(0, 0.2f);
                    mCurProgress = 0.5f;
                    startRecord(new RecorderVideoView.OnRecordFinishListener() {
                        @Override
                        public void onRecordFinish() {
                            mHandler.sendEmptyMessage(1);
                        }
                    });
                } else if (event.getAction() == MotionEvent.ACTION_UP) {
                    if (getTimeCount() > 1)
                        mHandler.sendEmptyMessage(1);
                    else {
                        /* 录制时刻小于1秒 录制失败 而且删去保存的文件  */
                        if (getVecordFile() != null) {
                            getVecordFile().delete();
                        }
                        mHandler.postDelayed(new Runnable() {
                            @Override
                            public void run() {
                                mShootBtn.startAnim(0.2f, 0);
                            }
                        }, 400);
                        stop();
                        Toast.makeText(getContext(), "视频录制时刻太短", Toast.LENGTH_SHORT).show();
                    }
                }
                return true;
            }
        });
        /*  点击取消 康复控件显现状况 删去文件 */
        mIvclose.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                mVideoPlay.stop();
                clearWindow();
                mShootBtn.clearProgress();
                mVideoPlay.setVisibility(View.GONE);
                mCameraAction.isShowCameraView(true);
                mRlbottom.setVisibility(View.GONE);
                mShootBtn.setVisibility(View.VISIBLE);
                getVecordFile().delete();
            }
        });
        /*  点击确认 录制结束 能够挑选发送或许到另一个界面看视频 */
        mIvfinish.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                Toast.makeText(getContext(), "录制结束,视频保存的地址:" + getVecordFile().toString(), Toast.LENGTH_SHORT).show();
                if (mCompleteListener != null) {
                    mCompleteListener.onComplete();
                }
            }
        });
    }

录制中:

首要咱们界说一个 Handler 去触发状况,而且履行定时的一些操作:

 private Handler mHandler = new Handler() {
        @Override
        public void handleMessage(Message msg) {
            super.handleMessage(msg);
            if (msg.what == 1) {
                finishRecode();
            } else if (msg.what == 2) {
                mCurProgress += 0.016;
                mShootBtn.setProgress(mCurProgress);
                mHandler.sendEmptyMessageDelayed(2, 16);
            } else if (msg.what == 100) {
                //履行倒计时,计算已录制的时刻
                mTimeCount++;
                if (mTimeCount >= mRecordMaxTime) {  // 抵达指定时刻,中止拍照
                    mShootBtn.setProgress(mRecordMaxTime);
                    stop();
                    if (mOnRecordFinishListener != null) {
                        mOnRecordFinishListener.onRecordFinish();
                    }
                } else {
                    mHandler.sendEmptyMessageDelayed(100, 1000);
                }
            }
        }
    };

当咱们开端录制的时分,调用 CameraAction接口 去录制视频,而且切换状况与开端倒计时:

    public void startRecord(final OnRecordFinishListener onRecordFinishListener) {
        //设置监听
        this.mOnRecordFinishListener = onRecordFinishListener;
        //动画履行
        mHandler.sendEmptyMessage(2);
        // 录制时刻记载
        mTimeCount = 0;
        mHandler.sendEmptyMessageDelayed(100, 1000);
        //  CameraAction调用录制
        mCameraAction.startCameraRecord();
    }

此时就会回调到 setProgress 设置进展了。

录制后:

当咱们手动的抬起手指,或许抵达录制时刻,咱们切换为录制后的状况。 展现隐藏布局,而且开释资源。

    private void finishRecode() {
        stop();
        /*  录制结束显现 操控控件的显现和隐藏  */
        mVideoPlay.setVisibility(View.VISIBLE);
        // todo CameraAction是否展现预览页面
        mCameraAction.isShowCameraView(false);
        mRlbottom.setVisibility(View.VISIBLE);
        mShootBtn.startAnim(0.2f, 0);
        ValueAnimator anim = mShootBtn.getButtonAnim();
        if (anim != null && anim.isRunning()) {
            anim.addListener(new AnimatorListenerAdapter() {
                @Override
                public void onAnimationEnd(Animator animation) {
                    super.onAnimationEnd(animation);
                    mShootBtn.setVisibility(View.GONE);
                }
            });
        }
        //录制结束之后展现现已录制的路径下的视频文件
        mVideoPlay.setVideoPath(getVecordFile().toString());
        mVideoPlay.setOnPreparedListener(new MediaPlayer.OnPreparedListener() {
            @Override
            public void onPrepared(MediaPlayer mp) {
                mVideoPlay.setLooping(true);
                mVideoPlay.start();
            }
        });
        if (mVideoPlay.isPrepared()) {
            mVideoPlay.setLooping(true);
            mVideoPlay.start();
        }
    }

开释的资源的操作:

    /**
     * 中止拍照
     */
    public void stop() {
        mHandler.removeMessages(2);
        mHandler.removeMessages(100);
        mShootBtn.setProgress(0);
        stopRecord();
        releaseRecord();
        //todo CameraAction开释摄像头资源
        mCameraAction.releaseCamera();
    }
    /**
     * 中止录制
     */
    public void stopRecord() {
        //todo CameraAction录制的相关操控
        mCameraAction.stopCameraRecord();
    }
    /**
     * 开释资源
     */
    private void releaseRecord() {
        //todo CameraAction录制的相关操控
        mCameraAction.releaseCameraRecord();
    }
    /**
     * 销毁悉数的资源
     */
    public void destoryAll() {
        mShootBtn.clearProgress();
        mHandler.removeCallbacksAndMessages(null);
    }

这样就结束了录制视频的UI逻辑,而详细的 Camera 的操作,咱们能够经过接口的方法运用不同的策略来运用不同的 Camera API,例如我是运用的过时的 Camera1的Api。

interface ICameraAction {
    void setupCustomParams(int width ,int height  ,int recordMaxTime);
    void setOutFile(File file);
    File getOutFile();
    View initCamera(Context context);
    void initCameraRecord();
    void startCameraRecord();
    void stopCameraRecord();
    void releaseCameraRecord();
    void releaseCamera();
    void clearWindow();
    void isShowCameraView(boolean isVisible);
}

Camera1 的大致结束:

public class Camera1ActionImpl implements ICameraAction {
    @Override
    public View initCamera(Context context) {
        mSurfaceView = new SurfaceView(context);
        mSurfaceView.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
        mSurfaceHolder = mSurfaceView.getHolder();
        mSurfaceHolder.addCallback(new CustomCallBack());
        mSurfaceHolder.setType(SurfaceHolder.SURFACE_TYPE_PUSH_BUFFERS);
        return mSurfaceView;
    }
    private class CustomCallBack implements SurfaceHolder.Callback {
        @Override
        public void surfaceCreated(SurfaceHolder holder) {
            initCameraAndRecord();
        }
        @Override
        public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
        }
        @Override
        public void surfaceDestroyed(SurfaceHolder holder) {
            releaseCamera();
        }
    }
private void initCameraAndRecord() {
        if (mCamera != null) {
            releaseCamera();
        }
        //打开摄像头
        try {
            mCamera = Camera.open();
        } catch (Exception e) {
            e.printStackTrace();
            releaseCamera();
        }
        if (mCamera == null)
            return;
        //设置摄像头参数
        setCameraParams();
        try {
            mCamera.setDisplayOrientation(90);   //设置拍照方向为90度(竖屏)
            mCamera.setPreviewDisplay(mSurfaceHolder);
            mCamera.startPreview();
            mCamera.unlock();
            //摄像头参数设置结束之后,初始化录制API装备
            initCameraRecord();
        } catch (IllegalStateException e) {
            e.printStackTrace();
        } catch (RuntimeException e) {
            e.printStackTrace();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

咱们就能把 UI 与 Camera 的逻辑别离,后期就能够替换为各种不同的 Camera 来结束。虽然结束相比比较简略,但详细代码还是太多,有爱好能够检查文章末尾的源码检查。

三、展现现已录制的视频

录制结束之后咱们就需求播映预览现已录制的视频,并让用户挑选是从头录制还是确认结束。

播映视频的方法有很多,因为咱们一般本地录制的视频都是 MP4 格局,所以运用原生的 VideoPlayer 或许 TextureView 都能简略快速的结束视频的预览。

例如我这儿运用的 MediaPlayer + TextureView结束的视频预览,大致的代码如下:

public class MyVideoView extends TextureView implements TextureView.SurfaceTextureListener {
    private MediaPlayer mMediaPlayer = null;
    private SurfaceTexture mSurfaceHolder = null;
    public void openVideo(Uri uri) {
        if (uri == null || mSurfaceHolder == null || getContext() == null) {
            // not ready for playback just yet, will try again later
            if (mSurfaceHolder == null && uri != null) {
                mUri = uri;
            }
            return;
        }
        mUri = uri;
        mDuration = 0;
        Exception exception = null;
        try {
            if (mMediaPlayer == null) {
                mMediaPlayer = new MediaPlayer();
                mMediaPlayer.setOnPreparedListener(mPreparedListener);
                mMediaPlayer.setOnCompletionListener(mCompletionListener);
                mMediaPlayer.setOnErrorListener(mErrorListener);
                mMediaPlayer.setAudioStreamType(AudioManager.STREAM_MUSIC);
                mMediaPlayer.setOnSeekCompleteListener(mSeekCompleteListener);
                //			mMediaPlayer.setScreenOnWhilePlaying(true);
                mMediaPlayer.setVolume(mVolumn, mVolumn);
                mMediaPlayer.setSurface(new Surface(mSurfaceHolder));
            } else {
                mMediaPlayer.reset();
            }
            mMediaPlayer.setDataSource(getContext(), uri);
            mMediaPlayer.prepareAsync();
            mCurrentState = STATE_PREPARING;
        } catch (IOException ex) {
            exception = ex;
        } catch (IllegalArgumentException ex) {
            exception = ex;
        } catch (Exception ex) {
            exception = ex;
        }
        if (exception != null) {
            exception.printStackTrace();
            mCurrentState = STATE_ERROR;
            if (mErrorListener != null)
                mErrorListener.onError(mMediaPlayer, MediaPlayer.MEDIA_ERROR_UNKNOWN, 0);
        }
    }
}

一般咱们设置循环播映的话,一些暂停康复等操作都是可不必的,只需求开端与中止即可:

    public void start() {
        mTargetState = STATE_PLAYING;
        //可用状况{Prepared, Started, Paused, PlaybackCompleted}
        if (mMediaPlayer != null && (mCurrentState == STATE_PREPARED || mCurrentState == STATE_PAUSED || mCurrentState == STATE_PLAYING || mCurrentState == STATE_PLAYBACK_COMPLETED)) {
            try {
                if (!isPlaying())
                    mMediaPlayer.start();
                mCurrentState = STATE_PLAYING;
                if (mOnPlayStateListener != null)
                    mOnPlayStateListener.onStateChanged(true);
            } catch (IllegalStateException e) {
                tryAgain(e);
            } catch (Exception e) {
                tryAgain(e);
            }
        }
    }
    public void stop() {
        mTargetState = STATE_STOP;
        if (mMediaPlayer != null && (mCurrentState == STATE_PLAYING || mCurrentState == STATE_PAUSED)) {
            try {
                mMediaPlayer.stop();
                mCurrentState = STATE_STOP;
                if (mOnPlayStateListener != null)
                    mOnPlayStateListener.onStateChanged(false);
            } catch (IllegalStateException e) {
                tryAgain(e);
            } catch (Exception e) {
                tryAgain(e);
            }
        }
    }   

这样就能结束一个超简略的视频录制逻辑了。

后期咱们还能把一些装备都抽取出来,一些图片资源也能抽取出来,对于闪光灯与切换前后摄像头号逻辑都能加上。

跋文

本文的示例代码是基于Camera + SurfaceView + MediaRecorder 录制API结束的。

对于录制视频的方法有很多种,示例仅仅最简略的 MediaRecorder ,MidiaRecoder 本质上便是对 MediaCodec 的封装,它用起来确实便利,可是一些装备不是很便利更改,例如修改录制的提示音,不便利断点续录,等等有时分并不符合咱们的要求。

那咱们能够运用 CameraX 的录制也显得更便利,或许自己手动的运用 MediaCodec 生成视频流与音频流的编码格局,然后经过 MediaMuxer去封装格局为MP4。

乃至你觉得能够都能够用 ffmpeg 去编码音频与视频的编码格局,然后合成MP4。

乃至咱们还能直接运用第三方的一些jar包结束特效/美颜录制。

可挑选的太多了,所以咱们第一步把 UI 逻辑别离出来之后,后期咱们想要经过怎样的方法来结束视频录制都是很便利了的。

好了,关于本文的内容假如想检查源码能够点击这儿 【传送门】。你也能够重视我的这个Kotlin项目,我有时刻都会持续更新。

假如有更多的更好的其他方法,也希望咱们能谈论区沟通一下。

常规,我如有讲解不到位或错漏的当地,希望同学们能够指出。

我自己一路写下来,对应自界说View的体系我也是有了更多的理解,希望咱们跟着一路温习下来能有更多的收货。

假如感觉本文对你有一点点的启示,还望你能点赞支撑一下,你的支撑是我最大的动力。

Ok,这一期就此结束。

Android录制视频,UI与Camera分离之UI的抽取