12、视频截图:getDrawingCache()与buildDrawingCache()、使用ImageReader截图、MediaMetadataRetriever截图、SurfaceView截图注意事项。
视频截图这个功能,几乎每个播放器项目都会遇到。用户想保存某个精彩瞬间,或者我们需要生成视频封面,都离不开它。
但说实话,SurfaceView 的截图一直是个坑。我早年做第一个播放器项目时,就被它折腾得不轻。今天我把几种主流方案掰开揉碎讲清楚,顺便聊聊我踩过的那些坑。
核心结论先放这:SurfaceView 不能用传统的 View 截图方式。它的绘制表面是独立的,和普通 View 不在同一个画布上。
12.1 传统 View 截图:getDrawingCache() 与 buildDrawingCache()
这是 Android 早期提供的截图方式。原理很简单——把 View 的内容渲染到一个 Bitmap 缓存里。
// 传统方式,对 SurfaceView 无效
view.buildDrawingCache();
Bitmap bitmap = view.getDrawingCache();
if (bitmap != null) {
// 拿到 bitmap 了
}
我刚开始做截图时,第一反应就是调这个方法。结果呢?拿到的 bitmap 是黑的,或者干脆是空的。
为什么会这样?因为 SurfaceView 的绘制是在独立的 Surface 上,和 Activity 的 View 层级是分开的。getDrawingCache() 只能捕获 View 层级的内容,Surface 上的东西它根本碰不到。
注意:buildDrawingCache() 在 API 28 已经被标记为废弃。Google 推荐用 View.draw(Canvas) 或者 PixelCopy 替代。但即便不废弃,它对 SurfaceView 也没用。
我个人习惯是:如果只是截普通 View,用 View.draw(Canvas) 更靠谱。但 SurfaceView 嘛,得换思路。
12.2 使用 ImageReader 截图
这是目前最推荐的方案。ImageReader 可以监听 Surface 上的新帧,然后把帧数据转成 Bitmap。
思路是这样的:
- 创建一个 ImageReader 对象,设置宽高和格式
- 把 ImageReader 的 Surface 作为输出目标
- 监听 onImageAvailable 回调,拿到 Image 转 Bitmap
// 创建 ImageReader
ImageReader reader = ImageReader.newInstance(
width, height,
ImageFormat.YUV_420_888,
2 // 最大图片数
);
// 设置监听
reader.setOnImageAvailableListener(new ImageReader.OnImageAvailableListener() {
@Override
public void onImageAvailable(ImageReader reader) {
Image image = reader.acquireLatestImage();
if (image != null) {
Bitmap bitmap = imageToBitmap(image);
// 保存或使用 bitmap
image.close(); // 必须关闭,否则会卡死
}
}
}, backgroundHandler);
// 把 reader 的 surface 设置给 MediaCodec 或 SurfaceView
// 这样每一帧都会同时输出到 reader
小技巧:acquireLatestImage() 比 acquireNextImage() 更安全。它会丢弃积压的旧帧,只拿最新的。避免内存暴涨。
我在项目中遇到过一个问题:ImageReader 的回调线程不能做耗时操作。如果你在回调里直接保存文件,UI 线程会卡顿。建议用 HandlerThread 或者协程做异步处理。
另外,YUV_420_888 转 Bitmap 需要自己写转换逻辑。网上有很多现成的工具类,但要注意性能。我一般用 RenderScript 来做,速度还不错。
12.3 使用 MediaMetadataRetriever 截图
这个方案适合从视频文件直接截取某一帧,不需要播放器正在播放。
MediaMetadataRetriever retriever = new MediaMetadataRetriever();
try {
retriever.setDataSource(videoPath);
// 截取指定时间点的帧,单位是微秒
Bitmap bitmap = retriever.getFrameAtTime(
timeUs,
MediaMetadataRetriever.OPTION_CLOSEST_SYNC
);
if (bitmap != null) {
// 保存 bitmap
}
} catch (Exception e) {
e.printStackTrace();
} finally {
retriever.release();
}
这里有个坑:OPTION_CLOSEST_SYNC 会找最近的同步帧,不一定是你指定的精确时间点。如果你需要精确到毫秒,用 OPTION_CLOSEST 或者 OPTION_CLOSEST_SYNC 配合更小的搜索范围。
我曾经做一个视频编辑 App,用户拖动进度条要实时预览缩略图。一开始用 MediaMetadataRetriever 每帧都去取,结果卡得不行。后来改成预先生成关键帧的缩略图列表,拖动时直接从缓存拿,体验就好多了。
适用场景对比:
| 方案 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| getDrawingCache | 普通 View 截图 | 简单直接 | 对 SurfaceView 无效,已废弃 |
| ImageReader | 实时播放中的 SurfaceView | 实时性强,帧率高 | 需要处理 YUV 转换,回调线程注意 |
| MediaMetadataRetriever | 从视频文件截取 | 不依赖播放器,精确控制时间点 | IO 操作,频繁调用会卡 |
12.4 SurfaceView 截图注意事项
嗯,这里要重点说说。SurfaceView 截图之所以麻烦,根本原因在于它的绘制表面是独立的 Window。你没法像普通 View 那样直接拿 Canvas 来画。
我总结了几条铁律:
- 不要用 getDrawingCache()——拿到的永远是黑屏
- 不要用 View.draw(Canvas)——SurfaceView 的 draw 方法不会绘制 Surface 内容
- 优先用 ImageReader——这是最可靠的方式
- 注意线程安全——截图回调可能不在主线程,更新 UI 要切线程
另外,如果你用的是 TextureView,情况会好一些。TextureView 的 getBitmap() 方法可以直接拿到当前帧。但 TextureView 的性能开销比 SurfaceView 大,而且不支持硬件叠加层。
我曾经遇到一个诡异的问题:某些手机上 ImageReader 回调不触发。后来发现是 Surface 的格式不匹配。ImageReader 的格式必须和 MediaCodec 或 SurfaceView 的格式一致,否则会静默失败。
重要:ImageReader 使用完毕后一定要 close()。否则会导致 Surface 无法释放,内存泄漏。我见过线上 OOM 的案例,就是因为 ImageReader 没关。
12.5 知识体系总览
下面这张图把几种截图方案的关系和适用场景梳理清楚了:
说白了,选哪种方案取决于你的场景。实时播放中截图,ImageReader 是首选。从文件里取某一帧,MediaMetadataRetriever 更省事。至于 getDrawingCache,让它安息吧。
最后提醒一句:截图操作一定要做异常处理。我见过太多因为截图失败导致 App 闪退的案例。尤其是低端机型,内存不足时 ImageReader 很容易报错。加个 try-catch,保平安。