5、数据读取与提取:读取压缩数据包、提取到文件、内存缓冲区操作

好,咱们接着往下走。上一章我们把 MediaExtractor 初始化好了,也选好了轨道。现在最关键的一步来了——怎么把数据真正读出来?

说实话,我早期做音视频开发时,觉得这步不就是调个 readSampleData() 嘛,有啥好讲的?结果第一次做视频剪辑功能,就栽在了缓冲区管理上。嗯,这里面的坑,比你想的要多。

5.1 读取压缩数据包的核心流程

MediaExtractor 读取数据,本质上是一个「拉模式」的循环。你调用一次 advance(),它就跳到下一个样本。你调用 readSampleData(),它就把当前样本的数据塞进你准备好的 ByteBuffer 里。

我习惯把整个流程画成下面这张图,你一看就明白:

MediaExtractor 数据读取循环 1. seekTo(位置) 定位到指定时间 2. readSampleData() 读取到 ByteBuffer 3. 处理数据 解码/保存/分析 4. advance() 移动到下一帧 还有数据? 读取完毕 核心逻辑:循环调用 readSampleData() → 处理 → advance(),直到返回 false

你看,这个循环其实很直观。但为什么很多人写出来的代码要么内存溢出,要么丢帧?问题往往出在缓冲区管理上。

5.2 内存缓冲区操作——ByteBuffer 的正确用法

MediaExtractor 本身不管理内存,它只管往你给的 ByteBuffer 里写数据。所以你得自己准备好缓冲区。我见过不少新手直接 new 一个 ByteBuffer,然后反复用同一个,结果数据被覆盖了都不知道。

核心原则:每次读取前,确保 ByteBuffer 有足够的容量,并且 position 和 limit 处于正确状态。

我个人习惯用 ByteBuffer.allocate(capacity) 来创建缓冲区。容量怎么定?对于视频轨道,一帧压缩数据的大小通常在几十 KB 到几百 KB 不等。我一般取 1MB 作为默认值,够用且不浪费。

// 我常用的缓冲区初始化方式
ByteBuffer buffer = ByteBuffer.allocate(1024 * 1024); // 1MB 缓冲区
MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();

while (true) {
    // 每次读取前,清空缓冲区状态
    buffer.clear();
    
    // 读取样本数据
    int sampleSize = extractor.readSampleData(buffer, 0);
    if (sampleSize < 0) {
        break; // 没有更多数据了
    }
    
    // 获取样本信息
    info.set(0, sampleSize, extractor.getSampleTime(), extractor.getSampleFlags());
    
    // 处理数据...
    // 注意:此时 buffer 的 position 是 sampleSize,limit 是 capacity
    
    // 移动到下一帧
    extractor.advance();
}

小技巧:每次循环开始前调用 buffer.clear() 非常重要。它把 position 重置为 0,limit 重置为 capacity。如果不做这一步,readSampleData 会从错误的位置开始写数据。

5.3 提取到文件——把压缩数据存下来

很多时候,我们不是要解码播放,而是要把某个时间段的视频片段提取出来保存成文件。比如做视频剪辑、截取 GIF 素材等。

提取到文件的核心思路很简单:把上面循环中「处理数据」那一步,改成写入 FileOutputStream 即可。但这里有个坑——你写的是压缩数据,不是原始像素。所以文件后缀名要对应,比如 .h264、.aac。

我曾经帮一个团队排查问题,他们提取出来的视频文件播放不了。查了半天,发现是写入时忘了加 buffer.flip()。你想想看,ByteBuffer 在 readSampleData 之后,position 指向数据末尾,直接写 FileOutputStream 会写出一堆空数据。

// 提取视频轨道到文件
FileOutputStream fos = new FileOutputStream("output.h264");
ByteBuffer buffer = ByteBuffer.allocate(1024 * 1024);

while (true) {
    buffer.clear();
    int sampleSize = extractor.readSampleData(buffer, 0);
    if (sampleSize < 0) break;
    
    // 关键:flip 一下,把 position 重置到 0,limit 设为 sampleSize
    buffer.flip();
    
    // 写入文件
    byte[] data = new byte[sampleSize];
    buffer.get(data);
    fos.write(data);
    
    extractor.advance();
}
fos.close();

注意:直接写入文件时,你得到的是纯 ES 流(Elementary Stream),没有封装格式信息。播放器可能无法直接识别。如果需要封装成 MP4,建议用 MediaMuxer 来写。

5.4 内存缓冲区操作——进阶技巧

除了简单的读写,实际项目中我们经常需要对缓冲区做更精细的操作。比如:

  • 复用缓冲区:ByteBuffer.allocateDirect() 分配直接内存,减少 JNI 拷贝开销。我测过,在 Android 上直接内存比堆内存快 20%-30%。
  • 分片读取:如果一帧数据特别大(比如 4K 视频),可以分多次读取。但 MediaExtractor 不支持分片,所以缓冲区要一次性够大。
  • 零拷贝:把 ByteBuffer 直接传给 MediaCodec 解码,避免中间数组拷贝。这个后面章节会细讲。
// 使用直接内存缓冲区
ByteBuffer directBuffer = ByteBuffer.allocateDirect(1024 * 1024);

