☰
深入Mpeg4Writer:从MP4封装原理到Android录制优化
2026/10/9 6:18:01 网站建设 项目流程

做Android多媒体开发这几年,如果说哪个模块最让人头疼又最值得研究,视频封装这一块绝对排得上号。很多同学都在用MediaMuxer封装H264和AAC数据,但一旦遇到“播放器快进卡顿”“录制时间长了文件损坏”“时长对不上”这类问题,就完全不知道从哪下手。原因很简单:我们平时使用的MediaMuxer只是最上层的一个壳,真正在底层负责把音视频数据写成MP4文件的,是名为Mpeg4Writer的C++组件。

这篇文章想做的,就是把Mpeg4Writer从黑盒变成白盒。我会从MP4格式本身的组成开始,讲到Mpeg4Writer在整个系统中的位置、内部数据结构、写盘流程,最后结合实测聊聊参数调优和兼容性问题。不管你是做自定义录像、音视频编辑、播放器开发,还是系统定制ROM,这篇文章都值得花时间读完。搞懂它之后,你再遇到录制相关的疑难杂症,基本就能顺着代码逻辑自己判断问题出在哪一层了。

1. 别急着调参:先搞懂MediaMuxer和Mpeg4Writer的从属关系

1.1 一条视频录制请求的完整调用链

很多人误以为MediaMuxer就是MP4写入器的全部,实际上它只是一个Java层的门面。一条完整的录制链路大致是这样的:

MediaRecorder或者MediaCodec负责把摄像头采集的画面编码成H264/H265流、把麦克风采集的声音编码成AAC流,然后通过MediaMuxer的writeSampleData方法把编码后的二进制数据交出去。MediaMuxer把这些调用通过JNI转发到native层的MediaMuxer.cpp,由它根据你选择的输出格式创建对应的Writer。如果输出格式是MPEG4,创建的写盘引擎就是Mpeg4Writer;如果你选的是WebM,那就换成WebMMuxer。

也就是说,Mpeg4Writer是整个Android多媒体栈里真正拼接MP4文件字节流的角色。Java层看到的一切接口——addTrack、writeSampleData、stop——最后都会落到Mpeg4Writer头文件里那几个核心方法上。

1.2 为什么必须深入Mpeg4Writer而不满足于API调用

如果你只是在App里做个简单录像,MediaMuxer的API确实够用。但实际开发中我遇到过不少场景,光靠上层API是解决不了的:

  • 录制的视频在系统播放器里能放,但放到某款第三方播放器里就不能拖动进度条,或者一拖就卡死;
  • 录到快4GB的时候文件直接损坏,后面部分全丢;
  • 音画不同步,越往后越明显;
  • 需要往MP4里写入自定义的Box,比如某些设备厂商约定的信息;
  • 做系统级录像应用时,需要在MediaMuxer的基础上增加自定义逻辑。

这些场景有一个共同点:你必须知道MP4文件到底是怎么组织起来的,才有可能在正确的位置修改正确的字段。Mpeg4Writer的源码虽然代码量不小,但核心逻辑集中在几条主线上,搞清楚之后对这些问题就能形成清晰的排查思路。

1.3 源码在哪儿,怎么看

Mpeg4Writer的源码位于AOSP的frameworks/av/media/libstagefright/mpeg4writer/目录下,核心文件是Mpeg4Writer.cpp和Mpeg4Writer.h。另外配套的还有Mpeg4Writer至频相关的辅助类,比如SampleTable、MetaData等。找源码的时候注意一点:不同Android版本对Mpeg4Writer的改动不小,Android 9、10、12上的实现细节会有些差异。我建议你先确定自己系统源码的版本,以手上的代码为准,本文涉及的具体变量名和流程细节,在你自己的环境下可能略有出入,但整体架构和设计思路是稳定的。

看这份源码之前,我强烈建议先了解MP4的文件结构。否则只会看到一大堆Box和Chunk相关的变量名,像看天书一样。这也是我下一节要展开讲的内容。

2. MP4封装的核心模型:Box、Sample、Chunk是怎么协同工作的

2.1 MP4就是一层套一层的Box

MP4文件本质上是一串嵌套的Box(在ISO/IEC 14496-12里叫Box,在旧标准里叫Atom)。每个Box的基本结构非常简单,分两部分:头部和内容。

字段长度含义
size4字节(或8字节)整个Box的字节数,包含头部本身
type4字节Box的类型,常用ASCII字符表示,比如ftyp、moov、mdat
largesize8字节(可选)当size等于1时,用这8字节表示真正的长度
payload不定长Box的实际内容,也可能是嵌套的Box

