Bitmap 对象的创建、复用与回收机制

做图片加载优化这么多年,我见过最头疼的问题就是——Bitmap 对象的生命周期管理。说白了,就是三个核心动作:怎么建、怎么用、怎么丢。大部分 OOM 都出在这几个环节上。

1. Bitmap 的创建:没那么简单

创建 Bitmap 最常见的方式就是通过 BitmapFactory。但你真的了解每个参数背后的坑吗?

方法适用场景我遇到的坑
BitmapFactory.decodeFile()加载本地文件不处理缩放,大图直接爆内存
BitmapFactory.decodeResource()加载 res 资源不同 dpi 目录会自动缩放,容易尺寸翻倍
BitmapFactory.decodeStream()加载网络流流必须支持 mark/reset,否则采样失败
BitmapFactory.decodeByteArray()加载 byte[]记得先解压缩,否则浪费内存

我个人习惯在 decode 之前,先通过 inJustDecodeBounds 获取图片的真实宽高,然后再根据目标控件尺寸计算采样比例。举个例子:

BitmapFactory.Options opts = new BitmapFactory.Options();
opts.inJustDecodeBounds = true;
BitmapFactory.decodeFile(path, opts);

int outWidth = opts.outWidth;
int outHeight = opts.outHeight;

// 计算采样比例
opts.inSampleSize = calculateInSampleSize(outWidth, outHeight, 200, 300);
opts.inJustDecodeBounds = false;
Bitmap bitmap = BitmapFactory.decodeFile(path, opts);
核心原则:永远不要在 UI 线程加载超过 4MB 的 Bitmap。安卓 8.0 之前 Bitmap 存于 Java 堆,之后才转入 Native 堆,但 OOM 风险依然存在。

2. Bitmap 的复用:别频繁创建对象

创建 Bitmap 对象本身就很耗时,GC 频繁触发还会导致卡顿。所以复用机制很重要。Android 3.0 之后提供了 BitmapFactory.Options.inBitmap,让我们可以重复使用一块内存。

我记得有个项目是做瀑布流图片列表,用户快速滑动时一秒要加载十几张图。如果不复用,GC 每秒钟触发好几次,列表直接掉帧。

2.1 复用要求

  • Android 4.4 之前:复用 Bitmap 必须与被复用对象 尺寸一致
  • Android 4.4 及以上:只需 被复用对象的大小 ≥ 新图所需内存(且像素格式相同)
  • inMutable 必须设为 true
// 准备一个复用池,典型的 LruCache 实现
private fun getReusableBitmap(width: Int, height: Int): Bitmap? {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
        return reusableCache?.let { cache ->
            cache.keys.forEach { key ->
                val bmp = cache[key]
                if (bmp != null && bmp.width * bmp.height * 4 >= width * height * 4) {
                    cache.remove(key)
                    return bmp
                }
            }
        }
    }
    // 老版本需要严格匹配尺寸
    // ...
    return null
}
你想想看:复用池里的 Bitmap 尺寸如果太小,新图就会失败。建议用一个 LruCache 来管理可复用的 Bitmap,key 用宽高组合,value 就是 Bitmap 引用。

3. Bitmap 的回收:该放手时就放手

为什么还要手动回收?Android 系统垃圾回收线程不是会自动处理吗?

嗯,理论上是的。但实际项目中,Bitmap 对象特别大,GC 回收速度赶不上创建速度。尤其在低端机上,GC 每次回收都要触发 Stop The World,卡顿感非常明显。

我曾经在一个直播项目里遇到过:用户连麦时频繁切换摄像头画面,每秒钟创建 10 张 1080p 的 Bitmap,GC 来不及回收,内存直接飙到 200MB 以上,然后崩溃。

3.1 什么时候必须回收

  • 使用 Bitmap.createBitmap() 创建的 Bitmap,必须在不再需要时调用 recycle()
  • 从 BitmapFactory 取出的 Bitmap,如果共享了同一个 byte[],不能随意回收
  • Android 3.0 之前,原生堆无法追踪,必须手动回收

3.2 正确的回收姿势

if (bitmap != null && !bitmap.isRecycled()) {
    bitmap.recycle();
    bitmap = null;  // 记得清引用,否则 GC 依然持有
}
注意别踩这个坑:很多同学在 onDraw 里调 recycle(),然后 Canvas 就拿着一个已经回收的 Bitmap 画,导致 Canvas: trying to use a recycled bitmap 异常。正确的做法是在 确认不再绘制后 再回收,比如在 Activity 的 onDestroy 或自定义 View 的 onDetachedFromWindow 中。

4. 全生命周期管理:从创建到回收

把上面三个环节串起来,就是一套完整的 Bitmap 管理流程。我画了一张图,你可以对照着看:

Bitmap 全生命周期管理流程图 1. 创建 BitmapFactory + inSampleSize 2. 检查复用池 inBitmap 是否可用? 是 复用已有Bitmap 清零内容 否 3. 新建Bitmap createBitmap / decode 显示 / 渲染 在ImageView中 4. 回收判断 是否不再使用? 5. 回收Bitmap recycle() + 置空 返回复用池(尺寸合适则缓存) 提示:复用池建议使用 LruCache<String, Bitmap> 管理,键用 "width×height" 格式

5. 实战中容易忽略的细节

5.1 在 ListView 或 RecyclerView 中

  • 不要在 getView 里频繁新建 Bitmap,用图片加载库(Glide/Picasso)内部已经做了复用
  • 如果自己管理,一定要在 Item 被回收时释放图片引用

5.2 在 Canvas 中直接绘制

// 错误示范
Canvas canvas = new Canvas(bitmap);
// ... 画完之后
bitmap.recycle(); // ❌ 此时 Canvas 还在引用,画布内容就没啦

// 正确做法
Canvas canvas = new Canvas(bitmap);
// ... 画完之后,确认不再使用
canvas = null; // 先断开 Canvas 引用
bitmap.recycle(); ✅

5.3 多线程加载

我个人习惯在异步线程 decode 完成后,通过 Handler 切回主线程设置。这样 decode 不会卡 UI,同时 Bitmap 的回收也可以放在后台线程——但要注意回收操作本身不是线程安全的,最好用一个串行的 HandlerThread 来处理。

一句话总结:创建时要采样,复用池要管好,回收要干净。这三点做好,OOM 基本就远离你了。

6. Android 版本带来的差异

Android 版本Bitmap 存储位置回收策略
2.x (API < 12)Java 堆必须手动 recycle(),否则容易 OOM
3.0 ~ 7.1Java 堆建议手动回收,但 inBitmap 可用(API 11+)
8.0 及以上Native 堆GC 会自动回收,但复用仍然有价值;inBitmap 依然推荐
你可能觉得新版本不用管回收了。但别忘了,Native 堆的碎片化和释放时机仍然不可控。我做性能测试时发现,即使 Android 10,频繁创建大 Bitmap 同样会导致 Native 堆 OOM。所以——规矩不能丢。