// 读取数据
int size = extractor.readSampleData(directBuffer, 0);
if (size > 0) {
    // 直接传给 MediaCodec
    int inputIndex = codec.dequeueInputBuffer(10000);
    if (inputIndex >= 0) {
        ByteBuffer inputBuffer = codec.getInputBuffer(inputIndex);
        inputBuffer.clear();
        inputBuffer.put(directBuffer); // 直接拷贝,无需 byte[]
        codec.queueInputBuffer(inputIndex, 0, size, extractor.getSampleTime(), 0);
    }
}

我的经验:在 Android 8.0 以上,MediaCodec 支持直接传入 ByteBuffer 的引用,不需要拷贝。但低版本上还是得走 getInputBuffer() 再 put。所以兼容性代码要写两份。

5.5 避坑指南——我踩过的那些雷

做音视频开发,缓冲区操作是重灾区。我把自己踩过的坑列出来,你遇到了直接对照排查:

问题 原因 解决方案
读取到的数据全是 0 没有调用 buffer.clear(),position 在末尾 每次循环前 clear()
文件写入后播放花屏 没有处理 CSD(Codec Specific Data) 在第一个样本前,先读取并写入 sps/pps
内存溢出 缓冲区分配太大,或者没有及时释放 用 1MB 足够,用完置 null 让 GC 回收
提取的视频时长不对 没有用 getSampleTime() 记录时间戳 每次读取后保存时间戳,用于后续封装

我曾经在一个直播项目中,因为忘了处理 CSD 数据,导致提取出来的 H.264 文件在 VLC 上能播,但在系统播放器上就花屏。查了两天才发现是缺少 sps/pps 头。从那以后,我每次提取视频流都会先检查 getSampleFlags() 是否包含 MediaCodec.BUFFER_FLAG_CODEC_CONFIG

// 处理 CSD 数据的正确姿势
if ((extractor.getSampleFlags() & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != 0) {
    // 这是编码配置数据(sps/pps),必须保存
    buffer.flip();
    byte[] csd = new byte[buffer.remaining()];
    buffer.get(csd);
    // 保存到文件或传给 MediaCodec
    saveCSD(csd);
}

5.6 实战:提取音频并保存为 AAC 文件

最后,咱们来个完整的例子。把音频轨道提取出来,保存成 AAC 裸流文件。注意,AAC 的 ADTS 头需要自己加,不然播放器认不出来。

public void extractAudioToFile(String inputPath, String outputPath) throws IOException {
    MediaExtractor extractor = new MediaExtractor();
    extractor.setDataSource(inputPath);
    
    // 选择音频轨道
    int audioTrackIndex = -1;
    for (int i = 0; i < extractor.getTrackCount(); i++) {
        MediaFormat format = extractor.getTrackFormat(i);
        String mime = format.getString(MediaFormat.KEY_MIME);
        if (mime != null && mime.startsWith("audio/")) {
            audioTrackIndex = i;
            break;
        }
    }
    if (audioTrackIndex == -1) {
        throw new IOException("No audio track found");
    }
    extractor.selectTrack(audioTrackIndex);
    
    // 准备输出
    FileOutputStream fos = new FileOutputStream(outputPath);
    ByteBuffer buffer = ByteBuffer.allocate(1024 * 1024);
    
    // 获取音频配置信息
    MediaFormat audioFormat = extractor.getTrackFormat(audioTrackIndex);
    int sampleRate = audioFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE);
    int channelCount = audioFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT);
    
    while (true) {
        buffer.clear();
        int sampleSize = extractor.readSampleData(buffer, 0);
        if (sampleSize < 0) break;
        
        buffer.flip();
        
        // 如果是 AAC,需要添加 ADTS 头
        if (audioFormat.getString(MediaFormat.KEY_MIME).equals("audio/mp4a-latm")) {
            byte[] adtsHeader = createADTSHeader(sampleSize, sampleRate, channelCount);
            fos.write(adtsHeader);
        }
        
        byte[] data = new byte[sampleSize];
        buffer.get(data);
        fos.write(data);
        
        extractor.advance();
    }
    
    fos.close();
    extractor.release();
    Log.d("ExtractAudio", "提取完成,文件大小: " + new File(outputPath).length());
}

// 生成 ADTS 头(简化版)
private byte[] createADTSHeader(int packetLength, int sampleRate, int channelCount) {
    int profile = 2; // AAC LC
    int freqIdx = getSampleRateIndex(sampleRate);
    int chanCfg = channelCount;
    
    int fullLength = packetLength + 7; // ADTS 头固定 7 字节
    byte[] header = new byte[7];
    header[0] = (byte) 0xFF; // syncword 高8位
    header[1] = (byte) 0xF1; // syncword 低4位 + MPEG版本 + 层 + 保护位
    header[2] = (byte) ((profile << 6) | (freqIdx << 2) | (chanCfg >> 2));
    header[3] = (byte) ((chanCfg & 0x03) << 6 | (fullLength >> 11));
    header[4] = (byte) ((fullLength >> 3) & 0xFF);
    header[5] = (byte) ((fullLength & 0x07) << 5 | 0x1F);
    header[6] = (byte) 0xFC;
    return header;
}

提示:上面这个 ADTS 头生成函数只适用于 AAC LC 格式。如果是 HE-AAC 或其他变体,profile 值要调整。建议用 MediaCodec 的 getOutputFormat() 来获取准确的配置信息。

好了,数据读取这块就讲到这里。核心就三点:缓冲区管理、循环读取、注意 CSD 数据。你把这些搞明白了,MediaExtractor 的基本操作就算拿下了。

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