21、缩略图生成:快速解码关键帧、缩略图尺寸控制、批量生成优化、缓存策略

缩略图生成,听起来好像很简单?不就是从视频里抽一帧出来,缩小一下嘛。但真做过这个功能的人都知道,坑多着呢。

我最早做缩略图时,直接拿MediaMetadataRetriever去抽帧,结果用户视频一多,界面直接卡死。后来我才意识到,缩略图生成这件事,本质上是在跟时间赛跑——你既要快,又要省内存,还得保证清晰度。

这一章,我就把我在多个项目里踩过的坑、总结出来的经验,一次性讲清楚。

21.1 快速解码关键帧:别傻傻地逐帧解码

很多人一上来就用MediaCodec去解码视频,然后一帧一帧地读。这其实是个误区。你想想看,我们只需要一张缩略图,为什么要解码全部帧?

正确的做法是:直接定位到关键帧(I帧)。关键帧是完整的图像帧,不需要依赖其他帧就能解码出来。而P帧和B帧,说白了都是基于I帧算出来的差值,解码它们反而更慢。

我用的是MediaExtractor配合MediaCodec,直接seek到关键帧位置。代码大概长这样:

MediaExtractor extractor = new MediaExtractor();
extractor.setDataSource(videoPath);

// 选择视频轨道
MediaFormat format = null;
for (int i = 0; i < extractor.getTrackCount(); i++) {
    MediaFormat trackFormat = extractor.getTrackFormat(i);
    String mime = trackFormat.getString(MediaFormat.KEY_MIME);
    if (mime != null && mime.startsWith("video/")) {
        extractor.selectTrack(i);
        format = trackFormat;
        break;
    }
}

// 定位到关键帧
extractor.seekTo(targetTimeUs, MediaExtractor.SEEK_TO_PREVIOUS_SYNC);

MediaCodec decoder = MediaCodec.createDecoderByType(mime);
decoder.configure(format, null, null, 0);
decoder.start();

// 后续就是常规的解码+输出流程

这里有个关键点:SEEK_TO_PREVIOUS_SYNC 这个flag,它会直接跳到最近的关键帧。我见过有人用 SEEK_TO_CLOSEST_SYNC,结果抽出来的帧跟目标时间差了老远。嗯,这里要注意,如果你需要精确到某一秒的画面,那就得用 SEEK_TO_CLOSEST_SYNC 然后解码到目标时间附近。

我的经验: 对于缩略图来说,精确到关键帧就够了。用户不会在意缩略图是第3秒还是第5秒的,他们在意的是「快不快」。

21.2 缩略图尺寸控制:别直接输出原始分辨率

解码出来的原始帧,分辨率可能是1920x1080甚至4K。你要是直接拿这个尺寸去生成缩略图,内存直接爆炸。我见过一个极端案例,一个4K视频的原始帧,转成Bitmap后占了将近30MB内存。

所以,一定要在解码时就控制输出尺寸。MediaCodec支持通过MediaFormat设置输出分辨率:

MediaFormat outputFormat = MediaFormat.createVideoFormat(mime, targetWidth, targetHeight);
// 其他参数照常设置
decoder.configure(outputFormat, surface, null, 0);

但这里有个坑:不是所有设备都支持任意尺寸的缩放。我建议你设置一个合理的缩略图尺寸,比如320x180或者160x90。太大没必要,太小又看不清。

我个人习惯是:根据视频的原始宽高比,计算出一个宽度不超过320px的缩略图。这样既保证了比例不变,又控制了内存。

原始分辨率 推荐缩略图尺寸 内存占用(约)
1920x1080 320x180 ~230KB
1280x720 320x180 ~230KB
640x480 320x240 ~307KB
注意: 不要用Bitmap.createScaledBitmap去做二次缩放。那意味着你先解码了全尺寸帧,再缩小,内存峰值依然很高。一定要在解码阶段就控制尺寸。

21.3 批量生成优化:别一个一个来

假设用户有一个视频列表,每个视频都要生成缩略图。如果你一个一个地创建MediaExtractor、MediaCodec,那效率太低了。每个解码器的初始化都要几百毫秒,10个视频就是好几秒。

我建议的做法是:使用线程池,控制并发数。但这里有个细节:不是并发越多越好。因为解码是CPU密集型的操作,并发太多反而会因为线程切换导致性能下降。

我一般控制在 2-4个并发,具体取决于设备的CPU核心数。代码结构大概是这样:

ExecutorService executor = Executors.newFixedThreadPool(3);
List<Future<Bitmap>> futures = new ArrayList<>();

for (String videoPath : videoPaths) {
    Future<Bitmap> future = executor.submit(() -> {
        return generateThumbnail(videoPath);
    });
    futures.add(future);
}

// 收集结果
for (Future<Bitmap> future : futures) {
    Bitmap thumbnail = future.get();
    // 处理缩略图
}

另外,复用MediaCodec实例也是一个优化点。如果你要生成同一个视频的多个缩略图(比如每隔10秒抽一张),那完全没必要反复创建解码器。只需要seek到不同位置,然后解码即可。

核心思路: 减少创建和销毁的开销,让解码器持续工作。

21.4 缓存策略:别每次都重新解码

缩略图生成完了,存哪儿?每次打开应用都重新解码一遍?那用户会疯掉的。

我常用的缓存策略分两层:

  • 内存缓存:用LruCache,缓存最近访问的缩略图。大小一般控制在应用可用内存的1/8左右。
  • 磁盘缓存:用DiskLruCache或者直接存文件。文件名可以用视频路径的MD5加上时间戳来命名,避免冲突。

这里有个我踩过的坑:缓存失效问题。如果视频文件被替换了,但文件名没变,那缓存里的缩略图就是旧的。我曾经因为这个被用户骂惨了。

解决方案是:在缓存key中加入文件的最后修改时间。这样只要文件变了,缓存就会自动失效。

String cacheKey = videoPath + "_" + fileLastModified + "_" + targetWidth + "x" + targetHeight;

另外,磁盘缓存一定要做容量控制。我见过有人把缓存目录搞到几个GB,最后用户存储空间报警。我一般限制在50MB以内,超过就删除最久未使用的文件。

我的习惯: 每次生成缩略图时,先检查缓存。如果缓存命中,直接返回,省掉解码的开销。如果没命中,再走解码流程,生成后写入缓存。

知识体系总览

下面这张图,把缩略图生成的整个流程串起来了。你可以看到,从视频文件到最终展示,每一步都有优化空间。

缩略图生成优化流程 视频文件 快速解码关键帧(SEEK_TO_PREVIOUS_SYNC) 缩略图尺寸控制(解码时缩放) 批量生成优化(线程池 + 复用解码器) 缓存策略(内存 + 磁盘) 关键优化点 • 只解码关键帧 • 解码时控制尺寸 • 控制并发数 • 复用解码器 • 缓存key含修改时间 • 限制缓存容量

你看,整个流程其实就是一个「减少不必要开销」的过程。从解码到输出,每一步都在问自己:能不能少做点事?能不能复用已有的结果?

我记得有一次,一个视频列表页加载了50个视频的缩略图,优化前要等5秒多,优化后不到1秒就全部显示出来了。用户反馈说「这App好流畅」,其实就是这些细节堆出来的。

好了,缩略图生成这块,核心就是这四个方面。你只要把关键帧解码、尺寸控制、批量优化、缓存策略都做到位,基本不会出大问题。

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