16、性能优化:缓冲区大小设置、避免内存抖动、异步提取

性能优化这个话题,说实话,是区分「能用」和「好用」的分水岭。

我自己刚做音视频那会儿,写出来的提取器也能跑,但一放到低端机上就卡顿、掉帧,甚至直接 OOM。后来踩了不少坑,才慢慢摸清楚门道。今天咱们就把缓冲区大小、内存抖动、异步提取这三个核心点彻底聊透。

16.1 缓冲区大小:不是越大越好

很多人一上来就问:「缓冲区设多大合适?」

我的回答是:看场景,看设备,看帧率

先看一个最基础的设置方式:

// 获取轨道格式
MediaFormat format = extractor.getTrackFormat(trackIndex);
int sampleSize = format.getInteger(MediaFormat.KEY_MAX_INPUT_SIZE);

// 分配缓冲区
ByteBuffer buffer = ByteBuffer.allocate(sampleSize);

嗯,这里要注意:KEY_MAX_INPUT_SIZE 这个值,不同厂商的 ROM 给得不一样。有的给 2MB,有的给 8MB。你直接用它分配,在低端机上很容易触发大块内存分配,造成卡顿。

我个人习惯的做法是:动态计算,按需分配

// 根据视频分辨率估算缓冲区大小
int width = format.getInteger(MediaFormat.KEY_WIDTH);
int height = format.getInteger(MediaFormat.KEY_HEIGHT);
// 一般一帧压缩数据不会超过 width * height * 1.5
int estimatedSize = (int)(width * height * 1.5f);
// 再给个上限,防止极端情况
int bufferSize = Math.min(estimatedSize, 2 * 1024 * 1024); // 最大2MB

ByteBuffer buffer = ByteBuffer.allocate(bufferSize);

为什么是 1.5 倍?我在项目中遇到过一些 H.264 的 I 帧,压缩比低的时候确实会接近这个值。你想想看,如果缓冲区设小了,readSampleData 返回 -1,你还得重新分配,这不是更亏?

核心原则:缓冲区大小 = 单帧最大可能数据量 × 1.2 ~ 1.5 安全系数。不要直接用 KEY_MAX_INPUT_SIZE,也不要拍脑袋设个固定值。

16.2 避免内存抖动:复用是关键

内存抖动,说白了就是频繁创建和销毁对象,导致 GC 频繁触发。

我记得有一次在项目里做实时提取,每读一帧就 new 一个 ByteBuffer,结果在 30fps 的视频上,GC 日志刷屏了。帧率直接从 30 掉到 15,画面肉眼可见地卡。

解决方案其实很简单:复用缓冲区

// 在循环外部只分配一次
ByteBuffer buffer = ByteBuffer.allocate(bufferSize);
MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();

while (true) {
    int sampleSize = extractor.readSampleData(buffer, 0);
    if (sampleSize < 0) break;
    
    // 处理数据...
    // 注意:处理完后要清空缓冲区,或者用 position/limit 控制
    buffer.clear();
    extractor.advance();
}

这里有个小坑:buffer.clear() 只是重置了 position 和 limit,并没有真正清空数据。如果你在处理时依赖 buffer 的剩余数据量,记得用 buffer.limit(sampleSize) 来限定有效数据范围。

我的经验:除了 ByteBuffer,像 MediaCodec.BufferInfo、MediaExtractor 本身这些对象,也尽量在循环外创建。能复用的都复用,GC 就不会频繁找你麻烦。

16.3 异步提取:别让主线程卡死

提取音视频数据,本质上是 I/O 操作。如果在主线程里同步读取,UI 会直接卡住。用户滑动列表时突然卡顿,体验极差。

我建议的做法是:用 HandlerThread 或者协程来做异步提取

先看一个经典的 HandlerThread 方案:

public class AsyncExtractor {
    private HandlerThread extractThread;
    private Handler extractHandler;
    private MediaExtractor extractor;
    
