【Android】LRU 与 Android 缓存策略

71 阅读5分钟

LRU 基本实现

LRU 缓存机制是一种常用的缓存策略,其选择最近最久未使用的条目予以淘汰。该算法赋予每个条目一个访问字段 key,用来记录该条目自上次被访问以来所经历的时间 t,当须淘汰某条目时,选择现有条目中其 t 值最大的,即最近最少使用的条目予以淘汰,不关心 t 具体值的情况可以通过链表的顺序插入来实现自然排序,访问字段和条目组成键值对。

LRU 的增改查操作往往要求在常数时间复杂度内进行,要实现顺序排列且在常数时间内删除过期条目,LRU 结构单元可以选择为双向链表,get 查找方法也要在常数时间内完成,所以 LRU 结构构成还要有哈希表的参与,基本的 LRU 结构是:

class Node<K, V>{
    K key;
    V value;
    Node<K, V> prev, next;
}

int capacity;
Node<K, V> dummy;
Map<K, Node<K, V>> hm = new HashMap<>();
public int get(int key) {
    Node result = hm.getOrDefault(key, dummy);
    if (result != dummy) {
        moveToHead(result);
    }
    return result.value;
}

public void put(int key, int value) {
    Node result = hm.get(key);
    if (result != null) {
        moveToHead(result);
        result.value = value;
    } else {
        result = new Node(key, value);
        result.prev = dummy;
        result.next = dummy.next;
        dummy.next.prev = result;
        dummy.next = result;
        hm.put(key, result);
    }
    if (hm.size() > capacity) {
        Node del = dummy.prev;
        del.prev.next = dummy;
        dummy.prev = del.prev;
        hm.remove(del.key);
    }
}

public void moveToHead(Node result) {
    result.prev.next = result.next;
    result.next.prev = result.prev;
    result.prev = dummy;
    result.next = dummy.next;
    dummy.next.prev = result;
    dummy.next = result;
}

LruCache

LruCache<K, V> 作为泛型类,内部采用 LinkedHashMap 存储外界缓存对象,通过 get 和 put 方法完成缓存获取和添加操作,初始化过程要提供缓存的总容量大小和重写 sizeOf 方法,sizeOf 方法用来计算缓存对象的大小。

int maxMemory = (int)(Runtime.getRuntime().maxMemory() / 1024);
int cacheSize = maxMemory / 8;
/* 缓存总容量大小为当前进程可以用内存的 1/8 */
LruCache<String, Bitmap> cache = new LruCache<>(cacheSize) {
    @Override
    protected int sizeOf(String key, Bitmap bitmap) {
        return bitmap.getRowBytes() * bitmap.getHeight() / 1024;
    }
}

cache.get(key);
cache.put(key, bitmap);

源码分析

首先来到 get 方法的执行过程,因为 LinkedHashMap 本身线程不安全,所以使用 synchronized 关键字做同步查询,如果有值则 hitCount 命中值自增且就地返回该值,若没有值则 missCount 自增。

if (key == null) {
    throw new NullPointerException("key == null");
}

V mapValue;
synchronized (this) {
    mapValue = map.get(key);
    if (mapValue != null) {
        hitCount++;
        return mapValue;
    }
    missCount++;
}

若没有值则尝试通过 create 方法获得值,该方法默认返回 null,可以重写 create 方法在查询不到值的时候做加载,是懒加载策略的体现,接下来同步尝试将加载的 value 写入哈希表,最后会判断是否有覆写行为,如果有则回调 entryRemoved 方法,该方法系回调监听器,供开发者对旧值做资源释放等操作;若无则调用 trimToSize 方法尝试裁剪超出容量的旧条目。

 /*
* Attempt to create a value. This may take a long time, and the map
* may be different when create() returns. If a conflicting value was
* added to the map while create() was working, we leave that value in
* the map and release the created value.
*/

V createdValue = create(key);
if (createdValue == null) {
    return null;
}

synchronized (this) {
    createCount++;
    mapValue = map.put(key, createdValue);

    if (mapValue != null) {
        // There was a conflict so undo that last put
map.put(key, mapValue);
    } else {
        size += safeSizeOf(key, createdValue);
    }
}

if (mapValue != null) {
    entryRemoved(false, key, createdValue, mapValue);
    return mapValue;
} else {
    trimToSize(maxSize);
    return createdValue;
}

我们来到 trimToSize 方法,其实现比较简单,首先会检查当前容量 size 是否大于 maxSize,是则调用 eldest 方法获得 LinkedHashMap 表尾的键值对条目,接下来从哈希表中删除该条目并更新缓存大小,最后统计被淘汰的条目数量,触发 entryRemoved 回调。

