LruCache 图片浏览器内存缓存

基于 android.util.LruCache 实现的图片浏览器内存缓存,主要特点:

  • 容量可控 :默认上限 8MB ,按 Bitmap 实际占用字节数计量。
  • LRU 淘汰:缓存总量超过上限时,自动移除最久未被访问的图片。
  • 安全回收 :被淘汰的图片不直接 recycle(),而是由 GC 在无人引用后释放,避免误回收仍显示在 ImageView 上的图片。
  • 单张限制:支持设置单张图片最大字节数,防止单张大图占满整个缓存。
  • 两级缓存 :可配合 DiskLruCache 实现内存 + 磁盘二级缓存。

完整代码

csharp 复制代码
package com.example.imagebrowser.cache;
​
import android.graphics.Bitmap;
import android.util.LruCache;
​
/**
 * 图片浏览器内存缓存。
 *
 * <p>基于 {@link LruCache} 实现,按 Bitmap 实际字节数计量容量。
 * 默认上限 8MB,支持自定义缓存容量和单张图片大小上限。
 * 超过容量时按 LRU 策略淘汰旧图,但不主动 {@link Bitmap#recycle()},
 * 由 GC 在 Bitmap 无人引用后回收像素内存,防止误回收仍显示在 ImageView 上的图片。</p>
 */
public class ImageMemoryCache {
​
    /**
     * 缓存容量上限:8MB。
     * 单位必须与 sizeOf() 的返回值保持一致(字节),否则容量控制会失准。
     */
    private static final int MAX_CACHE_BYTES = 8 * 1024 * 1024;
​
    /**
     * 动态容量系数:取应用最大可用堆内存的 1/8。
     * 默认值 8MB 较小,适合对内存占用敏感;动态值适合大图浏览场景。
     */
    private static final int DYNAMIC_MEMORY_FRACTION = 8;
​
    /**
     * 单张图片大小上限:默认与缓存容量相同。
     * 超过此大小的图片不会存入缓存,避免一张图把整个缓存撑爆。
     */
    private static final int MAX_BITMAP_BYTES = MAX_CACHE_BYTES;
​
    private final LruCache<String, Bitmap> mLruCache;
    private final int mMaxBitmapBytes;
​
    /** 默认构造:使用固定 8MB 容量,单张上限 8MB。 */
    public ImageMemoryCache() {
        this(MAX_CACHE_BYTES, MAX_BITMAP_BYTES);
    }
​
    /**
     * 指定缓存容量的构造方法。
     * 传入字节数即可,例如传入 (int)(Runtime.getRuntime().maxMemory() / 8)。
     * 单张图片上限默认与缓存容量相同。
     */
    public ImageMemoryCache(int maxCacheBytes) {
        this(maxCacheBytes, maxCacheBytes);
    }
​
    /**
     * 同时指定缓存容量和单张图片上限的构造方法。
     * maxBitmapBytes 用于拦截过大的 Bitmap,防止单张图把缓存占满。
     */
    public ImageMemoryCache(int maxCacheBytes, int maxBitmapBytes) {
        this.mMaxBitmapBytes = maxBitmapBytes;
        mLruCache = new LruCache<String, Bitmap>(maxCacheBytes) {
​
            /** 以 Bitmap 实际占用的字节数作为一条缓存的大小。 */
            @Override
            protected int sizeOf(String key, Bitmap value) {
                // 返回单个 Bitmap 占用的字节数,这样 LruCache 才知道当前缓存总大小
                return value.getByteCount();
            }
​
            /**
             * 某条缓存被移除时回调。
             * 不在内部调用 recycle(),避免被淘汰时图片仍挂在 ImageView 上导致崩溃。
             * 移除后 Bitmap 只剩外部引用(如 ImageView),当 ImageView 切换图片后由 GC 回收。
             */
            @Override
            protected void entryRemoved(boolean evicted, String key,
                                          Bitmap oldValue, Bitmap newValue) {
                // 安全提示:不要在这里 recycle(),因为无法确定 oldValue 是否还在被显示。
                // 若确实需要立刻释放 native 内存,请使用引用计数方案(见文末)。
            }
        };
    }
​
    /** 存入一张图片。key 建议使用图片唯一标识(如 URL 或文件路径)。 */
    public void put(String key, Bitmap bitmap) {
        // 防御空指针,避免 null 进入缓存导致后续 get/remove 异常
        if (key == null || bitmap == null) {
            return;
        }
        // 单张图片大小限制:超过上限的不入缓存,避免一张图把缓存撑爆
        if (bitmap.getByteCount() > mMaxBitmapBytes) {
            return;
        }
        mLruCache.put(key, bitmap);
    }
​
    /** 单张图片大小上限(字节)。 */
    public int maxBitmapSizeInBytes() {
        return mMaxBitmapBytes;
    }
​
    /** 取出一张图片,命中会将其标记为"最近使用";未命中返回 null。 */
    public Bitmap get(String key) {
        if (key == null) {
            return null;
        }
        Bitmap bitmap = mLruCache.get(key);
        // 防御:极端情况下取到已被回收的图片,按未命中处理并移除脏数据
        if (bitmap != null && bitmap.isRecycled()) {
            mLruCache.remove(key);
            return null;
        }
        return bitmap;
    }
​
    /**
     * 主动移除一张图片。此时回调的 evicted 为 false,不在缓存内部回收,
     * 由调用方确认真无引用后再决定是否 recycle。
     */
    public Bitmap remove(String key) {
        if (key == null) {
            return null;
        }
        return mLruCache.remove(key);
    }
​
    /** 清空缓存。通常用于系统内存吃紧(onTrimMemory)或页面销毁时。 */
    public void clear() {
        mLruCache.evictAll();
    }
​
    /** 当前缓存占用的字节数。 */
    public int sizeInBytes() {
        return mLruCache.size();
    }
​
    /** 缓存容量上限(字节)。 */
    public int maxSizeInBytes() {
        return mLruCache.maxSize();
    }
​
    /** 命中次数,可用于观察缓存效果。 */
    public int hitCount() {
        return mLruCache.hitCount();
    }
​
    /** 未命中次数,可用于观察缓存效果。 */
    public int missCount() {
        return mLruCache.missCount();
    }
}

