30、完整项目实战:开发一个音视频提取工具App,涵盖所有知识点
终于到了最后一章。说实话,写到这里我自己也挺感慨的。前面29章我们拆解了MediaExtractor的每一个细节,从基础API到高级用法,从音频提取到视频抽帧。但说实话,光看零散的知识点,你很难真正掌握这个东西。
所以我一直觉得,最好的学习方式就是做一个完整的项目。把前面学的所有东西串起来,踩一遍坑,才能真正变成自己的技能。今天我们就来开发一个音视频提取工具App,把MediaExtractor、MediaMuxer、MediaCodec这些核心组件全部用上。
项目需求与架构设计
先说说这个工具要做什么。说白了,就是一个能让你从视频里提取音频、提取视频轨道、甚至截取片段的工具。我当年做短视频SDK时,第一个版本就缺了这个功能,结果被产品经理追着改了三个版本。
核心功能就三个:
- 音频提取:从视频文件中提取音频,保存为AAC或MP3格式
- 视频轨道提取:提取纯视频轨道,去掉音频
- 片段截取:指定时间范围,截取音视频片段
架构上我采用了经典的MVP模式。为什么不用MVVM?嗯,因为这个工具的逻辑相对独立,MVP反而更清晰。View层负责UI展示,Presenter层处理业务逻辑,Model层封装MediaExtractor的操作。
核心架构图
核心代码实现
好,直接上代码。这是整个工具最核心的部分——音频提取。我习惯把提取逻辑封装成一个独立的类,这样测试和维护都方便。
public class AudioExtractor {
private static final String TAG = "AudioExtractor";
private MediaExtractor extractor;
private MediaMuxer muxer;
private ExtractConfig config;
public interface ExtractCallback {
void onProgress(int percent);
void onComplete(String outputPath);
void onError(String errorMsg);
}
public void extractAudio(Context context, Uri sourceUri,
ExtractConfig config, ExtractCallback callback) {
this.config = config;
try {
extractor = new MediaExtractor();
extractor.setDataSource(context, sourceUri, null);
// 选择音频轨道
int audioTrackIndex = selectAudioTrack();
if (audioTrackIndex < 0) {
callback.onError("未找到音频轨道");
return;
}
extractor.selectTrack(audioTrackIndex);
MediaFormat format = extractor.getTrackFormat(audioTrackIndex);
// 配置输出
String outputPath = generateOutputPath(config);
muxer = new MediaMuxer(outputPath,
MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4);
int muxerTrackIndex = muxer.addTrack(format);
muxer.start();
// 开始提取
ByteBuffer buffer = ByteBuffer.allocate(1024 * 1024);
MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
long totalDuration = getDuration(format);
long extractedDuration = 0;
while (true) {
int sampleSize = extractor.readSampleData(buffer, 0);
if (sampleSize < 0) break;
info.offset = 0;
info.size = sampleSize;
info.flags = extractor.getSampleFlags();
info.presentationTimeUs = extractor.getSampleTime();
muxer.writeSampleData(muxerTrackIndex, buffer, info);
extractor.advance();
// 进度回调
extractedDuration = info.presentationTimeUs;
int percent = (int) (extractedDuration * 100 / totalDuration);
callback.onProgress(Math.min(percent, 100));
}
muxer.stop();
muxer.release();
extractor.release();
callback.onComplete(outputPath);
} catch (Exception e) {
Log.e(TAG, "提取失败", e);
callback.onError("提取失败: " + e.getMessage());
}
}
private int selectAudioTrack() {
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 != null && mime.startsWith("audio/")) {
return i;
}
}
return -1;
}
}
我的经验之谈
这里有个坑,我当年踩过。MediaExtractor的readSampleData方法返回的是实际读取的字节数,但buffer的position不会自动重置。所以每次读取前,最好手动把buffer的position设为0。不然你会读到上一帧的残留数据。
视频轨道提取的实现
视频提取和音频提取逻辑上差不多,但有个关键区别——你要处理B帧的问题。为什么?因为视频编码里,B帧的显示顺序和编码顺序不一样。MediaExtractor默认是按编码顺序读取的,但MediaMuxer需要按显示顺序写入。
我个人的做法是,在提取视频轨道时,先把所有sample的时间戳读出来排序,然后再写入。虽然多了一次遍历,但保证了输出文件的正确性。
public class VideoExtractor {
// 视频提取的核心逻辑
public void extractVideo(MediaExtractor extractor, MediaMuxer muxer,
int videoTrackIndex, ExtractCallback callback) {
// 先收集所有sample的时间戳
List<Long> timestamps = new ArrayList<>();
while (extractor.advance()) {
timestamps.add(extractor.getSampleTime());
}
// 排序(处理B帧)
Collections.sort(timestamps);
// 重新定位并写入
extractor.selectTrack(videoTrackIndex);
for (long timestamp : timestamps) {
extractor.seekTo(timestamp, MediaExtractor.SEEK_TO_CLOSEST_SYNC);
// 读取并写入sample...
}
}
}
注意
seekTo操作是有性能开销的。如果视频很长(比如超过30分钟),频繁seek会导致提取速度变慢。我建议在这种情况下,使用SEEK_TO_PREVIOUS_SYNC模式,减少关键帧查找次数。
片段截取功能
片段截取说白了就是指定开始时间和结束时间,只提取中间那一段。实现起来其实很简单——在读取sample时,判断时间戳是否在目标范围内。
但有个细节要注意:开始时间不一定是关键帧。如果你从非关键帧开始截取,播放器解码时会花屏一下。我一般会往前找最近的关键帧作为实际开始点。
private long findClosestSyncFrame(MediaExtractor extractor, long targetTimeUs) {
extractor.seekTo(targetTimeUs, MediaExtractor.SEEK_TO_PREVIOUS_SYNC);
return extractor.getSampleTime();
}
UI设计与交互
UI这块我做得比较简洁。一个文件选择按钮,三个功能选项(音频提取、视频提取、片段截取),一个进度条,一个结果展示区。说实话,工具类App不需要花里胡哨的界面,好用才是王道。
交互上我做了几个优化:
- 提取过程中显示实时进度和预估剩余时间
- 支持后台提取(用户切到其他App也不中断)
- 提取完成后直接弹出分享菜单
性能优化与避坑指南
开发过程中我遇到了不少问题,挑几个典型的说说:
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 提取速度慢 | Buffer太小,频繁IO | Buffer设为2MB以上 |
| 输出文件损坏 | MediaMuxer未正确stop | 用try-finally保证释放 |
| 内存溢出 | 大文件全部加载到内存 | 流式处理,分块读取 |
| 音频不同步 | 时间戳精度问题 | 使用微秒级时间戳 |
避坑指南
我曾经在提取一个4K视频的音频时,发现输出文件只有几KB。查了半天,原来是MediaExtractor在读取高码率视频时,buffer分配太小导致读取失败。后来我把buffer大小改成了动态分配——根据视频的bitrate计算合适的buffer大小。
测试与发布
测试环节我覆盖了这些场景:
- 不同格式的视频(MP4、MOV、AVI、MKV)
- 不同分辨率的视频(480p到4K)
- 不同编码格式(H.264、H.265、VP9)
- 异常情况(文件损坏、权限不足、存储空间不足)
发布前我还做了兼容性测试。Android 5.0到Android 13都跑了一遍,确保MediaExtractor的API在不同版本上行为一致。嗯,这里要提一下,Android 10以后的文件访问权限变了,需要用MediaStore或者SAF来保存文件。
好了,整个项目就是这样。从架构设计到代码实现,从性能优化到测试发布,我们把MediaExtractor相关的知识点全部串起来了。说实话,做完这个工具,你对Android音视频处理的理解会上一个台阶。以后遇到类似的需求,你心里就有底了。
代码已经上传到GitHub,需要的同学可以自己去拉。有问题欢迎在评论区交流,我看到会回复。