ImageCache 的 LRU 策略:图片内存占用的精确计算与清理机制
大家好,今天我们来深入探讨 ImageCache 的实现,特别是其中至关重要的 LRU (Least Recently Used) 策略,以及如何精确计算图片内存占用并实现有效的清理机制。在移动应用开发中,图片资源占据了相当大的内存比例,不合理的缓存策略会导致 OOM (Out Of Memory) 错误,影响用户体验。因此,理解并正确实现一个高效的 ImageCache 至关重要。
1. ImageCache 的基本结构
一个基础的 ImageCache 通常包含以下几个核心组件:
- 缓存存储结构: 用于存储图片的容器,常见的选择是
LruCache(Android SDK 提供) 或者自定义的LinkedHashMap。 - 键 (Key): 用于唯一标识图片的键,通常是图片的 URL 或者文件名。
- 值 (Value): 图片本身,通常是
Bitmap对象。 - 大小计算器 (Size Calculator): 用于计算每个图片所占用的内存大小。
- LRU 策略: 用于决定何时以及如何移除缓存中的图片。
2. LruCache 的原理和使用
Android 提供的 LruCache 类实现了 LRU 缓存策略。它的基本原理是:当缓存已满时,移除最近最少使用的项。LruCache 基于 LinkedHashMap 实现,LinkedHashMap 可以维护插入顺序或者访问顺序。
以下是一个简单的 LruCache 使用示例:
import android.graphics.Bitmap;
import android.util.LruCache;
public class ImageCache {
private LruCache<String, Bitmap> memoryCache;
public ImageCache(int cacheSize) {
// Use 1/8th of the available memory for this memory cache.
final int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024);
if (cacheSize <= 0) {
cacheSize = maxMemory / 8;
}
memoryCache = new LruCache<String, Bitmap>(cacheSize) {
@Override
protected int sizeOf(String key, Bitmap bitmap) {
// The cache size will be measured in kilobytes rather than
// number of items.
return bitmap.getByteCount() / 1024;
}
};
}
public void addBitmapToMemoryCache(String key, Bitmap bitmap) {
if (getBitmapFromMemCache(key) == null) {
memoryCache.put(key, bitmap);
}
}
public Bitmap getBitmapFromMemCache(String key) {
return memoryCache.get(key);
}
public void removeBitmapFromMemoryCache(String key) {
memoryCache.remove(key);
}
public void clearCache() {
memoryCache.evictAll();
}
}
代码解释:
- 构造函数: 初始化
LruCache,传入缓存大小cacheSize(单位为 KB)。如果cacheSize小于等于 0,则使用可用内存的 1/8 作为默认大小。 sizeOf()方法: 这是LruCache的关键方法,用于计算每个缓存项的大小。这里我们使用bitmap.getByteCount()获取Bitmap占用的字节数,然后除以 1024 转换为 KB。addBitmapToMemoryCache()方法: 将Bitmap添加到缓存中。getBitmapFromMemCache()方法: 从缓存中获取Bitmap。removeBitmapFromMemoryCache()方法: 从缓存中移除Bitmap。clearCache()方法: 清空整个缓存。
3. 精确计算 Bitmap 内存占用
Bitmap 占用的内存大小直接影响 ImageCache 的效率和性能。计算 Bitmap 内存占用的方法至关重要。
3.1 Bitmap.getByteCount() vs. Bitmap.getAllocationByteCount()
在 Android API 12 (Honeycomb) 之后,Bitmap 类引入了 getByteCount() 和 getAllocationByteCount() 两个方法。
getByteCount(): 返回用于存储Bitmap像素数据的最小字节数。这个值可能小于getAllocationByteCount()。getAllocationByteCount(): 返回Bitmap分配的内存大小。这个值通常大于或等于getByteCount()。
在 sizeOf() 方法中,建议使用 getAllocationByteCount(),因为它能更准确地反映 Bitmap 实际占用的内存大小,尤其是在 Bitmap 被复用时。
3.2 不同 Bitmap 配置的内存占用
Bitmap.Config 决定了 Bitmap 的像素存储格式,不同的配置会影响 Bitmap 的内存占用。
| Bitmap.Config | 每像素占用字节数 | 说明 |
|---|---|---|
| ALPHA_8 | 1 | 每个像素 8 位,仅存储 Alpha 通道。 |
| ARGB_4444 | 2 | 每个像素 16 位,分别存储 Alpha、Red、Green、Blue 通道,每个通道 4 位。已弃用,不建议使用,质量差。 |
| ARGB_8888 | 4 | 每个像素 32 位,分别存储 Alpha、Red、Green、Blue 通道,每个通道 8 位。常用,质量好,占用内存大。 |
| RGB_565 | 2 | 每个像素 16 位,分别存储 Red、Green、Blue 通道,Red 通道 5 位,Green 通道 6 位,Blue 通道 5 位。不包含 Alpha 通道,占用内存较小。 |
| HARDWARE (API Level 26+) | 未知 | Bitmap 存储在硬件内存中,而不是 Dalvik 堆中。 优点是减少了 Java 堆的压力,但缺点是操作 Bitmap 的速度可能会变慢,并且不能直接访问像素数据。使用 copyPixelsToBuffer() 和 copyPixelsFromBuffer() 进行像素数据操作。 |
因此,在选择 Bitmap.Config 时,需要在内存占用和图像质量之间进行权衡。如果不需要 Alpha 通道,可以使用 RGB_565 来节省内存。如果需要高质量的图像,可以使用 ARGB_8888。
3.3 计算 Bitmap 大小的示例代码
import android.graphics.Bitmap;
public class BitmapUtils {
public static int getBitmapSize(Bitmap bitmap) {
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.KITKAT) {
return bitmap.getAllocationByteCount();
} else if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.HONEYCOMB_MR1) {
return bitmap.getByteCount();
} else {
return bitmap.getRowBytes() * bitmap.getHeight();
}
}
}
代码解释:
- 根据 Android API 版本,选择不同的方法来计算
Bitmap的大小。 - 在 API 19 (KitKat) 及以上版本,使用
getAllocationByteCount()。 - 在 API 12 (Honeycomb MR1) 到 API 18 版本,使用
getByteCount()。 - 在 API 11 及以下版本,使用
getRowBytes() * getHeight()计算。
4. 自定义 LRU Cache 的实现
虽然 LruCache 提供了方便的 LRU 策略实现,但在某些情况下,我们可能需要自定义 LRU Cache,例如:
- 更灵活的缓存淘汰策略: 除了 LRU,还可以考虑其他策略,例如 LFU (Least Frequently Used) 或者基于图片重要性的策略。
- 更精细的内存管理: 可以根据设备内存状况动态调整缓存大小。
- 异步加载和缓存: 可以结合线程池或者协程实现异步加载和缓存。
以下是一个简单的自定义 LRU Cache 的示例:
import android.graphics.Bitmap;
import java.util.LinkedHashMap;
import java.util.Map;
public class CustomLruCache {
private final LinkedHashMap<String, Bitmap> cache;
private long size;
private long maxSize;
public CustomLruCache(long maxSize) {
this.maxSize = maxSize;
this.cache = new LinkedHashMap<String, Bitmap>(0, 0.75f, true); // accessOrder = true, 实现 LRU
}
public synchronized Bitmap get(String key) {
return cache.get(key);
}
public synchronized void put(String key, Bitmap bitmap) {
if (key == null || bitmap == null) {
throw new NullPointerException("key == null || bitmap == null");
}
long bitmapSize = getBitmapSize(bitmap);
if (bitmapSize > maxSize) {
// 如果单张图片大于最大缓存大小,不缓存
return;
}
while (size + bitmapSize > maxSize) {
// 移除最近最少使用的元素
Map.Entry<String, Bitmap> eldest = cache.entrySet().iterator().next();
if (eldest == null) {
break;
}
remove(eldest.getKey());
}
cache.put(key, bitmap);
size += bitmapSize;
}
public synchronized Bitmap remove(String key) {
Bitmap previous = cache.remove(key);
if (previous != null) {
size -= getBitmapSize(previous);
}
return previous;
}
private long getBitmapSize(Bitmap bitmap) {
return BitmapUtils.getBitmapSize(bitmap); // 使用前面定义的 BitmapUtils
}
public synchronized void clear() {
cache.clear();
size = 0;
}
public synchronized long size() {
return size;
}
public synchronized long maxSize() {
return maxSize;
}
}
代码解释:
LinkedHashMap: 使用LinkedHashMap存储Bitmap,并将accessOrder设置为true,以实现 LRU 策略。size和maxSize:size记录当前缓存的大小,maxSize记录最大缓存大小。get()方法: 从缓存中获取Bitmap。put()方法: 将Bitmap添加到缓存中。如果缓存已满,则移除最近最少使用的元素。remove()方法: 从缓存中移除Bitmap。getBitmapSize()方法: 使用前面定义的BitmapUtils计算Bitmap的大小。clear()方法: 清空缓存。size()和maxSize()方法: 返回当前缓存大小和最大缓存大小。
自定义 LRU Cache 的优势:
- 更好的控制: 可以更精确地控制缓存的行为。
- 更灵活的策略: 可以实现更复杂的缓存淘汰策略。
- 异步支持: 可以更容易地实现异步加载和缓存。
5. ImageCache 的优化策略
除了选择合适的缓存结构和精确计算 Bitmap 大小,还可以采取以下优化策略来提高 ImageCache 的性能:
- 压缩 Bitmap: 在加载图片时,可以对
Bitmap进行压缩,以减少内存占用。可以使用BitmapFactory.Options来控制图片的采样率和解码配置。 - 复用 Bitmap: 尽量复用
Bitmap对象,避免频繁创建和销毁Bitmap。可以使用inBitmap选项来复用Bitmap。 - 使用弱引用或软引用: 可以使用
WeakReference或SoftReference来存储Bitmap,以便在内存不足时,GC 可以回收这些Bitmap。但是需要注意,在使用弱引用或软引用时,需要处理Bitmap被回收的情况。 - 磁盘缓存: 除了内存缓存,还可以使用磁盘缓存来存储图片。磁盘缓存可以提供更大的存储空间,并且在应用程序重启后仍然可用。
- 网络优化: 优化网络请求,减少图片加载时间。可以使用 CDN、HTTP 缓存等技术。
- 避免内存泄漏: 确保在不需要使用
Bitmap时,及时释放Bitmap占用的内存。
6. 结合磁盘缓存实现更完善的 ImageCache
仅仅依靠内存缓存是不够的,因为内存资源有限,而且应用程序重启后内存缓存会丢失。为了实现更完善的 ImageCache,需要结合磁盘缓存。
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
public class DiskLruCacheWrapper {
private DiskLruCache diskLruCache;
private final String uniqueName;
private final int appVersion;
private final Context context;
private final long maxDiskCacheSize;
public DiskLruCacheWrapper(Context context, String uniqueName, int appVersion, long maxDiskCacheSize) {
this.context = context;
this.uniqueName = uniqueName;
this.appVersion = appVersion;
this.maxDiskCacheSize = maxDiskCacheSize;
}
public void put(String key, Bitmap bitmap) {
DiskLruCache.Editor editor = null;
try {
diskLruCache = DiskLruCache.open(getDiskCacheDir(context, uniqueName), appVersion, 1, maxDiskCacheSize);
editor = diskLruCache.edit(key);
if (editor == null) {
return;
}
if (writeBitmapToFile(bitmap, editor)) {
diskLruCache.flush();
editor.commit();
} else {
editor.abort();
}
} catch (IOException e) {
try {
if (editor != null) {
editor.abort();
}
} catch (IOException ignored) {
}
}
}
public Bitmap get(String key) {
try {
diskLruCache = DiskLruCache.open(getDiskCacheDir(context, uniqueName), appVersion, 1, maxDiskCacheSize);
DiskLruCache.Snapshot snapShot = diskLruCache.get(key);
if (snapShot != null) {
FileInputStream inputStream = (FileInputStream) snapShot.getInputStream(0);
return BitmapFactory.decodeStream(inputStream);
}
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
private File getDiskCacheDir(Context context, String uniqueName) {
String cachePath;
if (android.os.Environment.MEDIA_MOUNTED.equals(android.os.Environment.getExternalStorageState())
|| !android.os.Environment.isExternalStorageRemovable()) {
cachePath = context.getExternalCacheDir().getPath();
} else {
cachePath = context.getCacheDir().getPath();
}
return new File(cachePath + File.separator + uniqueName);
}
private boolean writeBitmapToFile(Bitmap bitmap, DiskLruCache.Editor editor) throws IOException {
FileOutputStream out = null;
try {
out = new FileOutputStream(editor.getFile(0));
return bitmap.compress(Bitmap.CompressFormat.PNG, 100, out);
} finally {
if (out != null) {
out.close();
}
}
}
}
代码解释:
DiskLruCacheWrapper: 封装了DiskLruCache的操作。put()方法: 将Bitmap写入磁盘缓存。get()方法: 从磁盘缓存读取Bitmap。getDiskCacheDir()方法: 获取磁盘缓存目录。优先使用外部缓存目录,如果外部存储不可用,则使用内部缓存目录。writeBitmapToFile()方法: 将Bitmap写入文件。
结合内存缓存和磁盘缓存:
可以将内存缓存和磁盘缓存结合起来使用,形成一个两级缓存:
- 首先从内存缓存中查找
Bitmap,如果找到,则直接返回。 - 如果内存缓存中没有找到,则从磁盘缓存中查找
Bitmap,如果找到,则将Bitmap添加到内存缓存,并返回。 - 如果磁盘缓存中也没有找到,则从网络加载
Bitmap,将Bitmap添加到内存缓存和磁盘缓存,并返回。
这种两级缓存策略可以提高 ImageCache 的命中率和性能。
7. ImageCache 的线程安全问题
在多线程环境下,ImageCache 可能会存在线程安全问题。例如,多个线程同时访问 LruCache 或者自定义 LRU Cache,可能会导致数据竞争和崩溃。
为了解决线程安全问题,可以使用以下方法:
- 使用
synchronized关键字: 可以使用synchronized关键字来同步对LruCache或自定义 LRU Cache 的访问。 - 使用
ConcurrentHashMap: 可以使用ConcurrentHashMap代替LinkedHashMap来存储Bitmap。ConcurrentHashMap是线程安全的,可以支持并发访问。 - 使用锁 (Lock): 可以使用
ReentrantLock或其他锁来保护对LruCache或自定义 LRU Cache 的访问。
例如,在自定义 LRU Cache 中,可以使用 synchronized 关键字来同步 get()、put() 和 remove() 方法:
public class CustomLruCache {
// ... (其他代码)
public synchronized Bitmap get(String key) {
return cache.get(key);
}
public synchronized void put(String key, Bitmap bitmap) {
// ...
}
public synchronized Bitmap remove(String key) {
// ...
}
}
8. 性能测试和监控
在实现 ImageCache 后,需要进行性能测试和监控,以确保 ImageCache 的性能满足要求。
可以使用以下工具进行性能测试和监控:
- Android Profiler: Android Studio 提供的性能分析工具,可以用来监控 CPU、内存、网络等资源的使用情况。
- LeakCanary: 一个内存泄漏检测工具,可以用来检测
ImageCache是否存在内存泄漏。 - 自定义日志: 可以在
ImageCache中添加自定义日志,记录缓存的命中率、加载时间等信息。
通过性能测试和监控,可以发现 ImageCache 的瓶颈,并进行优化。
9. 内存管理的总结
ImageCache的实现需要考虑多个方面,包括缓存结构的选择、Bitmap内存占用的精确计算、LRU策略的实现、线程安全问题以及性能测试和监控。理解并正确实现一个高效的ImageCache对于开发高性能的Android应用至关重要。需要根据应用的实际情况选择合适的缓存策略和优化方法。