11、MediaExtractor与MediaCodec结合:解码前的数据准备

说实话,很多初学者一上来就急着把MediaExtractor和MediaCodec拼在一起跑,结果各种崩溃、花屏、音画不同步。我当年也踩过这个坑——拿到一个MP4文件,直接塞给解码器,结果解码器报了个"什么鬼格式"的错误。嗯,问题就出在数据准备上。

这一章,我们就来聊聊解码前的那些准备工作。说白了,就是让MediaExtractor把数据喂给MediaCodec之前,你得先搞清楚三件事:数据格式对不对、数据轨道对不对、数据时间戳对不对

11.1 数据格式协商:别让解码器"吃错药"

MediaCodec是个挑剔的家伙。你给它什么格式的数据,它就得按那个格式来解码。如果你给的是H.264的码流,它却以为是H.265的,那结果就是崩溃。

所以,第一步就是从MediaExtractor里拿到媒体格式信息,然后告诉MediaCodec。

核心流程:

  1. 用MediaExtractor选中音视频轨道
  2. 从轨道中获取MediaFormat对象
  3. 把MediaFormat配置给MediaCodec

来看一段我常用的代码模板:

// 1. 初始化MediaExtractor并选中轨道
MediaExtractor extractor = new MediaExtractor();
extractor.setDataSource(filePath);

int trackIndex = selectTrack(extractor, "video/"); // 选视频轨道
extractor.selectTrack(trackIndex);

// 2. 获取轨道格式
MediaFormat format = extractor.getTrackFormat(trackIndex);

// 3. 配置MediaCodec
MediaCodec codec = MediaCodec.createDecoderByType(mimeType);
codec.configure(format, surface, null, 0);
codec.start();

这里有个细节:selectTrack方法需要你自己实现。我个人习惯遍历所有轨道,找到mimeType以"video/"或"audio/"开头的那个。为什么?因为有些文件里可能有字幕轨道、数据轨道,你选错了就白忙活了。

小技巧:MediaFormat里除了mimeType,还包含分辨率、帧率、采样率、声道数等关键信息。你可以在configure之前打印出来看看,确认数据是否正常。

11.2 数据读取与缓冲:喂给解码器的"一口一口"

格式协商好了,接下来就是真正的数据搬运了。MediaExtractor读取原始数据,塞进MediaCodec的输入缓冲区。

这个过程,我习惯用一个循环来处理:

ByteBuffer[] inputBuffers = codec.getInputBuffers();
int inputBufferIndex = codec.dequeueInputBuffer(TIMEOUT_US);

if (inputBufferIndex >= 0) {
    ByteBuffer buffer = inputBuffers[inputBufferIndex];
    int sampleSize = extractor.readSampleData(buffer, 0);

    if (sampleSize < 0) {
        // 数据读完了,发送结束标志
        codec.queueInputBuffer(inputBufferIndex, 0, 0, 0,
                MediaCodec.BUFFER_FLAG_END_OF_STREAM);
    } else {
        // 正常数据,带上时间戳
        codec.queueInputBuffer(inputBufferIndex, 0, sampleSize,
                extractor.getSampleTime(), 0);
        extractor.advance(); // 移动到下一帧
    }
}

你想想看,这个循环其实就是一个"生产者-消费者"模型。MediaExtractor是生产者,MediaCodec是消费者。中间通过缓冲区来传递数据。

我曾经在项目里遇到过一个诡异的问题:解码出来的视频总是慢半拍。查了半天,发现是extractor.advance()调用时机不对。我把它放在了queueInputBuffer之前,导致时间戳和实际数据错位了。嗯,这个坑我记了很久。

注意:readSampleData返回的是实际读取的字节数。如果返回-1,表示数据已经读完了。这时候要发送BUFFER_FLAG_END_OF_STREAM标志,告诉解码器:没数据了,你收工吧。

11.3 时间戳管理:音画同步的命根子

时间戳,是音视频同步的核心。MediaExtractor读取每一帧数据时,都会附带一个时间戳(微秒单位)。这个时间戳必须原封不动地传给MediaCodec。

为什么?因为解码器输出的时候,也会带上同样的时间戳。你后续做音画同步、渲染、播放进度控制,全靠这个时间戳来对齐。

来看一个典型的时间戳传递流程:

// 从MediaExtractor获取时间戳
long presentationTimeUs = extractor.getSampleTime();

// 传给MediaCodec
codec.queueInputBuffer(inputBufferIndex, 0, sampleSize,
        presentationTimeUs, 0);

这里有个容易忽略的点:时间戳必须是单调递增的。如果某个视频文件里出现了时间戳回退(比如从1000ms跳回500ms),解码器可能会崩溃或者输出乱序帧。

我记得有一次处理一个从网上下载的MP4文件,解码到一半就崩了。用工具分析后发现,那个文件的时间戳居然有负值!后来我在读取时加了一层保护:

long pts = extractor.getSampleTime();
if (pts < lastPts) {
    // 时间戳异常,跳过这一帧
    extractor.advance();
    continue;
}
lastPts = pts;

总结一下时间戳的要点:

  • 从MediaExtractor获取的时间戳,直接传给MediaCodec
  • 不要自己修改时间戳,除非你明确知道要做什么
  • 检查时间戳是否单调递增,避免异常数据导致崩溃

11.4 数据准备的整体流程

说了这么多,我们来画一张图,把整个数据准备的流程串起来。这样你脑子里会有一个清晰的脉络。

MediaExtractor → MediaCodec 数据准备流程 ① 选择轨道 extractor.selectTrack() ② 获取格式 extractor.getTrackFormat() ③ 配置解码器 codec.configure() ④ 循环读取数据 readSampleData() → queueInputBuffer() → advance() → 继续下一帧 ⑤ 时间戳管理 getSampleTime() 传递原始时间戳 ⑥ 数据就绪 等待解码器输出 关键检查点 ✓ 轨道类型正确 ✓ 格式信息完整 ✓ 时间戳单调递增

这张图把整个流程分成了6步。前3步是"一次性的配置工作",后3步是"循环执行的读取工作"。你写代码的时候,脑子里要有这个结构。

11.5 避坑指南:我踩过的那些坑

最后,分享几个我在项目中真实遇到过的坑,希望能帮你少走弯路。

坑1:忘记调用start()

配置完MediaCodec后,一定要调用codec.start()。我见过有人configure之后直接开始dequeueInputBuffer,结果一直返回-1。嗯,解码器还没启动呢。

坑2:缓冲区大小不够

有些视频的关键帧(I帧)特别大,可能超过默认的缓冲区大小。我建议在读取数据前,先检查一下buffer.capacity()是否足够。不够的话,可以重新分配一个更大的ByteBuffer。

坑3:多线程下的同步问题

如果你把数据读取和解码放在不同线程,一定要注意同步。我曾经用了一个简单的wait()/notify()机制来控制生产者和消费者的节奏。否则,要么缓冲区溢出,要么解码器饿死。

好了,数据准备这部分就聊到这里。说白了,就是"选轨道、拿格式、配解码器、循环读数据、带上时间戳"。把这些步骤理顺了,MediaExtractor和MediaCodec的配合就不会出大问题。


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