在图片浏览器里怎么接

scss 复制代码
// 使用动态容量:取应用最大堆内存的 1/8 作为缓存上限
// 如需固定 8MB,可改用 new ImageMemoryCache()
// 第三个参数可限制单张图片大小,例如 4MB,超过则不入缓存
private final ImageMemoryCache mImageCache = new ImageMemoryCache(
        (int) (Runtime.getRuntime().maxMemory() / 8),
        4 * 1024 * 1024
);
​
private void loadImage(ImageView iv, String url) {
    // 1) 先查内存缓存
    Bitmap cached = mImageCache.get(url);
    if (cached != null) {
        // 命中:直接设置,避免重复解码和 I/O
        iv.setImageBitmap(cached);
        return;
    }
​
    // 2) 未命中再解码(务必先按显示尺寸 inSampleSize 采样,别把原图塞进来)
    // 例如 ImageView 为 300×300,原图为 4000×3000,应按 inSampleSize=4 或 8 解码,
    // 否则单张图可能超过 maxBitmapBytes,put() 直接拒绝入缓存。
    Bitmap bitmap = decodeSampledBitmap(url, iv.getWidth(), iv.getHeight());
    if (bitmap != null) {
        mImageCache.put(url, bitmap); // 3) 存入缓存,超容量自动淘汰旧图
        iv.setImageBitmap(bitmap);
    }
}
​
@Override
public void onTrimMemory(int level) {
    if (level >= TRIM_MEMORY_MODERATE) {
        // 内存吃紧时整体释放,让系统有更多可用内存;
        // TRIM_MEMORY_MODERATE 表示系统内存已偏低,可清理非必要缓存
        mImageCache.clear();
    }
}

单张图片大小限制

新增 maxBitmapBytes 参数,限制单张图片能进入缓存的最大字节数。超过上限的图片会被 put() 直接拒绝,防止一张超大图把整个缓存占满,导致其他图片频繁被淘汰。

yaml 复制代码
// 缓存上限 8MB,单张图片超过 4MB 就不入缓存
ImageMemoryCache cache = new ImageMemoryCache(
        8 * 1024 * 1024,
        4 * 1024 * 1024
);

磁盘缓存:DiskLruCache

