11、MediaExtractor与MediaMuxer:媒体文件解析、音视频轨道提取、音视频复用与封装
做播放器开发,绕不开两个核心工具——MediaExtractor 和 MediaMuxer。
说白了,一个负责拆,一个负责装。
MediaExtractor 把 MP4、MKV 这类容器格式拆开,把里面的音频轨道、视频轨道、字幕轨道一个个拎出来。MediaMuxer 则反过来,把独立的音频流和视频流重新封装成一个完整的媒体文件。
我刚开始接触这两个 API 时,觉得挺简单的——不就是读文件和写文件嘛。但真到项目里,坑一个接一个。比如轨道索引搞错了,出来的视频没声音;时间戳没对齐,画面和声音对不上。嗯,今天咱们就把这些细节掰开揉碎了讲清楚。
11.1 MediaExtractor:拆箱专家
MediaExtractor 的作用,是从一个媒体文件中提取出各个轨道的原始数据。它不关心编解码,只负责把压缩后的数据包(Sample)喂给你。
11.1.1 基本用法
先看一段最基础的代码,感受一下流程:
MediaExtractor extractor = new MediaExtractor();
try {
// 设置数据源,可以是文件路径、FD、URI
extractor.setDataSource(inputPath);
// 获取轨道数量
int trackCount = extractor.getTrackCount();
for (int i = 0; i < trackCount; i++) {
MediaFormat format = extractor.getTrackFormat(i);
String mime = format.getString(MediaFormat.KEY_MIME);
if (mime.startsWith("video/")) {
// 选中视频轨道
extractor.selectTrack(i);
break;
}
}
// 读取数据
ByteBuffer buffer = ByteBuffer.allocate(512 * 1024);
MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
while (true) {
int sampleSize = extractor.readSampleData(buffer, 0);
if (sampleSize < 0) break;
info.offset = 0;
info.size = sampleSize;
info.presentationTimeUs = extractor.getSampleTime();
info.flags = extractor.getSampleFlags();
// 处理这个Sample...
extractor.advance();
}
} finally {
extractor.release();
}
流程其实就三步:设置数据源 → 选择轨道 → 循环读取。
我个人习惯在读取之前先调用 seekTo(0, MediaExtractor.SEEK_TO_PREVIOUS_SYNC) 确保定位到关键帧,避免开头几帧花屏。
11.1.2 轨道选择与索引
这里有个容易踩的坑——轨道索引是固定的,但你不一定知道哪个索引对应什么类型。
我曾经在项目里直接写死 selectTrack(0),结果用户传了一个音频在前、视频在后的文件,直接崩了。后来我学乖了,每次都遍历 getTrackCount(),根据 MIME 类型动态选择。
| MIME 类型 | 轨道含义 | 常见编码 |
|---|---|---|
| video/avc | 视频轨道(H.264) | AVC / H.264 |
| video/hevc | 视频轨道(H.265) | HEVC / H.265 |
| audio/mp4a-latm | 音频轨道(AAC) | AAC |
| audio/opus | 音频轨道(Opus) | Opus |
另外要注意,一次只能选中一个轨道。如果你想同时读视频和音频,需要创建两个 MediaExtractor 实例,或者交替读取——但后者实现起来很麻烦,我一般直接开两个实例。
11.1.3 关键API说明
- setDataSource():支持文件路径、FileDescriptor、Content URI。注意网络流不支持,需要先下载到本地。
- getTrackFormat():返回 MediaFormat,里面包含编码类型、分辨率、采样率、帧率等信息。
- readSampleData():把当前 Sample 的数据读到 ByteBuffer 中。返回 -1 表示读取完毕。
- advance():移动到下一个 Sample。别忘了调用,否则会死循环。
- seekTo():定位到指定时间戳。模式有三种:
SEEK_TO_PREVIOUS_SYNC(前一个关键帧)、SEEK_TO_NEXT_SYNC(后一个关键帧)、SEEK_TO_CLOSEST_SYNC(最近的关键帧)。
SEEK_TO_CLOSEST_SYNC,然后手动解码到目标时间戳。虽然多花点性能,但精度最高。
11.2 MediaMuxer:封装大师
MediaMuxer 负责把独立的音频流和视频流写成一个容器文件。目前 Android 官方支持输出 MP4、WebM 和 3GPP 格式。
11.2.1 基本用法
MediaMuxer muxer = null;
try {
muxer = new MediaMuxer(outputPath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4);
// 添加轨道,获取轨道索引
int videoTrackIndex = muxer.addTrack(videoFormat);
int audioTrackIndex = muxer.addTrack(audioFormat);
// 开始封装
muxer.start();
// 写入视频数据
ByteBuffer videoBuffer = ...;
MediaCodec.BufferInfo videoInfo = ...;
muxer.writeSampleData(videoTrackIndex, videoBuffer, videoInfo);
// 写入音频数据
ByteBuffer audioBuffer = ...;
MediaCodec.BufferInfo audioInfo = ...;
muxer.writeSampleData(audioTrackIndex, audioBuffer, audioInfo);
// 结束
muxer.stop();
} finally {
if (muxer != null) muxer.release();
}
流程也很清晰:创建 Muxer → 添加轨道 → start → 写入数据 → stop。
这里有个关键点:必须在 start() 之前添加所有轨道。一旦 start() 被调用,就不能再 addTrack() 了。我刚开始写的时候,顺序搞反了,结果抛了个 IllegalStateException,排查了半天。
11.2.2 时间戳对齐
这是复用环节最容易出问题的地方。
MediaMuxer 要求写入的 Sample 必须按时间戳递增顺序提交。如果你先写了一个时间戳 1000 的视频帧,再写一个时间戳 500 的音频帧,Muxer 会直接报错。
解决办法是:在写入前对两个轨道的数据进行归并排序。我一般用一个优先级队列(PriorityQueue),按 presentationTimeUs 排序,谁的时间戳小就先写谁。
11.3 音视频复用实战:提取并重新封装
咱们来一个完整的例子:从一个 MP4 文件中提取视频轨道,然后和另一个 MP4 文件的音频轨道合并成一个新文件。
public void remux(String videoSource, String audioSource, String outputPath) {
MediaExtractor videoExtractor = new MediaExtractor();
MediaExtractor audioExtractor = new MediaExtractor();
MediaMuxer muxer = null;
try {
// 1. 设置视频源
videoExtractor.setDataSource(videoSource);
int videoTrackIndex = -1;
MediaFormat videoFormat = null;
for (int i = 0; i < videoExtractor.getTrackCount(); i++) {
MediaFormat format = videoExtractor.getTrackFormat(i);
if (format.getString(MediaFormat.KEY_MIME).startsWith("video/")) {
videoTrackIndex = i;
videoFormat = format;
videoExtractor.selectTrack(i);
break;
}
}
// 2. 设置音频源
audioExtractor.setDataSource(audioSource);
int audioTrackIndex = -1;
MediaFormat audioFormat = null;
for (int i = 0; i < audioExtractor.getTrackCount(); i++) {
MediaFormat format = audioExtractor.getTrackFormat(i);
if (format.getString(MediaFormat.KEY_MIME).startsWith("audio/")) {
audioTrackIndex = i;
audioFormat = format;
audioExtractor.selectTrack(i);
break;
}
}
// 3. 创建 Muxer
muxer = new MediaMuxer(outputPath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4);
int muxerVideoIndex = muxer.addTrack(videoFormat);
int muxerAudioIndex = muxer.addTrack(audioFormat);
muxer.start();
// 4. 读取并写入视频数据
ByteBuffer buffer = ByteBuffer.allocate(512 * 1024);
MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
while (true) {
int sampleSize = videoExtractor.readSampleData(buffer, 0);
if (sampleSize < 0) break;
info.offset = 0;
info.size = sampleSize;
info.presentationTimeUs = videoExtractor.getSampleTime();
info.flags = videoExtractor.getSampleFlags();
muxer.writeSampleData(muxerVideoIndex, buffer, info);
videoExtractor.advance();
}
// 5. 读取并写入音频数据
while (true) {
int sampleSize = audioExtractor.readSampleData(buffer, 0);
if (sampleSize < 0) break;
info.offset = 0;
info.size = sampleSize;
info.presentationTimeUs = audioExtractor.getSampleTime();
info.flags = audioExtractor.getSampleFlags();
muxer.writeSampleData(muxerAudioIndex, buffer, info);
audioExtractor.advance();
}
muxer.stop();
} catch (IOException e) {
e.printStackTrace();
} finally {
videoExtractor.release();
audioExtractor.release();
if (muxer != null) muxer.release();
}
}
这段代码有个明显的缺陷:先写完视频再写音频。如果视频时长比音频长,后半段音频就没了。更严谨的做法是交替写入,按时间戳排序。
不过在实际项目中,如果两个源文件的时长差不多,这种顺序写入的方式也能用。我曾在短视频剪辑工具里这么干过,用户反馈还不错。
11.4 核心流程与知识体系
下面这张图概括了 MediaExtractor 和 MediaMuxer 的协作关系:
11.5 避坑指南与经验总结
最后,把我这些年踩过的坑整理一下,希望能帮你少走弯路:
- 轨道索引别写死——不同文件的轨道顺序可能不同,一定要遍历判断 MIME 类型。
- 时间戳单位别搞错——微秒(μs)不是毫秒(ms),差1000倍。
- Muxer 的 start() 和 stop() 要成对调用——忘记 stop() 直接 release(),输出文件会损坏。
- ByteBuffer 要复用——每次读取都 new 一个 Buffer 会导致频繁 GC,影响性能。我一般预分配 512KB 的 Buffer,反复使用。
- 别忘了处理关键帧标志——
MediaCodec.BUFFER_FLAG_KEY_FRAME这个标志要正确传递,否则播放器可能无法定位。
核心总结:
MediaExtractor 负责拆,MediaMuxer 负责装。拆的时候注意轨道索引和 MIME 类型,装的时候注意时间戳递增和轨道添加顺序。这两个 API 本身不复杂,但细节决定成败。
我个人建议,在正式项目中使用之前,先写一个简单的复用 Demo,把视频和音频轨道互换一下,看看输出文件能不能正常播放。这一步能帮你发现 90% 的潜在问题。
公众号:蓝海资料掘金营,微信deep3321