11、MediaExtractor与MediaMuxer:媒体文件解析、音视频轨道提取、音视频复用与封装

做播放器开发,绕不开两个核心工具——MediaExtractorMediaMuxer

说白了,一个负责拆,一个负责装。

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 排序,谁的时间戳小就先写谁。

注意: 时间戳的单位是微秒(μs),不是毫秒。1秒 = 1_000_000 微秒。我曾经把毫秒当微秒传进去,结果生成的视频播放速度快了1000倍,画面一闪而过。

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 的协作关系:

MediaExtractor 与 MediaMuxer 核心流程 输入文件 MP4 / MKV / WebM MediaExtractor 解析 → 选择轨道 → 读取Sample 视频轨道 H.264 / H.265 音频轨道 AAC / Opus MediaMuxer 添加轨道 → start → 写入Sample 输出文件 MP4 / WebM ⚠ 关键点: 时间戳必须递增 | 轨道索引动态获取 | start()前完成addTrack() 💡 个人经验:用PriorityQueue做归并排序,避免时间戳乱序导致Muxer报错

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