文章目录
背景
Android 为开发者提供了MediaRecorder的类,可以帮助录屏。但是重要的缺陷:
- MediaRecorder控制粒度很粗,无法完成更多的自定义功能,如对音频数据处理
- MediaRecorder录制的效果很差,特别是音频效果。
为了更好的效果,最终决定利用AudioRecord、MediaProjection、MediaCodec、MediaMuxer几个重要的组件进行录屏。
这几个组件都涉及到很多的音视频的知识,建议先看之前的音视频相关的文章介绍。
Android 音视频组件介绍
-
AudioRecord : Android音频录制的重要的模块,可以读取到PCM裸流数据;
-
MediaProjection:Android提供的截图、录屏的模块,可以提供一个surface;
-
MediaCodec:音视频硬编解码的重要模块,这里负责两项工作:
- 将PCM音频硬编码压缩AAC格式
- 将提供的surface的视频数据硬编码成H264格式
- MeidaMuxer:音视频混合器,负责将AAC、H264合成mp4格式;
模块介绍
整体的难点在于如何协调音频录制、视屏录制、混合器的三个组件的流程,他们之前相互独立,但是在启动和结束又相互关系。
为此,设计了三个独立的线程,分别是音频录制线程、视频录制线程、混合线程,三个都是HandlerThread的机制,利用消息机制,推动线程进行消息处理。
(也就是handler.sendMessage()发送消息和onMessage()处理消息的机制、loop无线循环消息)
不过在视频录制上MediaCodec提供了回调,不需要自己维护一个HandlerThread。
状态介绍
初始状态 -> 预启动状态 -> 录制状态 -> 预结束状态 -> 结束状态
之所有需要一个预启动状态和一个预结束状态,是因为录制模块和合成模块相互之间是有依赖关系的,录制模块需要拿到合成模块的通道,合成模块需要让录制模块优先启动。
同理,预结束状态也是相互依赖的。
音频录制模块:AudioRecord + MediaCodec
- AudioRecord和MediaCodec start 启动,进入预启动状态;
- 不断的从AudioRecord 读取byte音频数据,给MediaCodec编码;
- MediaCodec发出
INFO_OUTPUT_FORMAT_CHANGED
状态,从混合器模块添加一个通道track,开始真正的音频编码,进入录制状态; - 不断的从AudioRecord 读取byte音频数据,MediaCodec编码出的ByteBuffer、BufferInfo封装一下,放到混合器模块的队列中,提供给混合器去消费;
- 调用AudioRecord的stop方法、停止读取AudioRecord的数据、给MediaCodec输入
BUFFER_FLAG_END_OF_STREAM
状态,进入预结束状态; - 之所以不能释放停止MediaCodec,因为混合器还不一定消费完MediaCodec提供的数据。
- 混合器通知音频模块,已经消费完成可以结束;音频模块进入结束状态,MeidaCodec释放,线程退出。
视频录制模块:MediaProjection + MediaCodec
- MediaCodec启动并创建surface提供给VirtualDisplay,virtaualDisplay启动,给MediaCodec设置一个回调setCallback(),进入预启动状态;
- 在MediaCodec设置的回调 onOutputFormatChanged() 中,向混合器模块添加一个通道track,开始真正的视频编码,进入录制状态;
- 在MediaCodec设置的回调 onOutputBufferAvailable() 中,MediaCodec编码出的ByteBuffer、BufferInfo封装一下,放到混合器模块的队列中,提供给混合器去消费;
- 调用停止VirtualDisplay的方法、停止在回调中消费数据,进入预结束状态;
- 混合器通知视频模块,已经消费完成可以结束;视频模块进入结束状态,MeidaCodec释放,线程退出。
混合模块:MediaMuxer
- 启动MediaMuxer,进入预启动状态;
- 等待音频模块和视频模块都添加了通道track,进入录制状态;
- 线程不停地从队列中取出视频或者音频模块封装的编码后的数据,给MediaMuxer混合;
- 外部通知结束,进入预结束状态;
- 继续不停地继续消费队列的数据,等到队列数据为空且是预结束状态,进入结束状态,通知视频和音频模块可以结束了,释放MediaMuxer。
以上三个模块的预启动状态、录制状态、预结束状态、结束状态都是相互独立的。
流程图
音视频的录制涉及到了多线程并行处理,单一线程录制音频、单一线程录制视频、单一线程合并音频帧和视频帧。因此需要非常注意维护多线程状态,小心陷入死锁问题。
问题
1. 时间戳同步问题
音视频最常遇到的问题就是如何保障音视频的同步问题?
这里记下遇到的几个坑:
-
Android 视频帧获取的时间戳presentationTime是不可靠的,以及我们获取到的音频流没有时间戳的概念。
-
为了追求流畅感,一般都是以音频帧为标准,视频帧迁就音频帧,也就是说视频帧可以丢帧处理
同步音视频的方法:
-
对齐处理:音频和视频都利用System.currentTimeMill()获取时间戳,并且需要从时间戳必须要从0开始。
-
随着时间尺度拉长,你会发现视频帧过快,音频帧追不上视频帧的速度,时间差距越来越大,当差距超过规定的时间差,对视频帧做丢帧处理
2. 暂停和恢复
如何对录屏进行暂停和恢复呢?
如果对mediaCodec停止重启或者对AudioRecord停止重启,都有可能失败的概率。为了保证不失败,笔者的做法是让音视频持续获取音频帧和视频帧,但是不做合成处理(即丢帧)
-
暂停:记录暂停时间,修改当前为暂停状态,对获取到的音频帧和视频帧多丢帧处理;
-
恢复:记录恢复时间,算出来此次暂停了多久时间,后续的所有视频帧和音频帧计算出来的时间戳都需要减去此次暂停的时间差
3. 花屏问题或者启动奔溃问题
(1) 长宽限制
设置录制的页面长宽需要被16除尽
(2) 启动崩溃
不同机型支持的分辨率是一样的,虽然你可以通过查看MediaCodecList支持的分辨率,但事实上获取到支持的分辨率和实际上支持的分辨率是不一致的,比如,某机型声称能支持1920,但实际上只能支持720。
解决方法可以设立不同flavor,对不同机型对应录制不同的分辨率
(3) 切换场景会出现糊或者花屏
出现的场景是在静态页面下,录制出来的页面是正常的,一旦出现界面跳转之类的动态页面就会花屏或者糊。
一般是由于帧率过高且手机性能较差,如果你对帧率不怎么了解,可以看下图,解决方法是降低帧率
4. 纯视频时候,播放速度过快问题
当只是录制纯视频不需要音量时候,一开始直接移除了音频帧的输入,仅保留视频帧,最后合成出来的mp4,播放的速度很快。
无论你如何调改时间戳也无济于事,最后笔者的处理方法是,继续引入音频帧,但是对获取的音频帧做处理成静音帧,即将对应的byte设置为0。如果你有更好的解决方法,希望能告诉我。
5. 异常处理
音视频的录制非常容易出现异常,基本都是native层的奔溃,大部分是由于状态不一致。
最后需要坚持的原则:
- 自己处理不了的异常要向上抛出,不要自己默默吃掉。
- 尽可能控制好异常的粒度,维护好状态,否则很容易出现多线程的问题,例如死锁
这里列举出音视频模块需要捕获异常的地方:
- MediaMuxer new创建和prepare()
- MediaMuxer.addTrack()添加通道
- MediaMuxer.start()启动合并
- MediaMuxer.stop()、release()释放动作
- MediaMuxer.writeSampleData()写入数据
- AudioRecord.start()启动录制
- AudioRecord.stop()、release()释放停止
- MediaCodec.start()硬编码
- MediaCodec.stop()、release()释放停止
- MediaCodec操作buffer,包括dequeueInputBuffer、getInputBuffer、queueInputBuffer、dequeueOutputBuffer、dequeueOutputBuffer
- VirtualDisplay.release()释放
由于分段录制的需要,需要非常频繁地启动、结束,所以非常遇到上述异常,一般在容易出现在启动阶段、例如AudioRecord无法启动等问题,解决方法是可以再次启动AudioRecord,因为非常短的时间内,AudioRecord的状态还没被修改,再次启动时,native会爆出状态错误的运行时异常。
6. MediaCodec数量限制、AudioRecord被占用
MediaCodec实际上是有数量限制的,不同平台支持的编解码器数量是不一样的,最少的6,最大的有32。所以在采取多线程并行编解码的时候是需要考虑到。
为了减少因为启动失败的问题导致中间较长时间无法录制,提出快速失败快速启动的方案,大概方案就是如果启动失败,即马上进入下次启动,不等待MediaCodec的释放停止,因为MediaCodec的启动是相对耗时的。
但是实际上由于MeidaCodec数量的限制,导致短时间的几次失败需要许多MediaCodec,最后放弃了这种方案。
同理,AudioRecord在同种类型下(例如麦克风)无法被同时占用的。
7. 绕过权限检测
MediaProjection是需要用户权限动态申请,为了优化用户体验,hook修改了绕过系统权限检测
public static Intent getProjectionIntent(Context context, final int uid, final String packageName) {
MediaProjectionManager mediaProjectionManager =
(MediaProjectionManager)context.getSystemService(MEDIA_PROJECTION_SERVICE);
try {
Object mediaProjectionManagerService =
ReflectionUtil.getPrivateField(mediaProjectionManager, "mService");
Object mediaProjection = ReflectionUtil.invokePrivateMethod(
mediaProjectionManagerService,
"createProjection",
new Class[]{
int.class, String.class, int.class, boolean.class},
uid,
packageName,
TYPE_SCREEN_CAPTURE,
false);
Object binder = ReflectionUtil.invokePrivateMethod(
mediaProjection,
"asBinder");
Intent intent = new Intent();
Bundle bundle = new Bundle();
bundle.putBinder(EXTRA_MEDIA_PROJECTION, (IBinder) binder);
intent.putExtras(bundle);
return intent;
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
8. 死锁问题
一般是由于异常的出现,导致状态混乱出现死锁问题,属于代码逻辑问题。
9. 录制系统声音
Android系统提供了几种类型的AudioRecord音频采集的来源:
- MediaRecorder.AudioSource.DEFAULT 默认
- MediaRecorder.AudioSource.MIC 主麦克风
- MediaRecorder.AudioSource.VOICE_UPLINK 上行声音
- MediaRecorder.AudioSource.VOICE_DOWNLINK 下行声音
- MediaRecorder.AudioSource.VOICE_CALL 语音拨出的语音与对方说话的声音
- MediaRecorder.AudioSource.CAMCORDER 同方向的相机麦克风相同,若相机无内置相机或无法识别,则使用预设的麦克风
- MediaRecorder.AudioSource.VOICE_RECOGNITION 语音识别
- MediaRecorder.AudioSource.VOICE_COMMUNICATION 摄像头旁边的麦克风
- MediaRecorder.AudioSource.REMOTE_SUBMIX 远程声音,例如wifi display
如果需要录制系统声音,可以利用REMOTE_SUBMIX进行录制。
那如何录制麦克风声音 + 系统声音?
Android无法同时选择录制麦克风+系统声音,如果你有好的解决方法,希望能告诉我。
10. 利用SurfaceControl替换MediaProjection
之前刚好看到scrcpy的源码,其中利用了SurfaceControl反射创建了Display,避开了权限检测,而且速度增快了。
使用SurfaceControl
11. 强制输出key帧
为了保证播放连续性,希望能够至少每秒都输出key帧,好处是可以直接在任意秒处直接播放。
// 设置key帧间隔
mediaFormat.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1000);
// 只在surface-input模式有用, 一帧之后多少毫秒如果没有新的数据进来,就重复这一帧
mediaFormat.setLong(MediaFormat.KEY_REPEAT_PREVIOUS_FRAME_AFTER, 1000_000 / getFramerate());
但是实际上发现,KEY_I_FRAME_INTERVAL并不一定生效,所以在每一秒过后检测是否是key帧,否则强制生成
// 是否是key帧
boolean keyFrame = (bufferInfo.flags & MediaCodec.BUFFER_FLAG_KEY_FRAME) != 0;
/**
* 强制生成key帧:播放时可以直接seek
*/
private void postKeyFrameEvent() {
Bundle params = new Bundle();
params.putInt(MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME, 0);
mMediaCodec.setParameters(params);
}
原创文章,作者:奋斗,如若转载,请注明出处:https://blog.ytso.com/6246.html