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 然后解码到目标时间附近。
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 |
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以内,超过就删除最久未使用的文件。
知识体系总览
下面这张图,把缩略图生成的整个流程串起来了。你可以看到,从视频文件到最终展示,每一步都有优化空间。
你看,整个流程其实就是一个「减少不必要开销」的过程。从解码到输出,每一步都在问自己:能不能少做点事?能不能复用已有的结果?
我记得有一次,一个视频列表页加载了50个视频的缩略图,优化前要等5秒多,优化后不到1秒就全部显示出来了。用户反馈说「这App好流畅」,其实就是这些细节堆出来的。
好了,缩略图生成这块,核心就是这四个方面。你只要把关键帧解码、尺寸控制、批量优化、缓存策略都做到位,基本不会出大问题。