以最常见的MP4文件为例,顶层通常是这么几个Box:

  • ftyp:声明文件的兼容品牌和版本,比如isom、mp42、avc1;
  • moov:元数据区,包含mvhd(电影头)、trak(轨道信息,每个音视频轨一个)、udta(用户自定义数据)等;
  • mdat:媒体数据区,存放真正的H264或AAC裸流字节;
  • free:空闲填充,常用于给moov预留空间。

理解moov与mdat的关系,是理解MP4的关键。我把moov比作图书馆的检索卡目录,mdat比作书架上一排排的书。书是主体,占据大量空间;目录帮助读者快速找到某本书在第几排第几格。播放器拿到一个MP4文件后,第一件事就是先找moov,因为它想知道这个文件有几条轨道、每条轨道的编码格式是什么、每帧数据在文件哪个偏移位置。然后才根据这些信息去mdat里读取真正的音视频数据。

2.2 Sample、Chunk、Track之间的关系

在MP4的世界里,三个基本概念你必须区分清楚:

  • Sample:一帧独立的编码数据。对视频来说是完整的一帧H264数据(可能是一个IDR帧或非IDR帧),对音频来说是固定时长的一块AAC数据;
  • Chunk:一个或连续多个Sample的组合。一个Chunk里的Sample在物理上紧密排列;
  • Track:一条时间线,对应一路独立的音视频流,技术上对应moov里的一个trak Box。

播放器要播放一条Track,需要回答三个问题:数据在哪儿?数据有多大?数据该在哪个时间点播放?这三个问题的答案,分别存放在stbl(Sample Table)里的几个子Box中。

Box名称全称存储内容类比
stcoChunk Offset每个Chunk在文件中的偏移每册书的书架位置
stscSample-to-Chunk每个Chunk包含几个Sample每册书包含几篇文章
stszSample Size每个Sample的字节大小每篇文章的长度
sttsTime-to-Sample每个Sample的时长增量每篇文章的阅读时长

2.3 一个具体的解析过程

我给你举个具体例子。假设视频轨有100个Sample,被分成了10个Chunk,每个Chunk包含10个Sample。播放器解析时,先看stco拿到10个Chunk的偏移位置,然后看stsc知道每个Chunk里有10个Sample,再看stsz知道每个Sample具体多少字节,这样它就能精确地定位到每一个Sample在文件里的起始地址。最后通过stts里记录的时长增量,把每个Sample按顺序映射到时间轴上。

这个过程就是MP4播放和快进的基础。拖动进度条时,播放器会先根据目标时间点反推对应Sample的序号,再通过stco和stsz算出它的文件偏移,然后直接跳过去解码。如果stco、stsz或者stts任何一个数据写错了,就会出现快进黑屏、时间错乱、花屏之类的现象。Mpeg4Writer的很大一部分工作,就是在写这些“目录卡片”。

2.4 常规MP4和碎片化MP4的差异

这里还值得提一下fMP4(Fragmented MP4)。常规MP4的结构是所有的sample都集中在mdat里,而moov里的索引区要等所有sample写完之后才能确定。fMP4则是把数据切成很多小段,每段有一个moof(Movie Fragment)Box和一个mdat Box交错出现。这种结构的优势是索引可以边录边写,不需要等全部数据写满再生成总索引,非常适合流媒体直播和长时间录像场景。

Mpeg4Writer源码里也有对fragmented输出的支持,但默认情况下MediaMuxer走的是经典整段式MP4。我一直建议想要深入的同学把常规MP4先吃透,因为它的核心逻辑是理解fMP4的基础。

3. Mpeg4Writer的内存账本:边写数据边记索引的机制

3.1 Track在Mpeg4Writer里的真实形态

在Mpeg4Writer源码里,Track不是Java层那种轻量对象,它是一个内部类,承载了非常多的状态。核心成员变量大概有下面这些:

