录制视频UI别离
我正在参与「启航计划」
前言
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项目,我有时刻都会持续更新。
假如有更多的更好的其他方法,也希望咱们能谈论区沟通一下。
常规,我如有讲解不到位或错漏的当地,希望同学们能够指出。
我自己一路写下来,对应自界说View的体系我也是有了更多的理解,希望咱们跟着一路温习下来能有更多的收货。
假如感觉本文对你有一点点的启示,还望你能点赞
支撑一下,你的支撑是我最大的动力。
Ok,这一期就此结束。