    public void startAsyncExtract(String filePath) {
        extractThread = new HandlerThread("ExtractThread");
        extractThread.start();
        extractHandler = new Handler(extractThread.getLooper());
        
        extractHandler.post(() -> {
            extractor = new MediaExtractor();
            extractor.setDataSource(filePath);
            // ... 选择轨道、分配缓冲区
            while (true) {
                int sampleSize = extractor.readSampleData(buffer, 0);
                if (sampleSize < 0) break;
                // 处理数据,注意线程安全
                processData(buffer, sampleSize);
                extractor.advance();
            }
            // 提取完成,回调主线程
            new Handler(Looper.getMainLooper()).post(() -> {
                onExtractComplete();
            });
        });
    }
    
    public void stopExtract() {
        extractHandler.removeCallbacksAndMessages(null);
        extractThread.quitSafely();
    }
}

为什么用 HandlerThread 而不是直接 new Thread?因为 HandlerThread 自带 Looper,方便你后续发消息、取消任务。说白了,管理起来更顺手。

如果你用 Kotlin,协程方案会更简洁:

// 用 Dispatchers.IO 做异步提取
viewModelScope.launch(Dispatchers.IO) {
    val extractor = MediaExtractor()
    extractor.setDataSource(filePath)
    // ... 提取逻辑
    withContext(Dispatchers.Main) {
        // 更新 UI
    }
}
注意:MediaExtractor 不是线程安全的。不要在多个线程同时操作同一个 extractor 实例。如果你需要并行提取多个轨道,请创建多个 extractor 实例,每个跑在独立的线程上。

16.4 知识体系总览

下面这张图,把本章的核心逻辑串起来了:

MediaExtractor 性能优化 缓冲区大小设置 • 避免直接使用 KEY_MAX_INPUT_SIZE • 根据分辨率动态估算 • 设置安全系数 1.2~1.5 倍 避免内存抖动 • 复用 ByteBuffer 实例 • 循环外创建 BufferInfo • 用 clear() + limit() 控制 异步提取 • HandlerThread 方案 • Kotlin 协程方案 • 注意线程安全 核心目标:流畅提取,不卡顿,不 OOM 缓冲区合理 → 内存复用 → 异步执行 → 体验丝滑

16.5 综合实战:一个完整的优化示例

把上面三个点串起来,写一个完整的优化版提取器:

public class OptimizedExtractor {
    private MediaExtractor extractor;
    private ByteBuffer buffer;
    private MediaCodec.BufferInfo info;
    private HandlerThread workerThread;
    private Handler workerHandler;
    
    public void start(String filePath) {
        // 1. 异步线程
        workerThread = new HandlerThread("ExtractWorker");
        workerThread.start();
        workerHandler = new Handler(workerThread.getLooper());
        
        workerHandler.post(() -> {
            extractor = new MediaExtractor();
            try {
                extractor.setDataSource(filePath);
                int trackIndex = selectTrack(extractor);
                if (trackIndex < 0) return;
                extractor.selectTrack(trackIndex);
                
                // 2. 动态计算缓冲区
                MediaFormat format = extractor.getTrackFormat(trackIndex);
                int width = format.getInteger(MediaFormat.KEY_WIDTH);
                int height = format.getInteger(MediaFormat.KEY_HEIGHT);
                int bufferSize = (int)(width * height * 1.5f);
                bufferSize = Math.min(bufferSize, 2 * 1024 * 1024);
                
                // 3. 复用对象
                buffer = ByteBuffer.allocate(bufferSize);
                info = new MediaCodec.BufferInfo();
                
                while (!Thread.currentThread().isInterrupted()) {
                    int sampleSize = extractor.readSampleData(buffer, 0);
                    if (sampleSize < 0) break;
                    
                    buffer.limit(sampleSize);
                    // 处理数据...
                    processFrame(buffer, info);
                    
                    buffer.clear();
                    extractor.advance();
                }
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                release();
            }
        });
    }
    
    private void release() {
        if (extractor != null) {
            extractor.release();
            extractor = null;
        }
        workerThread.quitSafely();
    }
}
避坑指南:我曾经在 release() 里忘记调用 workerThread.quitSafely(),结果线程一直挂在那里,内存泄漏了。后来我养成了一个习惯:所有异步任务,一定要有对应的取消/释放逻辑,成对出现。

好了,关于性能优化,核心就是这三板斧:缓冲区大小要合理、内存对象要复用、提取操作要异步。你想想看,只要把这三点做到位,大部分性能问题都能迎刃而解。


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