16. 录制流程与MediaRecorder:录制状态机、音频/视频源管理、MPEG4Writer与封装
说到Android的多媒体框架,大家往往更关注播放流程。但说实话,录制比播放复杂得多。播放是“读数据→解码→渲染”,一条直线走到底。录制呢?你得同时管理音频和视频两条时间线,要同步,要编码,要封装,还要处理各种硬件差异。
我个人习惯把录制流程比作“两条河流汇入一个水库”。音频是一条河,视频是另一条河,它们各自从源头(Camera/Mic)流过来,经过编码器加工,最后在MPEG4Writer这个“水库”里汇合,封装成一个完整的MP4文件。今天我们就来拆解这个过程。
16.1 MediaRecorder状态机:别乱调API
MediaRecorder的设计核心是一个严格的状态机。你想想看,如果录制还没准备好你就调用start(),那系统肯定崩溃。Android用状态机来保证API调用的合法性。
状态机一共有这些状态:
| 状态 | 说明 | 可调用的关键方法 |
|---|---|---|
| Initial | 初始状态,刚new出来 | setAudioSource(), setVideoSource() |
| Initialized | 数据源已设置 | setOutputFormat(), setAudioEncoder(), setVideoEncoder() |
| DataSourceConfigured | 编码器和输出格式已配置 | setOutputFile(), setPreviewDisplay(), prepare() |
| Prepared | 准备就绪,可以开始录制 | start() |
| Recording | 正在录制中 | stop(), pause(), reset() |
| Released | 资源已释放 | 无 |
状态切换的典型流程是这样的:
// 伪代码展示状态流转
MediaRecorder recorder = new MediaRecorder(); // Initial
recorder.setAudioSource(MediaRecorder.AudioSource.MIC); // → Initialized
recorder.setVideoSource(MediaRecorder.VideoSource.CAMERA); // 仍为Initialized
recorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4); // → DataSourceConfigured
recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC);
recorder.setVideoEncoder(MediaRecorder.VideoEncoder.H264);
recorder.setOutputFile("/sdcard/test.mp4");
recorder.prepare(); // → Prepared
recorder.start(); // → Recording
// ... 录制中 ...
recorder.stop(); // → Initialized (注意!不是回到Prepared)
recorder.reset(); // → Initial
recorder.release(); // → Released
这里有个细节很多人不知道:stop()之后状态回到Initialized,而不是Prepared。如果你想再次录制,必须重新调用setOutputFile()和prepare()。嗯,Google这么设计是有道理的——每次录制都是新的文件,旧的输出文件已经关闭了。
16.2 音频/视频源管理:两条河流的源头
音频源和视频源的管理,说白了就是“谁提供数据,谁控制节奏”。
16.2.1 音频源:CameraSource vs 独立AudioRecord
在Stagefright框架里,音频源有两种实现方式:
- CameraSource:早期版本使用,音频和视频绑定在一起。缺点很明显——灵活性差,你想换音频编码器?得改CameraSource的代码。
- AudioSource:基于AudioRecord的独立实现。这是目前的主流方案,音频和视频各自独立管理,通过时间戳同步。
我个人更推荐AudioSource方案。为什么?因为解耦。我在项目中遇到过一个问题:客户想用Opus编码器代替AAC,如果用CameraSource,得动整个录制管道;换成AudioSource,只需要换编码器配置就行。
音频源的核心逻辑在AudioSource::read()方法里:
// 简化后的AudioSource读取逻辑
status_t AudioSource::read(Vector<MediaBufferBase *> *buffers) {
// 1. 从AudioRecord读取原始PCM数据
size_t bytesRead = mAudioRecord->read(mBuffer, kBufferSize);
// 2. 生成时间戳(关键!)
int64_t timestamp = getTimestamp();
// 3. 封装成MediaBuffer,交给编码器
MediaBuffer *buffer = new MediaBuffer(mBuffer, bytesRead);
buffer->meta_data()->setInt64(kKeyTime, timestamp);
buffers->push_back(buffer);
return OK;
}
注意那个getTimestamp()。音频的时间戳不能直接用系统时间,得用AudioRecord的帧位置换算。否则音频和视频的时间基准不同,录出来的视频会音画不同步。嗯,这个坑我帮大家踩过了。
16.2.2 视频源:CameraSource的复杂之处
视频源比音频源复杂得多。CameraSource不仅要管理Camera硬件,还要处理预览和录制的双路流问题。
我记得有一次调试,发现录出来的视频画面比预览画面暗很多。查了半天,原来是预览用的是Camera的YUV直接输出,录制却走了编码器再解码回来,颜色空间转换出了问题。解决方案是:预览和录制使用同一个Surface,通过BufferQueue共享数据。
视频源的关键代码在CameraSource::dataCallback():
// Camera数据回调
void CameraSource::dataCallback(int32_t msgType,
const sp<IMemory> &data) {
// 1. 从Camera拿到原始帧
sp<MediaBuffer> buffer = new MediaBuffer(data);
// 2. 设置时间戳(基于帧率计算)
int64_t timestamp = mVideoTimeUs;
buffer->meta_data()->setInt64(kKeyTime, timestamp);
// 3. 交给编码器
mEncoder->feedRawInput(buffer);
// 4. 更新下一帧的时间
mVideoTimeUs += 1000000 / mFramerate;
}
这里有个性能陷阱:dataCallback()是在Camera的线程里调用的,如果你在这里做耗时操作,会阻塞Camera的帧率。我建议只做最轻量的处理,把编码工作交给编码器线程。
16.3 MPEG4Writer与封装:水库的建造者
MPEG4Writer是整个录制流程的终点站。它负责把编码后的音频和视频数据,按照MP4容器格式写入文件。
16.3.1 MPEG4Writer的内部架构
MPEG4Writer内部维护了两个Track(轨道):
- AudioTrack:管理音频数据
- VideoTrack:管理视频数据
每个Track都有自己的缓冲区队列和写入线程。数据流是这样的:
编码器输出 → Track缓冲区 → 写入线程 → MP4文件
我用SVG画了一张MPEG4Writer的内部结构图,方便你理解:
16.3.2 封装过程:从数据到MP4
MPEG4Writer的封装过程,说白了就是“写盒子”。MP4文件由一个个Box(也叫Atom)组成,每个Box有类型、大小、数据三个部分。
主要的Box有:
- ftyp:文件类型,告诉播放器“我是MP4文件”
- moov:元数据,包含所有轨道的信息(时长、编码参数、关键帧索引等)
- mdat:实际媒体数据(音频帧、视频帧)
这里有个重要的设计决策:moov Box放在文件头还是文件尾?
放在文件头(fast start模式)的好处是:播放器可以立即开始播放,不需要先下载整个文件。但代价是:录制过程中必须预分配moov的空间,或者录制结束后再重写文件头。
我建议:如果是实时流媒体场景,用fast start模式;如果是本地录制后编辑,用标准模式(moov在文件尾)就行,录制完成后用工具优化一下。
16.3.3 关键帧与同步
MPEG4Writer在写入视频数据时,会特别关注关键帧(I帧)。为什么?因为MP4的索引是基于关键帧的。如果你没有关键帧,播放器无法随机定位。
音频和视频的同步,靠的是时间戳比对。MPEG4Writer内部有一个同步控制器,它会比较当前音频帧和视频帧的时间戳:
// 同步逻辑伪代码
void MPEG4Writer::writeSample(Track *track, MediaBuffer *buffer) {
int64_t timestamp = buffer->meta_data()->findInt64(kKeyTime);
// 如果是视频轨道,检查是否有关键帧标记
if (track == mVideoTrack) {
bool isSync = buffer->meta_data()->findInt32(kKeyIsSyncFrame);
if (isSync) {
// 记录关键帧位置,用于moov Box的索引
mVideoTrack->addSyncPoint(timestamp, mCurrentOffset);
}
}
// 写入数据到mdat Box
writeToMdat(track, buffer);
// 更新轨道的时间范围
track->updateDuration(timestamp);
}
16.4 录制流程的完整链路
把上面所有知识点串起来,一次完整的录制流程是这样的:
- 配置阶段:设置音频源、视频源、编码器、输出格式、输出文件
- 准备阶段:MediaRecorder调用prepare(),内部会创建AudioSource、CameraSource、MediaCodec编码器、MPEG4Writer
- 启动阶段:start()被调用,音频源和视频源开始采集数据,编码器开始工作,MPEG4Writer打开输出文件
- 录制阶段:数据流持续流动,MPEG4Writer不断写入mdat Box
- 停止阶段:stop()被调用,所有源停止采集,编码器flush剩余数据,MPEG4Writer写入moov Box并关闭文件
嗯,这里有个容易被忽略的点:stop()不是瞬间完成的。编码器里可能还有几帧数据没处理完,MPEG4Writer需要等待所有数据都写入后才能关闭文件。所以stop()可能会阻塞几百毫秒,千万别在UI线程里调用。
我曾经在一个项目里遇到过:用户点击停止录制按钮,界面卡了1秒钟才响应。查了半天,发现是stop()在UI线程里同步等待编码器flush。解决方案很简单——把stop()放到后台线程,或者用异步接口。
录制流程的复杂性,说白了就体现在“多条数据流的同步与资源管理”上。音频和视频各自独立运行,但最终要合二为一。MPEG4Writer就是那个“合”的地方,它用时间戳作为粘合剂,把两条河流汇入同一个MP4文件。
公众号:蓝海资料掘金营,微信deep3321