public void trimToSize(int maxSize) {
    while (true) {
        K key;
        V value;
        synchronized (this) {
            if (size < 0 || (map.isEmpty() && size != 0)) {
                throw new IllegalStateException(getClass().getName()
                        + ".sizeOf() is reporting inconsistent results!");
            }

            if (size <= maxSize) {
                break;
            }

            Map.Entry<K, V> toEvict = eldest();
            if (toEvict == null) {
                break;
            }

            key = toEvict.getKey();
            value = toEvict.getValue();
            map.remove(key);
            size -= safeSizeOf(key, value);
            evictionCount++;
        }

        entryRemoved(true, key, value, null);
    }
}

DiskLruCache

DiskLruCache 用于实现存储设备缓存,通过将缓存对象写入文件系统实现缓存效果,该类不属于 Android SDK 的内容,所以要引入依赖:implementation 'com.jakewharton:disklrucache:2.0.2',创建方式是通过 open 方法,其参数 directory 表示期望磁盘缓存在文件系统的缓存路径,appVersion 类似 DatabaseHelper 的 update 机制,appVersion 发生改变时会清空之前所有的缓存文件,valueCount 表示单个节点对应的数据个数,maxSize 表示缓存的总大小。

public static DiskLruCache open(File directory, int appVersion, int valueCount, long maxSize)

private static final long DISK_CACHE_SIZE = 1024 * 1024 * 50;
File diskCacheDir = getDiskCacheDir(mContext, "bitmap");
if (!diskCacheDir.exists()) {
    diskCacheDir.mkdirs();
}
mDiskLruCache = DiskLruCache.open(diskCacheDir, 1, 1, DISK_CACHE_SIZE);

缓存写入操作主要通过 Editor 对象完成。Editor 表示缓存条目的可编辑视图,可以通过 DiskLruCache 实例的 edit(key) 方法获取对应的 Editor 实例。通过该实例可以获取输出流,向缓存写入数据。写入操作产生的所有字节数据对应创建 Editor 时指定的 key 值。若要删除缓存条目,可以调用 remove(key) 方法将其清除。值得注意的是,DiskLruCache 不允许同时编辑相同的缓存对象,如果该缓存正在被编辑,则 edit 会返回 null。

String key = "bitmap_key";
DiskLruCache.Editor editor = diskLruCache.edit(key);
if (editor != null) {
    OutputStream os = editor.newOutputStream(0);
    try {
        os.write("Hello DiskLruCache".getBytes());
        editor.commit();
    } catch (IOException e) {
        editor.abort(); // 回退操作
        e.printStackTrace();
    } finally {
        try { os.close(); } catch (IOException ignored) {}
    }
}

缓存查找要借助表示缓存条目只读视图的 Snapshot 类,逻辑和 Editor 类似,可以通过 DiskLruCache 实例的 get(key) 方法获取对应的 Snapshot 实例。通过该实例可以获取输入流,读取缓存中的数据。读取操作不会修改缓存条目,获取的数据对应创建时指定的 key 值。使用完成后需要调用 snapshot.close() 关闭流,释放资源。

DiskLruCache.Snapshot snapshot = diskLruCache.get(key);
if (snapshot != null) {
    InputStream is = snapshot.getInputStream(0);
    BufferedReader reader = new BufferedReader(new InputStreamReader(is));
    StringBuilder sb = new StringBuilder();
    String line;
    while ((line = reader.readLine()) != null) {
        sb.append(line);
    }
    String cachedValue = sb.toString();
    snapshot.close();
    Log.d("DiskLruCache", "Cached value: " + cachedValue);
}

Bitmap

Bitmap 在 Android 指 1 幅图片资源,可以通过 BitmapFactory 的 decodeFile、decodeResource、decodeStream 和 decodeByteArray 方法分别从文件系统、资源、输入流和字节数组加载 Bitmap 对象,最终会调用 BitmapFactory 的 native 层方法。

大多数情况 ImageView 显示图片的内容区域要小于图片本身的大小,所以将图片完整的加载到设备再使用 scaleType 属性进行缩放或裁切往往是不高效的,我们可以采用 BitmapFactory.Options 来加载适应尺寸的图片,Options 有 inSampleSize 参数表示采样率,inSampleSize 会重复作用于 Bitmap 的宽高,所以缩放比例是 1/inSampleSize^2。

Options 的 inJustDecodeBounds 参数表示是否仅获取图片信息,所以我们可以通过获取图片宽高信息后结合 ImageView 显示区域的尺寸计算出采样率 inSampleSize,使用该采样率加载 Bitmap。

public static Bitmap suitableBitmap(Resources res, int resId, int weith, int height) {
    final BitmapFactory.Options options = new BitmapFactory.Options();
    options.inJustDecodeBounds = true;
    BitmapFactory.decodeResource(res, resId, options);
    
    options.inSampleSize = calculateInSampleSize(options, weith, height);
    /* calculateInSampleSize 获得采样率过程略 */
    
    options.inJustDecodeBounds = false;
    return BitmapFactory.decodeResource(res, resId, options);
}