基于 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 会自动保留最近查看的图片,离开屏幕较久的图片会被优先淘汰,符合典型图片浏览场景。