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

1,739 阅读10分钟

录制视频UI分离

我正在参加「掘金·启航计划」

前言

Android 开发常见的录制视频的逻辑,平常我们用的最多的就是直接跳转到系统的视频 App 里面。让系统帮我们录制,这肯定是最方便效果最好的。

但是有些情况下我们就被需求限制了,例如需要双端统一UI,例如有些兼容性问题导致时长无法最大限制,有些时候我们难免就需要自定义录制视频的逻辑。

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

所以就需要把 UI 与 Camera 逻辑分离出来,本文的 UI 效果是基于老版的微信录制页面仿制的

效果如下:

image.png

image.png

image.png

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 中

我们大致定义的布局如下:

image.png

预览的布局如下:

image.png

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

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

录制中,我们通过倒计时,通过定时刷新的操作来调用 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,这一期就此完结。