ImageCache 的 LRU 策略:图片内存占用的精确计算与清理机制

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 策略。
  • sizemaxSize: 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
  • 使用弱引用或软引用: 可以使用 WeakReferenceSoftReference 来存储 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 写入文件。

结合内存缓存和磁盘缓存:

可以将内存缓存和磁盘缓存结合起来使用,形成一个两级缓存:

  1. 首先从内存缓存中查找 Bitmap,如果找到,则直接返回。
  2. 如果内存缓存中没有找到,则从磁盘缓存中查找 Bitmap,如果找到,则将 Bitmap 添加到内存缓存,并返回。
  3. 如果磁盘缓存中也没有找到,则从网络加载 Bitmap,将 Bitmap 添加到内存缓存和磁盘缓存,并返回。

这种两级缓存策略可以提高 ImageCache 的命中率和性能。

7. ImageCache 的线程安全问题

在多线程环境下,ImageCache 可能会存在线程安全问题。例如,多个线程同时访问 LruCache 或者自定义 LRU Cache,可能会导致数据竞争和崩溃。

为了解决线程安全问题,可以使用以下方法:

  • 使用 synchronized 关键字: 可以使用 synchronized 关键字来同步对 LruCache 或自定义 LRU Cache 的访问。
  • 使用 ConcurrentHashMap: 可以使用 ConcurrentHashMap 代替 LinkedHashMap 来存储 BitmapConcurrentHashMap 是线程安全的,可以支持并发访问。
  • 使用锁 (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应用至关重要。需要根据应用的实际情况选择合适的缓存策略和优化方法。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注