本文尝试去简单分析来自 Jake Wharton 大神的 DiskLruCache 库,尽管这个库已经有多年历史,但至今仍然被 OkHttpGlide 等主流基础库用以作为磁盘缓存的基础,仍有不错的学习价值。

在分析 DiskLruCache 之前,先对 Android SDK 里附带的 LruCache 进行简单介绍。(基于Android SDK 25)

LruCache

LRU (Least Recently Used),从名字就不难看出 LruCache 使用的缓存策略 — 缓存满了的时候,先抛弃近期最少使用的缓存对象。

This class appeared in Android 3.1; it’s available as part of Android’s Support Package for earlier releases.

LruCache 从 Android 3.1 开始提供,所以在2017年的今天可以直接用来实现内存缓存。

LruCache 原理

LruCache 的实现原理并不难理解,LruCache 用了 LinkedHashMap 来存缓存,巧妙地使用了 LinkedHashMap 在数据被访问时将被访问的数据放至链表最后(即头部前面)的特性,在需要删除缓存时将 LinkedHashMap 链表从前面(即头部后面)开始移出集合。

LruCache 需要注意的几点
  1. LruCache 默认的 sizeOf() 方法返回的是 1,如果要以缓存大小而不是缓存数目来控制需要重载 sizeOf()方法。
  2. 若要特定释放缓存的资源,需要重载 entryRemoved() 方法。
  3. 不允许空 key 或空 value。

DiskLruCache

回到 DiskLruCache ,因为 IO 操作的效率远不如内存操作,所以 DiskLruCache 一般是三级缓存策略里优先级最低的存在。虽然优先级低,但是重要性并不低。

DiskLruCache 原理

DiskLruCache 的核心原理跟 LruCache 基本一致,一样是利用了 LinkedHashMap 的特性来实现 LRU 策略。

DiskLruCache 的内部类

DiskLruCache 有三个内部类,分别为 EntrySnapshotEditor

  1. Entry

    Entry是 DiskLruCache 内 LinkedHashMap Value 的基本结构。

    1
    2
    3
    4
    5
    6
    7
    class Entry {
    String key;
    long[] lengths;
    boolean readable;
    Editor currentEditor;
    long sequenceNumber;
    }
    • keyEntry的唯一标识

    • lengths是与key匹配的缓存的大小,数组长度与创建 DiskLruCache时入参 valueCount的值一致

    • readable是当前缓存是否已经被创建至磁盘的标记

    • currentEditor是一个 Editor对象,在当前处于被编辑状态中的Entry里不为空

    • sequenceNumber记录了最近一次Entry的更新,每次更新Entry均会递增,用于判断 Snapshot是否过期

  2. Snapshot

    SnapshotEntry的快照(snapshot)。当调用 diskLruCache.get(key)时,便能获得一个Snapshot对象,该对象可用于获取或更新存于磁盘的缓存。

    1
    2
    3
    4
    5
    6
    class Snapshot {
    String key;
    long sequenceNumber;
    InputStream[] ins;
    long[] lengths;
    }
    • key即用于获取相应Entry快照的 key
    • sequenceNumber可以理解为当前Snapshot对象的版本号,当与目标EntrysequenceNumber不一致时,当前Snapshot失效,不能用于更新存于磁盘的缓存
    • ins是一个InputStream的数组,其大小与创建 DiskLruCache时入参 valueCount的值一致,是供获取磁盘缓存的输入流
    • lenghtsEntry里的lengths一样是与key匹配的缓存的大小
  3. Editor

    每个Entry都包裹有一个Editor对象,当Entry对象处于被编辑状态时不为空。所以Editor能有效避免多个编辑动作同时应用于Entry

    1
    2
    3
    4
    5
    6
    class Editor {
    Entry entry;
    boolean[] written;
    boolean hasErrors;
    boolean committed;
    }
    • entryEditor的编辑对象
    • written用于标记当前Editor对象里的entry是否已被写入至磁盘中
    • hasErrors用于标记执行输出流时是否有错误
    • committed标记Editor对象是否调用过editor.commit()方法
Journal 文件

DiskLruCache 通过在磁盘中创建并维护一个简单的Journal文件来记录各种缓存操作,供初始化时生成 LinkedHashMap 用。记录的类型有4种,分别为READREMOVECLEANDIRTY

  • READ key— 是key对应缓存的一次读取记录
  • REMOVE key — 是key对应缓存的一次移除记录
  • CLEAN key length[]CLEAN表明对应磁盘缓存已经成功被生成,可供读取,length[]是同一 key 对应的不同分块的大小,当CLEAN记录生成时也就意味着磁盘缓存文件去掉了带后缀 .tmpDIRTY状态
  • DIRTY keyDIRTY是相对于上面的CLEAN而言的,当磁盘缓存处于正在编辑状态时(即调用diskLruCache.edit()时)便会生成DIRTY记录,一般来说每条DIRTY记录后面都会接上一条CLEAN(即成功调用editor.commit())或REMOVE(即调用editor.commit()时出错)

在缓存记录之外,Journal 文件在初始化创建的时候还有一些固定的头部信息,包括了文件名、版本号和valueCount(决定每一个 key 能匹配的 Entry 数量)。

当缓存记录超过2000条的时候,Journal 文件会根据 LinkedHashMap 进行重建。

接下来结合正常使用时的 API 来分析

初始化缓存
1
DiskLruCache open(File directory, int appVersion, int valueCount, long maxSize)
  • directory缓存的路径
  • appVersion版本号,用于给 Journal 文件判断缓存是否有效
  • valueCount每个 key 对应 Entry的数量
  • maxSize缓存的最大值,当超过时依照 LRU 策略移除超出的部分

open()方法会检查是否已经存在 Journal 文件,若存在就根据 Journal 文件初始化 LinkedHashMap,若不存在则新建一个。

写入缓存

调用edit()方法

1
DiskLruCache.Editor editor = mDiskCache.edit(key);

正常情况会返回一个Editor对象给我们进行处理流,但当该 key 对应的 Entry正在被编辑也就是处于DIRTY状态时将返回一个空值,这时只有上一个Editor调用commit()abort()方法后才会解除DIRTY状态。

通过调用editor.newOutputStream(index)可以获取输出流来将我们的缓存输出至磁盘。值得注意的是,我们获得的输出流其实写到的是后缀为.tmp的 dirty 文件里,只有当我们调用editor.commit()的时候才会把.tmp后缀去掉成为正式有效的缓存,调用editor.abort()方法是将 dirty 文件从磁盘删除掉。

查询/读取缓存

我们可以通过Snapshot对象去查询/读取缓存,调用get()方法

1
DiskLruCache.Snapshot snapshot = mDiskCache.get(key);

查询的话,只需要判断返回的Snapshot对象是否为空,直接调用snapshot.close()方法离开即可,get(key)这个方法里就已经实现了 LRU 策略的缓存更新。

读取缓存的话,我们可以通过调用 snapshot.getInputStream(index)去获取缓存的输入流,操作输入流即可获得缓存。

关闭缓存

直接调用diskLruCache.close()即可,close()方法会把 Journal 的 writer 关掉,也会把所有 dirty 文件删除掉。

参考资料