前言
Android 开发常见的录制视频的逻辑,平常我们用的最多的就是直接跳转到系统的视频 App 里面。让系统帮我们录制,这肯定是最方便效果最好的。
但是有些情况下我们就被需求限制了,例如需要双端统一UI,例如有些兼容性问题导致时长无法最大限制,有些时候我们难免就需要自定义录制视频的逻辑。
而网上一些的资源大多都是一些老的项目,页面与 Camera 逻辑耦合了,例如有的项目用的Camera1,有的用的Camera2, 有的用的之前的谷歌兼容库 CameraView 之类的,后面出了 CameraX 又如何与我们的录制视频页面绑定呢?又要重写一套,相对比较复杂。
所以就需要把 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 中
我们大致定义的布局如下:
预览的布局如下:
在一个录制的页面我们分为几种状态,录制前,录制中,录制后。
录制前,我们要隐藏显示对应的布局,初始化各种资源,对录制按钮做监听
录制中,我们通过倒计时,通过定时刷新的操作来调用 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项目,我有时间都会持续更新。
作者:newki
链接:https://juejin.cn/post/7234795795215794237
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。