4、Bitmap内存优化:Bitmap加载策略、inSampleSize采样、Bitmap复用与缓存

说到Bitmap优化,我脑子里第一个蹦出来的画面,就是几年前那个让人头皮发麻的OOM崩溃统计图。那时候我们团队做的是一个图片社交App,用户上传的图片动辄几兆甚至十几兆。你想想看,一张1920x1080的图片,在Android里解码成ARGB_8888格式,光这一张图就要吃掉将近8MB的内存。要是列表里同时加载十几张,那画面太美我不敢看。

所以今天咱们就来聊聊,Bitmap到底该怎么管。说白了,就是三件事:怎么加载、怎么采样、怎么复用和缓存。

4.1 Bitmap加载策略:别一股脑全塞进来

我见过不少新手,拿到图片URL就直接用BitmapFactory.decodeStream()去加载。结果呢?图片原图多大,内存里就占多大。一张4000x3000的照片,在手机上显示可能只需要一个200x200的缩略图,你非要把原图塞进来,这不是跟自己过不去吗?

正确的做法是:按需加载。你需要多大的显示区域,就加载多大尺寸的Bitmap。

我个人习惯的做法是,先通过BitmapFactory.Options读取图片的原始宽高,然后结合目标控件的尺寸,计算出合适的采样比例。这里有个关键点——inJustDecodeBounds这个属性。把它设为true,解码时不会分配内存,只读取图片的边界信息。

BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeResource(getResources(), R.id.myimage, options);
int imageHeight = options.outHeight;
int imageWidth = options.outWidth;
String imageType = options.outMimeType;

拿到宽高后,再根据目标尺寸计算采样率。嗯,这里要注意,inSampleSize的值必须是2的幂次方,比如1、2、4、8。如果不是,系统会向下取整到最近的2的幂次方。我曾经踩过这个坑,设了个3,结果系统给我按2处理了,图片比预期大了一倍。

核心原则:Bitmap占用的内存 = 图片宽度 × 图片高度 × 每个像素占用的字节数。控制好宽高,就控制住了内存。

4.2 inSampleSize采样:降采样不是缩略图

很多人把inSampleSize和缩略图搞混了。缩略图是加载原图后再缩小,内存峰值依然很高。而inSampleSize是降采样,它在解码阶段就直接跳过部分像素,内存占用从一开始就是小的。

举个例子,一张1024x1024的图片,inSampleSize设为2,解码出来的Bitmap就是512x512,内存直接降到原来的四分之一。这个过程中,大图的内存从未被分配过。

我在项目中遇到过这样一个场景:用户上传的照片是4000x3000,但列表里的ImageView只有100x75。我当时的计算逻辑是这样的:

public static int calculateInSampleSize(
        BitmapFactory.Options options, int reqWidth, int reqHeight) {
    // 原始图片的宽高
    final int height = options.outHeight;
    final int width = options.outWidth;
    int inSampleSize = 1;

    if (height > reqHeight || width > reqWidth) {
        final int halfHeight = height / 2;
        final int halfWidth = width / 2;

        // 不断除以2,直到小于目标尺寸
        while ((halfHeight / inSampleSize) >= reqHeight
                && (halfWidth / inSampleSize) >= reqWidth) {
            inSampleSize *= 2;
        }
    }

    return inSampleSize;
}

然后配合inJustDecodeBounds,先算采样率,再真正解码:

BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeResource(getResources(), R.id.myimage, options);

options.inSampleSize = calculateInSampleSize(options, 100, 75);
options.inJustDecodeBounds = false;
Bitmap sampledBitmap = BitmapFactory.decodeResource(getResources(), R.id.myimage, options);
imageView.setImageBitmap(sampledBitmap);

小技巧:如果目标尺寸是固定的,比如头像都是100x100,我建议直接把采样率写死成常量。省去每次计算的性能开销。当然,前提是图片源尺寸相对稳定。

4.3 Bitmap复用:别频繁创建和回收

