七、大图加载与缓存策略:分片加载、LRU缓存与DiskLruCache实现

各位同学,今天我们来聊聊大图加载。说实话,这个问题在Android开发中太常见了。我见过太多App因为一张大图直接OOM,用户正刷着朋友圈呢,啪,闪退了。嗯,咱们今天就把这块彻底讲透。

7.1 大图加载的痛点

先说说为什么大图容易出问题。一张5000x3000像素的图片,如果直接加载到内存里,占多大空间?

// 以ARGB_8888为例,每个像素占4字节
内存占用 = 5000 * 3000 * 4 = 60,000,000字节 ≈ 57MB

57MB!你想想看,一个App的内存上限通常就128MB或256MB,一张图吃掉一半,再来几张不就崩了?

我在项目中遇到过这样的情况:用户上传了一张单反拍的照片,分辨率高达8000x6000,结果在列表页直接加载,OOM了。后来我们做了分片加载,问题才解决。

7.2 分片加载:BitmapRegionDecoder

分片加载的核心思想很简单:只看你需要的部分。就像你看一幅巨大的清明上河图,你不会一次性看全貌,而是看哪个局部就放大哪个局部。

Android提供了BitmapRegionDecoder这个类,专门干这个事。

// 初始化区域解码器
InputStream is = getAssets().open("big_image.jpg");
BitmapRegionDecoder decoder = BitmapRegionDecoder.newInstance(is, false);

// 定义要加载的区域(比如屏幕左上角)
Rect rect = new Rect(0, 0, 1080, 1920);
BitmapFactory.Options options = new BitmapFactory.Options();
options.inSampleSize = 2;  // 采样率,减少内存

// 只解码指定区域
Bitmap bitmap = decoder.decodeRegion(rect, options);
imageView.setImageBitmap(bitmap);

使用场景

  • 长图浏览(类似微博长图)
  • 高分辨率地图的局部显示
  • 超大图片的缩略图预览
注意:BitmapRegionDecoder不支持所有图片格式。我踩过坑——WebP格式的图片,部分解码器会返回null。建议用PNG或JPEG。

7.3 三级缓存架构设计

缓存这东西,说白了就是用空间换时间。我习惯把缓存分成三层:内存、磁盘、网络。就像你家里有三个冰箱:一个在厨房(内存),一个在地下室(磁盘),还有一个是超市(网络)。

先看整体架构图:

三级缓存架构 L1:内存缓存 LRUCache(最快,容量最小) 未命中 L2:磁盘缓存 DiskLruCache(较快,容量中等) 未命中 L3:网络加载 HTTP请求(最慢,容量无限) 加载流程 1. 先查L1内存 2. 未命中查L2磁盘 3. 未命中查L3网络 4. 网络加载成功后 写入L2和L1 5. 内存满时淘汰 最近最少使用 6. 磁盘满时淘汰 最早未使用 性能对比 L1:纳秒级 L2:毫秒级 L3:秒级 命中率目标: L1 > 60% L2 > 30% L3 < 10%

7.4 LRU缓存实现

LRU(Least Recently Used)缓存,说白了就是淘汰最久没被用过的那个。Android官方提供了LruCache,用起来很方便。

// 设置内存缓存大小为最大可用内存的1/8
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;
    }

    @Override
    protected void entryRemoved(boolean evicted, String key,
                                 Bitmap oldValue, Bitmap newValue) {
        // 当缓存项被移除时回调,可以在这里做资源回收
        // 我曾经在这里忘记回收,导致内存泄漏,大家注意
    }
};

// 使用示例
public Bitmap getBitmapFromMemory(String key) {
    return memoryCache.get(key);
}

public void putBitmapToMemory(String key, Bitmap bitmap) {
    if (getBitmapFromMemory(key) == null) {
        memoryCache.put(key, bitmap);
    }
}
个人经验:LruCache的sizeOf方法一定要重写。默认是按条目数计算的,但Bitmap大小差异很大。我见过有人没重写,结果一张大图占了几十MB,缓存还认为只占了一个条目,直接OOM。

