5、数据读取与提取:读取压缩数据包、提取到文件、内存缓冲区操作
好,咱们接着往下走。上一章我们把 MediaExtractor 初始化好了,也选好了轨道。现在最关键的一步来了——怎么把数据真正读出来?
说实话,我早期做音视频开发时,觉得这步不就是调个 readSampleData() 嘛,有啥好讲的?结果第一次做视频剪辑功能,就栽在了缓冲区管理上。嗯,这里面的坑,比你想的要多。
5.1 读取压缩数据包的核心流程
MediaExtractor 读取数据,本质上是一个「拉模式」的循环。你调用一次 advance(),它就跳到下一个样本。你调用 readSampleData(),它就把当前样本的数据塞进你准备好的 ByteBuffer 里。
我习惯把整个流程画成下面这张图,你一看就明白:
你看,这个循环其实很直观。但为什么很多人写出来的代码要么内存溢出,要么丢帧?问题往往出在缓冲区管理上。
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 的基本操作就算拿下了。