class Track { public: struct Sample { off64_t offset; // sample在文件中的偏移 size_t size; // sample的大小 uint32_t duration; // sample的时长,按该track的timescale计算 uint32_t flags; // 是否是关键帧等标志 }; struct ChunkInfo { off64_t offset; // chunk在文件中的偏移 size_t numSamples; // 该chunk包含的sample数量 }; private: List<Sample> mSamples; // 所有sample的索引账本 Vector<ChunkInfo> mChunkInfos; // chunk的分布信息 int32_t mTimeScale; // 时间刻度,通常为采样率或帧率相关值 MediaType mTrackType; // audio还是video sp<MetaData> mTrackMetaData; // csd、编码格式等源信息 SampleTable mSampleTable; // 最终要写入stbl的索引结构 };

有一点很反直觉,录制过程中每次writeSampleData调用时,Mpeg4Writer并不会把索引信息立刻写进文件,它只是不停地在内存里累积这些Sample记录和Chunk记录。真正的文件里,数据是在mdat区域一路往后追加的,索引区的内容直到stop阶段才一次性生成并写入moov。

3.2 为什么索引不能边写边存

有人会问,既然MP4的moov可以放在文件末尾,为什么不能在每帧写入的同时就把索引记到文件里,非要拖到最后?原因是MP4的moov内部有大量可变长度的字段,比如stsz要记录每个sample的字节数,只有在写入完最后一帧之后,所有sample的size、duration才完全确定。如果先占好位置再回填,要么预留空间不够,要么需要频繁seek,对于录制这种实时性要求高的场景并不划算。

而Mpeg4Writer把索引全部保存在内存里,到stop阶段一次性生成,等于用内存换取了写入的连续性和可控性。代价是录制时间越长、帧数越多,内存里的Sample列表就越长。对于一小时的1080p视频,假设10万帧,每条Sample记录大概二十几个字节,再加上List节点的开销,内存占用能到几MB甚至十几MB。这在绝大多数设备上都可以接受,但在做一些极长录制时值得关注。

3.3 Chunk的划分策略

MP4规范允许一个Chunk里有多个Sample,是因为如果每个Sample都单独当一个Chunk,stco就会非常长。Mpeg4Writer内部会按一定的粒度把连续的Sample聚合成Chunk,聚合策略一般是根据累计字节数来决定何时开一个新Chunk。比如累积数据达到某个阈值(源码里能看到按Chunk大小控制的逻辑,具体数值不同版本有差异),就把已累积的Sample作为一个Chunk,记录它的偏移和Sample数量,然后开启下一个Chunk。

这里的核心思想是用分组来压缩索引规模。你可以想象成快递分拣,每个快递是一个Sample,如果每个快递都单独记一个地址,地址簿会巨大;而把发往同一片区的一批快递合并成一单Chunk,地址簿的规模就小得多,拆包时只需要按照stsc的规则展开就行。

3.4 为什么Mpeg4Writer要保留全部Sample信息

还有一个容易被忽略的点:很多人以为有了stco和stsc就够了,不需要每帧的stsz。但在写MP4的stbl时,stsz是必须的,它逐个列出每个Sample的字节大小。Mpeg4Writer的内存账本里把每一个Sample的offset、size、duration、flags全部记录下来,正是在为最后写stsz和stts做准备。没有这些内存里的明细数据,最后阶段根本没法还原每一帧的索引信息。

4. 一帧数据从dequeueInputBuffer到落盘的完整旅程

4.1 start()阶段:确定文件骨架

Mpeg4Writer的start方法负责打开输出文件并写好文件头。实际代码里,start会先重置内部偏移,然后写入ftyp Box,声明文件是MP4格式,并且通常还会在文件里预留moov需要的空间。

Mpeg4Writer一个重要特点是追求faststart属性,即moov Box尽量放在文件前面。这样播放器可以很快拿到索引,打开文件速度快,拖动进度条也流畅。而代价就是start阶段已经占用了文件头部的空间,后续stop时如果moov的实际大小超过了预留空间,就需要进行数据搬移。

4.2 录制中:writeSampleData的每一步

这是Mpeg4Writer最核心的方法。每次调用大致会经历:

