30、完整项目实战:开发一个音视频提取工具App,涵盖所有知识点

终于到了最后一章。说实话,写到这里我自己也挺感慨的。前面29章我们拆解了MediaExtractor的每一个细节,从基础API到高级用法,从音频提取到视频抽帧。但说实话,光看零散的知识点,你很难真正掌握这个东西。

所以我一直觉得,最好的学习方式就是做一个完整的项目。把前面学的所有东西串起来,踩一遍坑,才能真正变成自己的技能。今天我们就来开发一个音视频提取工具App,把MediaExtractor、MediaMuxer、MediaCodec这些核心组件全部用上。

项目需求与架构设计

先说说这个工具要做什么。说白了,就是一个能让你从视频里提取音频、提取视频轨道、甚至截取片段的工具。我当年做短视频SDK时,第一个版本就缺了这个功能,结果被产品经理追着改了三个版本。

核心功能就三个:

  • 音频提取:从视频文件中提取音频,保存为AAC或MP3格式
  • 视频轨道提取:提取纯视频轨道,去掉音频
  • 片段截取:指定时间范围,截取音视频片段

架构上我采用了经典的MVP模式。为什么不用MVVM?嗯,因为这个工具的逻辑相对独立,MVP反而更清晰。View层负责UI展示,Presenter层处理业务逻辑,Model层封装MediaExtractor的操作。

核心架构图

音视频提取工具App架构 View层 MainActivity ExtractFragment ProgressDialog Presenter层 ExtractPresenter MediaExtractor MediaMuxer Model层 ExtractConfig MediaInfo ExtractResult 数据流方向 用户操作 → View → Presenter → Model(MediaExtractor) 提取结果 → Model(MediaMuxer) → Presenter → View → 文件保存 进度回调 → Presenter → View → UI更新

核心代码实现

好,直接上代码。这是整个工具最核心的部分——音频提取。我习惯把提取逻辑封装成一个独立的类,这样测试和维护都方便。

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,需要的同学可以自己去拉。有问题欢迎在评论区交流,我看到会回复。


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