7.5 DiskLruCache实现

DiskLruCache不是Android官方库,但Google在官方文档里推荐过。它把缓存存到磁盘上,App重启后还能用。

// 初始化DiskLruCache
File cacheDir = new File(context.getCacheDir(), "bitmap_cache");
DiskLruCache diskCache = DiskLruCache.open(
    cacheDir,          // 缓存目录
    1,                 // App版本号
    1,                 // 每个key对应几个文件
    50 * 1024 * 1024   // 最大缓存50MB
);

// 写入缓存
public void putBitmapToDisk(String key, Bitmap bitmap) {
    DiskLruCache.Editor editor = diskCache.edit(key);
    if (editor != null) {
        OutputStream os = editor.newOutputStream(0);
        bitmap.compress(Bitmap.CompressFormat.JPEG, 90, os);
        editor.commit();
    }
}

// 读取缓存
public Bitmap getBitmapFromDisk(String key) {
    DiskLruCache.Snapshot snapshot = diskCache.get(key);
    if (snapshot != null) {
        InputStream is = snapshot.getInputStream(0);
        return BitmapFactory.decodeStream(is);
    }
    return null;
}

// 关闭缓存(建议在Application的onTerminate中调用)
public void closeDiskCache() {
    if (diskCache != null && !diskCache.isClosed()) {
        diskCache.close();
    }
}
避坑指南:我曾经在写入DiskLruCache时忘记调用editor.commit(),结果缓存一直写不进去。还有,DiskLruCache的key不能包含特殊字符,建议用MD5加密一下。

7.6 三级缓存整合

把上面三部分整合起来,就是一个完整的缓存系统:

public class ImageCacheManager {
    private LruCache<String, Bitmap> memoryCache;
    private DiskLruCache diskCache;

    public ImageCacheManager(Context context) {
        // 初始化内存缓存
        int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024);
        int cacheSize = maxMemory / 8;
        memoryCache = new LruCache<>(cacheSize);

        // 初始化磁盘缓存
        try {
            File cacheDir = new File(context.getCacheDir(), "images");
            diskCache = DiskLruCache.open(cacheDir, 1, 1, 50 * 1024 * 1024);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    public Bitmap getBitmap(String key) {
        // 1. 查内存
        Bitmap bitmap = memoryCache.get(key);
        if (bitmap != null) {
            return bitmap;
        }

        // 2. 查磁盘
        bitmap = getFromDisk(key);
        if (bitmap != null) {
            memoryCache.put(key, bitmap);  // 回写到内存
            return bitmap;
        }

        // 3. 都没命中,返回null,由调用方去网络加载
        return null;
    }

    public void putBitmap(String key, Bitmap bitmap) {
        // 同时写入内存和磁盘
        memoryCache.put(key, bitmap);
        putToDisk(key, bitmap);
    }
}

7.7 实战中的优化点

在实际项目中,光有缓存还不够。我总结了几条经验:

  1. 预加载:用户滑动列表时,提前加载下一屏的图片。我习惯用RecyclerView的OnScrollListener来做。
  2. 缩略图策略:列表页用小图,详情页用大图。别在列表页加载原图,那是找死。
  3. 内存监控:用LeakCanary检测Bitmap是否泄漏。我遇到过Activity销毁了,Bitmap还被缓存引用着,导致内存泄漏。
  4. 磁盘缓存清理:App版本升级时,旧版本的缓存可能不兼容,建议清空重建。

核心要点回顾:

  • 大图用BitmapRegionDecoder分片加载
  • 三级缓存:内存→磁盘→网络
  • LruCache用sizeOf正确计算Bitmap大小
  • DiskLruCache注意commit和key的合法性
  • 缓存命中率:L1 > 60%,L2 > 30%,L3 < 10%

好了,大图加载和缓存策略就讲到这里。这些技术我在多个项目里验证过,效果都不错。大家在实际开发中,一定要根据自己App的特点来调整缓存大小和策略,没有银弹。

公众号:蓝海资料掘金营,微信deep3321