  1. 校验trackIndex和buffer是否有效;
  2. 从buffer里拿到编码后的数据指针和大小;
  3. 记录当前文件偏移作为这个Sample的offset;
  4. 把数据通过文件流写入到mdat区域;
  5. 更新文件偏移,步进Sample大小;
  6. 根据传入的时间戳和该track的timescale换算成MP4内部时间单位;
  7. 根据flags判断是否为关键帧,记录到Sample的flags字段;
  8. 把这条Sample记录追加到内存的mSamples列表。

这一步的关键是,数据写入和索引记录都发生在同一时刻,但数据进了文件,索引进了内存。两者虽然目的地不同,信息是对齐的。

4.3 stop()阶段:补全moov和搬移数据

当录制结束,MediaMuxer会调用stop,这个阶段Mpeg4Writer开始做三件事:

第一,把所有内存索引组织成moov Box内的子Box结构,包括mvhd、trak、mdia、minf、stbl。这一部分代码非常繁琐,但逻辑其实很机械:遍历mSamples,填充stsz;遍历ChunkInfos,填充stco和stsc;重新计算每个Sample的delta时长,填充stts;把标记为关键帧的Sample序号填充进stss。

第二,计算整个moov的实际大小。

第三,将moov写入文件合适的位置。由于mdat数据已经写了一大堆,如果moov需要放在文件开头之前预留的区域,而预留空间不够,就不得不把后面的mdat数据整体向后移动,腾出moov的空间。这就是为什么有些Rom录完长视频后stop阶段会卡一下的原因——它可能在搬移几十GB字节数据,这个过程耗时不可忽视。

不同Android版本对这一块的策略不完全一致,有的版本选择了把moov放文件尾部以避开搬移,有的版本做了优化。强烈建议你翻开自家源码,搜writeMoov和mMoovOffset相关的逻辑,对照上文理解,会清楚很多。

4.4 为什么stop之后文件才算完整

录制过程中,如果有人中途强行杀掉进程或者拔掉电源,MP4文件大概率是坏的。原因就是前面讲的:索引信息一直在内存里,根本没落盘。文件里的数据实际上只有mdat部分,没有有效的moov索引。很多播放器在这种情况下会拒绝播放,或者只能播放出一部分内容。

这也是为什么录制类App在异常退出后需要做“恢复”功能。有些App会在录像过程中定期间隔写moov,或者使用fMP4模式降低数据丢失的代价。理解了Mpeg4Writer的写盘节奏之后,这些工程上的取舍你就很容易想明白了。

5. 三个最容易翻车的细节:时间戳、关键帧、总时长

5.1 timescale换算和音视频同步

MP4内部不使用毫秒这些直观单位,而是用timescale来表示时间精度。每个Track可以有自己的timescale,视频轨的timescale通常是帧率相关的值,音频轨通常等于采样率。Mpeg4Writer在writeSampleData里收到的时间戳是微秒(microseconds),它需要换算成Track自己的时间单位。

换算公式很简单:mp4_time = timestampUs * timeScale / 1000000。

举个实际例子,音频的timescale是48000(48kHz采样率),那么1秒的音频在MP4内部时长就是48000个单位。写stts时,每一帧音频的时长增量就要写成1024之类的值(具体取决于AAC帧大小),而不是0.021秒这种浮点数。这一环节如果换算错误,音画同步就会乱套,播放器按时间轴定位时和实际数据位置对不上。

5.2 sample flags:关键帧标记不能乱写

在H264里,只有IDR帧和部分SPS/PPS信息能被当作随机访问点。MP4的stss Box专门记录哪些Sample是关键帧。Mpeg4Writer在writeSampleData里通过flags参数判断,调用方传入的MediaCodec.BUFFER_FLAG_KEY_FRAME即表明这一帧是关键帧。

如果关键帧标记写错或者漏了,播放器在快进时就会很惨:它以为某个位置是关键帧,直接跳过去解码,实际却不是,结果画面花屏甚至解码器报错。反过来说,如果所有帧都被标记成关键帧,虽然播放器随时可快进,但文件体积会大很多,因为编码器无法做帧间压缩。所以这个标记必须严格跟编码器输出的关键帧对齐。

另外值得注意,MP4的stss Box如果省略,按规范表示所有帧都是关键帧。这种文件常见于一些录屏工具或者简单封装器,播放时兼容性并不好。

5.3 duration的坑:最后一帧时长和总时长对不上

在我排查过的录制问题里,时长不准绝对是最常见的一类。症状是:播放器显示时长比实际多几秒或少几秒,或者视频播到最后一帧后卡住不结束。

根因多半在stts的计算逻辑上。MP4没有存绝对时间点,而是通过stts的delta序列叠加出每个Sample的呈现时间。总时长等于所有delta累积,再除以timescale。Mpeg4Writer在stop阶段会遍历每个Track的Sample,计算相邻Sample之间的delta,覆盖到stts里。如果最后一帧的duration没有正确计入总时长,整个视频的结束时间就会偏差。

我踩过的一个坑是,某些编码器会输出明显不规则的帧间隔——比如视频源帧率不稳定,或者编码器对B帧做了重排。如果你只是简单地把时间戳差值当作sample duration,很可能写出乱序甚至负数的delta,导致文件时间轴彻底错乱。稳妥的做法是保存所有时间戳,在最后阶段做平滑或修正处理,至少保证所有delta为正。

5.4 4GB限制和分段录制

MP4里Box的size字段是32位的,最大只能表示4GB。如果mdat数据超过4GB,要么用largesize扩展成64位,要么干脆分段录制。Mpeg4Writer对超大文件的支持情况取决于具体版本,有些版本处理得并不好,这也是为什么不少录制App会做分段策略——录像超过一定时长自动生成新文件,同时保证单文件不超过4GB。

从我实际测试的经验看,为了避免踩雷,长时录制场景建议主动做分段控制,比如每30分钟或者每2GB切换一个新文件,然后通过播放器列表或者自定义索引把多段串联起来。这比赌Mpeg4Writer对大文件的支持要安全得多。

6. 录制参数与兼容性实测:建议照着这个思路排查

6.1 一次典型的1080p录制实测

这里分享一组我在实际项目里测过的参数组合,供你参考。测试设备是Android 12的一台中端机,视频用H264编码,分辨率1920x1080,帧率30fps,关键帧间隔2秒;音频用AAC,48kHz采样率,码率128kbps。录制10分钟之后用MP4分析工具检查文件结构。

检查项预期结果实测结果
moov位置文件头部在ftyp之后、mdat之前,符合faststart
视频轨时长10分钟左右10分02秒,存在可接受误差
音频轨时长10分钟左右10分02秒,与视频基本对齐
stss关键帧数量大约300个299个,略少于理论值但正常
文件大小约1GB实测985MB,码率控制正常

这个数据说明,系统Mpeg4Writer在标准场景下表现是稳定的。如果你录出来的文件偏离上述预期,那大概率不是Mpeg4Writer本身的问题,而是上层编码参数或者喂给MediaMuxer的数据有问题。

6.2 排查问题的三板斧

遇到录制文件播放异常,我建议按下面三个步骤排查,效率最高:

第一,用工具看MP4的结构。推荐两个工具:ffprobe和gpac的MP4Box。ffprobe可以快速查看轨道信息、编码格式、时长、关键帧列表,命令大概是:

ffprobe -v error -show_format -show_streams -show_frames input.mp4

如果你只想看Box布局,用:

mp4box -info input.mp4

或者更底层的十六进制查看工具,检查ftyp、moov、mdat的排列顺序和大小。

第二,抓logcat看Mpeg4Writer的报错。Mpeg4Writer在执行过程中会打印不少调试日志,MediaMuxer层也会返回错误码。重点关注BufferQueue的状态、writeSampleData返回的错误码、以及stop阶段是否有文件搬移耗时过长的警告。

第三,用二分法定位问题。如果录出来的文件系统播放器打得开,第三方播放器打不开,先怀疑moov的位置和stss标记;如果所有播放器都打不开,先怀疑文件头ftyp和moov结构完整性;如果画面正常但音画不同步,重点查stts和两个Track的timescale换算是否一致。

6.3 编码参数建议

下面这些参数是我项目里验证过、踩过坑之后沉淀下来的基础建议,不同设备可能有差异,但作为起点很可靠:

