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。

思路是这样的:

  1. 创建一个 ImageReader 对象,设置宽高和格式
  2. 把 ImageReader 的 Surface 作为输出目标
  3. 监听 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 知识体系总览

下面这张图把几种截图方案的关系和适用场景梳理清楚了:

SurfaceView 截图方案对比 视频截图需求 getDrawingCache() ❌ 对SurfaceView无效 已废弃,仅适用普通View ImageReader 截图 ✅ 推荐方案,实时帧捕获 需处理YUV转Bitmap MediaMetadataRetriever ✅ 从文件截取,不依赖播放 频繁调用会卡顿 核心建议 1. 实时播放截图 → ImageReader(最可靠) 2. 视频文件截帧 → MediaMetadataRetriever(最方便) 3. 不要用 getDrawingCache() 截 SurfaceView(必黑屏)

说白了,选哪种方案取决于你的场景。实时播放中截图,ImageReader 是首选。从文件里取某一帧,MediaMetadataRetriever 更省事。至于 getDrawingCache,让它安息吧。

最后提醒一句:截图操作一定要做异常处理。我见过太多因为截图失败导致 App 闪退的案例。尤其是低端机型,内存不足时 ImageReader 很容易报错。加个 try-catch,保平安。