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 资源已释放
⚠️ 我曾经踩过的坑:在Recording状态下调用setOutputFile(),直接抛IllegalStateException。后来我养成了一个习惯——每次调用API前,先看一眼当前状态是否合法。特别是pause()和resume(),Android 5.0之后才支持,老版本上调用会直接crash。

状态切换的典型流程是这样的:

// 伪代码展示状态流转
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文件
🎯 核心设计思想:MPEG4Writer不关心数据是音频还是视频,它只关心“什么时候写,写多少”。同步逻辑完全由上层(MediaRecorder)通过时间戳控制。

我用SVG画了一张MPEG4Writer的内部结构图,方便你理解:

MPEG4Writer 内部架构 视频编码器输出 音频编码器输出 VideoTrack 缓冲区 AudioTrack 缓冲区 视频写入线程 音频写入线程 MP4文件 同步控制器(时间戳比对) 视频数据流 音频数据流 同步控制

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);
}
💡 小技巧:如果你发现录制的视频在播放时拖动进度条卡顿,很可能是关键帧间隔太大。建议设置关键帧间隔为2-3秒(比如帧率30fps,每60帧一个I帧)。太频繁会增大文件体积,太稀疏会影响seek体验。

16.4 录制流程的完整链路

把上面所有知识点串起来,一次完整的录制流程是这样的:

  1. 配置阶段:设置音频源、视频源、编码器、输出格式、输出文件
  2. 准备阶段:MediaRecorder调用prepare(),内部会创建AudioSource、CameraSource、MediaCodec编码器、MPEG4Writer
  3. 启动阶段:start()被调用,音频源和视频源开始采集数据,编码器开始工作,MPEG4Writer打开输出文件
  4. 录制阶段:数据流持续流动,MPEG4Writer不断写入mdat Box
  5. 停止阶段:stop()被调用,所有源停止采集,编码器flush剩余数据,MPEG4Writer写入moov Box并关闭文件

嗯,这里有个容易被忽略的点:stop()不是瞬间完成的。编码器里可能还有几帧数据没处理完,MPEG4Writer需要等待所有数据都写入后才能关闭文件。所以stop()可能会阻塞几百毫秒,千万别在UI线程里调用。

我曾经在一个项目里遇到过:用户点击停止录制按钮,界面卡了1秒钟才响应。查了半天,发现是stop()在UI线程里同步等待编码器flush。解决方案很简单——把stop()放到后台线程,或者用异步接口。

录制流程的复杂性,说白了就体现在“多条数据流的同步与资源管理”上。音频和视频各自独立运行,但最终要合二为一。MPEG4Writer就是那个“合”的地方,它用时间戳作为粘合剂,把两条河流汇入同一个MP4文件。


公众号:蓝海资料掘金营,微信deep3321