内存缓存进程杀掉就清空,图片浏览器通常还需要磁盘缓存做二级兜底。流程是:

复制代码
ImageView 需要图片
    ↓
查内存缓存 → 命中直接显示
    ↓ 未命中
查磁盘缓存 → 命中解码后写入内存缓存,再显示
    ↓ 未命中
从网络/文件加载 → 解码 → 写入内存缓存 + 磁盘缓存 → 显示

Gradle 依赖

arduino 复制代码
implementation 'com.jakewharton:disklrucache:2.0.2'

磁盘缓存封装

java 复制代码
package com.example.imagebrowser.cache;
​
import android.content.Context;
​
import com.jakewharton.disklrucache.DiskLruCache;
​
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
​
/**
 * 图片磁盘缓存。
 *
 * <p>保存图片原始字节(如 JPEG/PNG),容量固定 50MB。key 必须是 URL 的文件名安全版本,
 * 所以内部会对 URL 做 MD5 哈希。</p>
 */
public class ImageDiskCache {
​
    private static final int MAX_DISK_CACHE_SIZE = 50 * 1024 * 1024; // 50MB
    private static final int VALUE_COUNT = 1;
    private static final int APP_VERSION = 1;
​
    private final DiskLruCache mDiskLruCache;
​
    public ImageDiskCache(Context context) throws IOException {
        File cacheDir = new File(context.getCacheDir(), "image_disk_cache");
        mDiskLruCache = DiskLruCache.open(
                cacheDir, APP_VERSION, VALUE_COUNT, MAX_DISK_CACHE_SIZE);
    }
​
    /** 从磁盘读取图片原始数据。 */
    public InputStream get(String key) {
        try {
            DiskLruCache.Snapshot snapshot = mDiskLruCache.get(hashKey(key));
            if (snapshot != null) {
                return snapshot.getInputStream(0);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
        return null;
    }
​
    /** 把图片原始数据写入磁盘。 */
    public void put(String key, InputStream inputStream) {
        String hash = hashKey(key);
        try {
            DiskLruCache.Editor editor = mDiskLruCache.edit(hash);
            if (editor == null) {
                return;
            }
            OutputStream out = editor.newOutputStream(0);
            byte[] buffer = new byte[8192];
            int len;
            while ((len = inputStream.read(buffer)) != -1) {
                out.write(buffer, 0, len);
            }
            out.flush();
            out.close();
            inputStream.close();
            editor.commit();
        } catch (IOException e) {
            e.printStackTrace();
            try {
                mDiskLruCache.remove(hash);
            } catch (IOException ignored) {
            }
        }
    }
​
    /** 清空磁盘缓存。 */
    public void clear() {
        try {
            mDiskLruCache.delete();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
​
    private String hashKey(String key) {
        try {
            MessageDigest md = MessageDigest.getInstance("MD5");
            byte[] digest = md.digest(key.getBytes());
            StringBuilder sb = new StringBuilder();
            for (byte b : digest) {
                sb.append(String.format("%02x", b));
            }
            return sb.toString();
        } catch (NoSuchAlgorithmException e) {
            return String.valueOf(key.hashCode());
        }
    }
}

两级缓存一起用

scss 复制代码
private final ImageMemoryCache mMemoryCache = new ImageMemoryCache(
        (int) (Runtime.getRuntime().maxMemory() / 8),
        4 * 1024 * 1024
);
private ImageDiskCache mDiskCache;
​
@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    try {
        mDiskCache = new ImageDiskCache(this);
    } catch (IOException e) {
        e.printStackTrace();
    }
}
​
private void loadImage(ImageView iv, String url) {
    // 1) 内存缓存
    Bitmap cached = mMemoryCache.get(url);
    if (cached != null) {
        iv.setImageBitmap(cached);
        return;
    }
​
    // 2) 磁盘缓存 → 解码 → 回写内存
    if (mDiskCache != null) {
        InputStream is = mDiskCache.get(url);
        if (is != null) {
            Bitmap bitmap = decodeSampledBitmap(is, iv.getWidth(), iv.getHeight());
            closeQuietly(is);
            if (bitmap != null) {
                mMemoryCache.put(url, bitmap);
                iv.setImageBitmap(bitmap);
                return;
            }
        }
    }
​
    // 3) 从网络或文件加载(示例:异步加载)
    downloadAndDecode(iv, url);
}
​
private void downloadAndDecode(ImageView iv, String url) {
    // 请在后台线程执行网络请求
    new Thread(() -> {
        try {
            InputStream netStream = new URL(url).openStream();
            if (mDiskCache != null) {
                mDiskCache.put(url, new BufferedInputStream(netStream));
                netStream = mDiskCache.get(url);
            }
            Bitmap bitmap = decodeSampledBitmap(netStream, iv.getWidth(), iv.getHeight());
            closeQuietly(netStream);
​
            if (bitmap != null) {
                Bitmap finalBitmap = bitmap;
                iv.post(() -> {
                    mMemoryCache.put(url, finalBitmap);
                    iv.setImageBitmap(finalBitmap);
                });
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }).start();
}
​
private void closeQuietly(InputStream is) {
    if (is != null) {
        try {
            is.close();
        } catch (IOException ignored) {
        }
    }
}

磁盘缓存注意点

  • key 要哈希 :DiskLruCache 的 key 只能包含 [a-z0-9_-]{1,64},URL 不能直接当 key,需要 MD5/SHA-1。
  • 存原始字节 :磁盘缓存存的是下载下来的原始文件字节,不是解码后的 Bitmap,否则体积太大。
  • IO 必须在后台线程:磁盘读写是耗时操作,不要在主线程调用。
  • 生命周期:页面退出时不需要清磁盘缓存,应用卸载或用户手动清理时才会消失。
  • 容量选择:50MB~100MB 是比较常见的值,视应用图片量而定。
  • 配合内存缓存:从磁盘读出后建议再写回内存缓存,避免同一页反复解码。

进阶:引用计数 + 安全回收(可选)

如果你希望"淘汰时立刻回收 native 内存"且又不崩溃,需要确认 Bitmap 真的没人用了。下面是一个简化的引用计数思路:

arduino 复制代码
public class RefCountedBitmap {
    private final Bitmap bitmap;
    private int refCount = 0;
​
    public RefCountedBitmap(Bitmap bitmap) {
        this.bitmap = bitmap;
    }
​
    /** 被 ImageView 使用时调用。 */
    public synchronized void acquire() {
        refCount++;
    }
​
    /** ImageView 切换图片时调用。 */
    public synchronized void release() {
        if (--refCount <= 0 && !bitmap.isRecycled()) {
            bitmap.recycle();
        }
    }
​
    public Bitmap getBitmap() {
        return bitmap;
    }
}

缓存里存 RefCountedBitmap,加载到 ImageView 前 acquire(),替换/销毁时 release()。当缓存淘汰且引用计数归零时再 recycle()。这样既能立即释放内存,又不会误回收正在显示的图片。


额外补充

  • 命中率观察 :可通过 hitCount() 和 missCount() 计算命中率,评估是否需要调整缓存大小或采样策略。
  • key 的选择:建议用图片的 URL 或文件路径作为 key,避免重复图片占用多份缓存。
  • 缓存与列表滚动:在 RecyclerView/ListView 快速滚动时,LRU 会自动保留最近查看的图片,离开屏幕较久的图片会被优先淘汰,符合典型图片浏览场景。
相关推荐
玩美移动1 小时前
AI Skin Analysis API 技术解析:文件上传、异步任务与结果读取
java·人工智能·python
我是小白呀1 小时前
19-Temporal项目实战:将客户开通流程迁移到持久执行架构
java·开发语言·人工智能·架构·workflow
步行cgn1 小时前
Spring 负责注入的注解详解
java·sql·spring
摇滚侠1 小时前
《On Java 中文版 基础卷》阅读笔记 安装 Java 和本书示例 02
java·开发语言·笔记
今天要早睡_2 小时前
C++入门到精通:类和对象(下)全方位解析
android·c++
许彰午2 小时前
53-审计三表
java·低代码·架构
重生之小比特2 小时前
【C++进阶】红黑树的实现
java·开发语言·c++
程序猿乐锅2 小时前
【黑马点评 | 第九篇】秒杀优化-异步实现
java·spring boot·redis·后端·spring·中间件·mybatis
FYKJ_20103 小时前
springboot助农产品销售商城05829-计算机课程设计、毕业设计
java·spring boot·python·mysql·spark·django