9、MediaExtractor解封装:MP4文件解析、轨道分离、关键帧提取、Seek操作

各位同学,今天我们来聊聊MediaExtractor。说实话,这个API在Android多媒体开发里属于那种「看着简单,用起来全是坑」的类型。我最早接触它是在做一个短视频剪辑工具的时候,当时天真地以为解封装就是读个文件头,结果被各种MP4的奇葩封装方式折腾得够呛。

嗯,咱们先理清一个概念:解封装(Demuxer)解码(Decoder) 是两码事。解封装只是把容器格式里的音视频数据「拆」出来,不涉及压缩数据的解码。你想想看,MP4文件就像一个大箱子,里面装着视频轨道、音频轨道、字幕轨道,还有各种元数据。MediaExtractor就是帮你把这个箱子拆开,让你能拿到里面的原始压缩数据包。

核心要点:MediaExtractor只负责「拆包」,不负责「解包」。它输出的还是H.264、AAC这类压缩数据,要播放还得交给MediaCodec去解码。

9.1 MP4文件结构速览

在写代码之前,我觉得有必要先看一眼MP4的底层结构。不然你遇到一些诡异的文件,根本不知道问题出在哪。

MP4文件基于ISO Base Media File Format,核心结构是 Box(也叫Atom)。每个Box都有类型和大小,层层嵌套。常见的Box有:

  • ftyp:文件类型标识,告诉你这是MP4还是M4V
  • moov:元数据大本营,包含轨道信息、时长、采样率等
  • mdat:真正的音视频数据,体积最大
  • moof:出现在Fragmented MP4里,用于流式传输

我记得有一次,一个合作方发来的MP4文件在Android上死活打不开,用MediaExtractor一跑就抛异常。后来用十六进制工具一看,moov Box竟然被放在了文件末尾。这种「moov后置」的文件在录制过程中很常见,但有些播放器不兼容。解决办法?要么用工具把moov挪到文件头,要么在代码里做容错处理。

小技巧:用命令行 ffprobe -show_format input.mp4 可以快速查看MP4的Box结构,排查问题利器。

9.2 MediaExtractor基本用法

好了,直接上代码。MediaExtractor的使用流程其实就四步:

  1. 创建实例并设置数据源
  2. 选择轨道
  3. 循环读取数据包
  4. 释放资源

来看一个完整的示例:

// 1. 创建MediaExtractor
MediaExtractor extractor = new MediaExtractor();

// 2. 设置数据源(支持文件路径、FileDescriptor、URI)
extractor.setDataSource(filePath);

// 3. 获取轨道数量
int trackCount = extractor.getTrackCount();
Log.d("Demuxer", "轨道数量: " + trackCount);

// 4. 遍历轨道,找到视频轨道
int videoTrackIndex = -1;
for (int i = 0; i < trackCount; i++) {
    MediaFormat format = extractor.getTrackFormat(i);
    String mime = format.getString(MediaFormat.KEY_MIME);
    if (mime != null && mime.startsWith("video/")) {
        videoTrackIndex = i;
        break;
    }
}

// 5. 选中视频轨道
if (videoTrackIndex >= 0) {
    extractor.selectTrack(videoTrackIndex);
}

// 6. 读取数据包
MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo();
ByteBuffer buffer = ByteBuffer.allocate(512 * 1024); // 512KB缓冲区

while (true) {
    int sampleSize = extractor.readSampleData(buffer, 0);
    if (sampleSize < 0) break; // 读取完毕

    bufferInfo.set(0, sampleSize, extractor.getSampleTime(), extractor.getSampleFlags());
    
    // 这里可以处理数据,比如交给MediaCodec解码
    // ...

    // 前进到下一个采样点
    extractor.advance();
}

// 7. 释放
extractor.release();

这里有个细节要注意:readSampleData() 返回的是实际读取的字节数,如果返回 -1 说明已经读到文件末尾了。另外,getSampleTime() 返回的是微秒单位的时间戳,别跟毫秒搞混了——我见过有人直接拿这个时间戳去更新UI,结果进度条飞一样地跑。

9.3 轨道分离实战

在实际项目中,我们经常需要把视频文件和音频文件「拆开」处理。比如做视频剪辑时,你想替换背景音乐,那就得把原视频的音频轨道单独提取出来。

轨道分离的核心思路:为每个轨道创建一个独立的MediaExtractor实例。为什么?因为一个MediaExtractor同时只能选中一个轨道,你没法在一个实例上交替读取两个轨道的数据。

// 分离视频轨道
MediaExtractor videoExtractor = new MediaExtractor();
videoExtractor.setDataSource(inputPath);
int videoTrack = selectTrack(videoExtractor, "video/");
videoExtractor.selectTrack(videoTrack);