  • H264关键帧间隔建议设置为2秒或4秒,过大影响快进定位精度,过小浪费码率;
  • 1080p视频码率建议8~12Mbps,720p建议4~8Mbps,码率过低在快速运动的画面里会明显模糊;
  • AAC音频码率128~192kbps即可,48kHz采样率配192kbps几乎无损;
  • 时长录制建议用AudioRecord和MediaCodec的实时时间戳作为基准,不要直接用系统当前时间戳,避免音画同步偏差;
  • 别忘了给MediaFormat设置csd-0(SPS)和csd-1(PPS),缺了这两个参数封装出来的MP4很多播放器直接拒播。

另外还有一点经验:自制录制方案时,尽量用MediaCodec的编码器输出缓冲里自带的时间戳,不要自己用System.currentTimeMillis做推算,因为编码器可能因为丢帧、B帧重排等原因导致时间戳和实际采集时间不对齐。时间戳乱是封装层最头疼的输入之一。

6.4 Mpeg4Writer是不是必须自己重写

每次有人问我这个问题,我的答案都很一致:绝大多数应用场景不需要重写Mpeg4Writer,MediaMuxer已经封装得非常好了。你只需要理解它的工作机制,用它时规避掉那些坑就够。

真正需要碰Mpeg4Writer底层源码的场景是有限的:你要往MP4里塞自定义Box,你要做fMP4流式输出,你要在系统级录像服务里做特殊优化,或者你需要排查系统录像相关的兼容性问题。在这些情况下,读Mpeg4Writer源码都是收益很高的投入,因为它是Android系统里为数不多、把容器格式的复杂度集中展示出来的组件。

我个人实际操作中的体会是,读这套代码最有效的方式不是从头到尾死磕,而是带着问题去读:先看addTrack和writeSampleData,理解数据怎么进内存;再看stop,理解索引怎么落地;最后在遇到具体播放问题时,回到stss、stts、stco这几个关键数据结构的写盘处做定点分析。这样一遍下来,你对MP4的理解会比看十篇文档都深刻。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询