频繁创建Bitmap,频繁GC,这是性能杀手。Android 3.0之后引入了BitmapFactory.Options.inBitmap,允许你复用已有的Bitmap内存。说白了,就是告诉系统:别给我重新分配内存了,用这块旧的就行。

使用inBitmap有几个硬性条件:

  • 复用Bitmap的尺寸必须大于等于新图片的尺寸
  • Android 4.4之前,要求新旧Bitmap的尺寸必须完全一致
  • Android 4.4之后,只要新图片不大于复用Bitmap的尺寸即可
  • 新旧Bitmap的编码格式必须一致(比如都是ARGB_8888)

我曾经在一个图片编辑功能里用过这个技巧。用户连续裁剪多张图片,每次裁剪都会生成新的Bitmap。如果不复用,内存会像坐过山车一样忽高忽低。用了inBitmap之后,内存曲线平稳多了。

// 准备一个可复用的Bitmap池
private static Set<WeakReference<Bitmap>> reusableBitmaps;

public static Bitmap decodeSampledBitmapFromFile(
        String filename, int reqWidth, int reqHeight) {
    BitmapFactory.Options options = new BitmapFactory.Options();
    options.inJustDecodeBounds = true;
    BitmapFactory.decodeFile(filename, options);
    options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight);

    // 尝试从复用池中找一个合适的Bitmap
    options.inMutable = true;
    options.inBitmap = getReusableBitmap(options);
    options.inJustDecodeBounds = false;

    return BitmapFactory.decodeFile(filename, options);
}

private static Bitmap getReusableBitmap(BitmapFactory.Options options) {
    if (reusableBitmaps == null) return null;
    for (WeakReference<Bitmap> ref : reusableBitmaps) {
        Bitmap bitmap = ref.get();
        if (bitmap != null && bitmap.isMutable()) {
            // 检查尺寸是否满足复用条件
            if (canUseForInBitmap(bitmap, options)) {
                return bitmap;
            }
        }
    }
    return null;
}

避坑指南:我曾经在Android 4.3的设备上踩过inBitmap的坑。当时没注意编码格式,复用的Bitmap是RGB_565,新加载的是ARGB_8888,结果解码出来的图片颜色完全不对。所以一定要确保编码格式一致。

4.4 Bitmap缓存:内存缓存与磁盘缓存

缓存这块,我推荐用LruCache做内存缓存,用DiskLruCache做磁盘缓存。LruCache的原理是最近最少使用算法,当缓存满了,会优先淘汰最久没被访问的条目。

内存缓存的大小怎么定?我一般取应用最大可用内存的八分之一。太大容易OOM,太小缓存命中率低。

// 获取应用最大可用内存
int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024);
int cacheSize = maxMemory / 8;

LruCache<String, Bitmap> memoryCache = new LruCache<String, Bitmap>(cacheSize) {
    @Override
    protected int sizeOf(String key, Bitmap bitmap) {
        // 返回Bitmap占用的内存大小(单位:KB)
        return bitmap.getByteCount() / 1024;
    }
};

磁盘缓存我习惯用DiskLruCache,它把缓存文件存储在SD卡或应用私有目录。加载图片时,先查内存缓存,命中就直接用;没命中就查磁盘缓存,读出来再放进内存;磁盘也没有,才去网络下载。

这个三级缓存策略,说白了就是拿空间换时间。内存快但容量小,磁盘慢但容量大,网络最慢但数据源无限。我参与过的一个电商App,商品图片列表滑动流畅,靠的就是这套缓存机制。

总结一下:Bitmap优化的核心就四个字——按需、复用。按需加载控制内存峰值,复用减少GC压力,缓存提升加载速度。这三板斧用好了,Bitmap相关的OOM基本就能跟你say goodbye了。

嗯,最后再啰嗦一句。优化不是一蹴而就的,我建议你在开发阶段就开启Android Studio的Profiler,盯着内存分配曲线看。哪里冒尖了,哪里频繁GC了,一目了然。别等到线上OOM了才想起来优化,那时候就晚了。