// 分离音频轨道
MediaExtractor audioExtractor = new MediaExtractor();
audioExtractor.setDataSource(inputPath);
int audioTrack = selectTrack(audioExtractor, "audio/");
audioExtractor.selectTrack(audioTrack);

// 然后分别读取两个轨道的数据
// 注意:两个提取器的时间戳是独立的,需要自己做同步

注意:不要试图用同一个MediaExtractor实例来回切换轨道。每次调用 selectTrack() 都会重置读取位置,而且频繁切换的性能开销很大。我曾经在项目里这么干过,结果帧率直接掉到个位数。

9.4 关键帧提取

关键帧(I帧)在视频处理中非常重要。做缩略图、Seek操作、视频拼接,都离不开它。MediaExtractor提供了 getSampleFlags() 方法来判断当前帧是不是关键帧。

int flags = extractor.getSampleFlags();
if ((flags & MediaExtractor.SAMPLE_FLAG_SYNC) != 0) {
    // 这是一个关键帧(I帧)
    Log.d("KeyFrame", "找到关键帧,时间戳: " + extractor.getSampleTime());
}

为什么要关注关键帧?因为非关键帧(P帧、B帧)依赖前面的帧才能解码。如果你随机Seek到一个非关键帧位置,解码器会因为没有参考帧而输出花屏或者绿屏。所以,Seek操作一定要对齐到关键帧

我个人习惯在提取缩略图时,先找到最近的关键帧,再解码。这样既保证了画面质量,又避免了花屏问题。

9.5 Seek操作详解

MediaExtractor的Seek方法有两个参数:timeUsmode。mode有三种:

模式 常量 行为
精确到关键帧 SEEK_TO_PREVIOUS_SYNC 跳到指定时间之前最近的关键帧
精确到关键帧(向后) SEEK_TO_NEXT_SYNC 跳到指定时间之后最近的关键帧
最接近时间 SEEK_TO_CLOSEST_SYNC 跳到离指定时间最近的关键帧
// 跳到第10秒的位置
long seekTimeUs = 10 * 1000 * 1000; // 10秒,单位微秒

// 方式1:跳到之前的关键帧
extractor.seekTo(seekTimeUs, MediaExtractor.SEEK_TO_PREVIOUS_SYNC);

// 方式2:跳到之后的关键帧
extractor.seekTo(seekTimeUs, MediaExtractor.SEEK_TO_NEXT_SYNC);

// 方式3:跳到最近的关键帧(推荐)
extractor.seekTo(seekTimeUs, MediaExtractor.SEEK_TO_CLOSEST_SYNC);

实际项目中,我推荐用 SEEK_TO_CLOSEST_SYNC。为什么?因为 PREVIOUS_SYNC 可能会跳到很靠前的位置,导致用户看到的画面跟预期时间差太多。而 NEXT_SYNC 又可能跳过一些关键内容。CLOSEST_SYNC 是最折中的选择。

经验之谈:Seek之后,记得调用 advance() 跳过当前帧。因为Seek操作本身不会改变读取位置,你需要先读一帧数据来「激活」新的位置。

9.6 核心流程总结

说了这么多,咱们用一张图把整个流程串起来:

MediaExtractor 解封装核心流程 1. 设置数据源 2. getTrackCount() 获取轨道数 3. selectTrack() 选择轨道 4. readSampleData() + advance() 循环 5. release() 释放资源 关键点 • 一个Extractor只能选一个轨道 • 轨道分离需要多个Extractor • Seek必须对齐关键帧 • 时间戳单位是微秒 • 读取完毕返回 -1 • 关键帧用SYNC标志判断 • 释放资源避免内存泄漏 • moov位置影响兼容性 • 缓冲区建议512KB以上 • 不要频繁切换轨道 • 支持文件/URI/Descriptor

9.7 避坑指南

最后,分享几个我踩过的坑:

  • 缓冲区大小:有些MP4文件的单个采样点特别大,尤其是4K视频。我建议缓冲区至少分配512KB,否则会抛出 BufferOverflowException
  • 时间戳溢出getSampleTime() 返回的是long类型,但有些老旧文件的时间戳会接近Long.MAX_VALUE。做加减运算时注意溢出。
  • 空轨道:有些MP4文件虽然声明了轨道,但实际没有数据。读取时记得检查 sampleSize 是否为0。
  • 资源泄漏:MediaExtractor用完后一定要调用 release(),否则文件句柄会一直占用。我曾经在循环里忘记释放,结果跑了几个小时手机就报「打开文件过多」的错误。

嗯,关于MediaExtractor的解封装就讲到这里。这些内容看起来基础,但每一个点在实际项目中都可能变成拦路虎。多写代码,多踩坑,慢慢就熟练了。

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