源码分析 DiskLruCache
功能介绍
DiskLruCache
是一个硬盘缓存工具类,它可以将数据持久化到硬盘上,且可以根据Lru算法,超限后删除长久不用的数据。
误区
先确定这些误区,对源码理解很有帮助。
1.DiskLruCache
只有存储和获取缓存的功能,当无缓存时返回null,不对任何网络情景进行判断。即即使对于某个key本地有缓存,调用DiskLruCache
的存储方法时,会不加判断的进行覆盖。
2.LinkHashMap
只有put、get时排序的功能,长久不用的排在队首,最近使用的排在队尾。LinkHashMap
不会自动删除长久不用的数据,这个功能是DiskLruCache
实现的。
Volley 缓存使用机制(未看此节可忽略)中给出了下面的图。
概览1.jpg
这里更准确的说法应该是:是否走缓存?返回缓存:网络获取。即这里的'内存、硬盘缓存'模块仅是返回已经获取到的缓存,但是缓存的获取时机并不在这里。
源码中是否走缓存的执行顺序是:是否有缓存?->是否max-age?->是否304?从这里可以看到,是否有缓存这一步,就从硬盘中获取了缓存,只不过是在满足条件时,才返回它。
使用
初始化
diskLruCache = DiskLruCache.open(getCacheFile(), getAppVersion(this), 1, 10 * 1024 * 1024);
存储数据
//DiskLruCache只允许Key值为[a-z0-9_-],故这里使用md5转换
String key = MD5Util.md5(img).toLowerCase();
//关键代码1
DiskLruCache.Editor edit = diskLruCache.edit(key);
if (edit != null) {
//关键代码2
OutputStream os = edit.newOutputStream(0);
if (downImg(img, os)) {
//关键代码3
edit.commit();
} else {
edit.abort();
}
}
diskLruCache.flush();
附非重要代码,以下代码仅为测试DiskLruCache
功能,不属于今天的讨论范畴,可忽略:
//md5加密 这里的作用是将key值转为只有数字和A-F的字符
public final static String md5(String pwd) {
//用于加密的字符
char md5String[] = {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
'A', 'B', 'C', 'D', 'E', 'F'};
try {
//使用平台的默认字符集将此 String 编码为 byte序列,并将结果存储到一个新的 byte数组中
byte[] btInput = pwd.getBytes();
// 获得指定摘要算法的 MessageDigest对象,此处为MD5
//MessageDigest类为应用程序提供信息摘要算法的功能,如 MD5 或 SHA 算法。
//信息摘要是安全的单向哈希函数,它接收任意大小的数据,并输出固定长度的哈希值。
MessageDigest mdInst = MessageDigest.getInstance("MD5");
//System.out.println(mdInst);
//MD5 Message Digest from SUN, <initialized>
//MessageDigest对象通过使用 update方法处理数据, 使用指定的byte数组更新摘要
mdInst.update(btInput);
//System.out.println(mdInst);
//MD5 Message Digest from SUN, <in progress>
// 摘要更新之后,通过调用digest()执行哈希计算,获得密文
byte[] md = mdInst.digest();
//System.out.println(md);
// 把密文转换成十六进制的字符串形式
int j = md.length;
//System.out.println(j);
char str[] = new char[j * 2];
int k = 0;
for (int i = 0; i < j; i++) { // i = 0
byte byte0 = md[i]; //95
str[k++] = md5String[byte0 >>> 4 & 0xf]; // 5
str[k++] = md5String[byte0 & 0xf]; // F
}
//返回经过加密后的字符串
return new String(str);
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
//download数据,并通过OutputStream存储
private boolean downImg(String urlString, OutputStream os) {
HttpURLConnection conn = null;
BufferedOutputStream out = null;
BufferedInputStream in = null;
try {
URL url = new URL(urlString);
conn = (HttpURLConnection) url.openConnection();
in = new BufferedInputStream(conn.getInputStream());
out = new BufferedOutputStream(os);
int len = 0;
while ((len = in.read()) != -1) {
out.write(len);
}
out.flush();
return true;
} catch (IOException e) {
e.printStackTrace();
} finally {
if (conn != null)
conn.disconnect();
try {
if (out != null)
out.close();
} catch (IOException e) {
e.printStackTrace();
}
try {
if (in != null)
in.close();
} catch (IOException e) {
e.printStackTrace();
}
}
return false;
}
获取数据
//关键代码1
DiskLruCache.Snapshot snapshot = diskLruCache.get(MD5Util.md5(img).toLowerCase());
if (snapshot != null) {
//关键代码2
InputStream is = snapshot.getInputStream(0);
Bitmap bitmap = BitmapFactory.decodeStream(is);
if (bitmap != null) {
iv.setImageBitmap(bitmap);
}
}
机制
缓存的实现机制
journal.pngDiskLruCache
维护了如上图所示的一份文件,文件名为journal。
前三个数字分别表示:
DiskLruCache
的版本。
app的版本,用于DiskLruCache
更新缓存文件。
valueCount,对DiskLruCache
而言,一个key并不一定只对应一份数据,据valueCount而定。
正文部分,分别是'标识位','key','size'。其中标识位的意义如下:
DIRTY表示进入编辑态,缓存待写入,会在diskLruCache.edit(key)
时创建,并没有真正的缓存。
CLEAN表示数据已经缓存,其后还跟了当前数据的大小。
READ表示当前数据被get一次,用于更新Lru顺序。
REMOVE表示当前数据已经被移除。
源码分析
初始化
使用DiskLruCache
,首先要初始化。
public static DiskLruCache open(File directory, int appVersion, int valueCount, long maxSize)
throws IOException {
...
File backupFile = new File(directory, JOURNAL_FILE_BACKUP);
if (backupFile.exists()) {
File journalFile = new File(directory, JOURNAL_FILE);
if (journalFile.exists()) {
backupFile.delete();
} else {
renameTo(backupFile, journalFile, false);
}
}
DiskLruCache cache = new DiskLruCache(directory, appVersion, valueCount, maxSize);
if (cache.journalFile.exists()) {
try {
cache.readJournal();
cache.processJournal();
cache.journalWriter = new BufferedWriter(
new OutputStreamWriter(new FileOutputStream(cache.journalFile, true), Util.US_ASCII));
return cache;
} catch (IOException journalIsCorrupt) {
System.out
.println("DiskLruCache "
+ directory
+ " is corrupt: "
+ journalIsCorrupt.getMessage()
+ ", removing");
cache.delete();
}
}
//没有缓存文件、或版本更新,则新建journal文件
directory.mkdirs();
cache = new DiskLruCache(directory, appVersion, valueCount, maxSize);
cache.rebuildJournal();
return cache;
}
代码这么多,核心代码只做了一件事:读Journal文件。
cache.readJournal();
cache.processJournal();
cache.journalWriter = new BufferedWriter(
new OutputStreamWriter(new FileOutputStream(cache.journalFile, true), Util.US_ASCII));
通过读Jouranl文件,主要完成了如下工作:
1.通过读Journal文件,把本地缓存的key有序的加载到了LinkHashMap里。
具体是如何做到的呢?
readJouranl调用了如下代码来读正文的每一行。
private void readJournalLine(String line) throws IOException {
int firstSpace = line.indexOf(' ');
if (firstSpace == -1) {
throw new IOException("unexpected journal line: " + line);
}
int keyBegin = firstSpace + 1;
int secondSpace = line.indexOf(' ', keyBegin);
final String key;
if (secondSpace == -1) {
key = line.substring(keyBegin);
if (firstSpace == REMOVE.length() && line.startsWith(REMOVE)) {
lruEntries.remove(key);
return;
}
} else {
key = line.substring(keyBegin, secondSpace);
}
Entry entry = lruEntries.get(key);
if (entry == null) {
entry = new Entry(key);
lruEntries.put(key, entry);
}
if (secondSpace != -1 && firstSpace == CLEAN.length() && line.startsWith(CLEAN)) {
String[] parts = line.substring(secondSpace + 1).split(" ");
entry.readable = true;
entry.currentEditor = null;
entry.setLengths(parts);
} else if (secondSpace == -1 && firstSpace == DIRTY.length() && line.startsWith(DIRTY)) {
entry.currentEditor = new Editor(entry);
} else if (secondSpace == -1 && firstSpace == READ.length() && line.startsWith(READ)) {
// This work was already done by calling lruEntries.get().
} else {
throw new IOException("unexpected journal line: " + line);
}
}
LinkHashMap,也就是lruEntries
,这样存储数据:lruEntries.put(key, entry);
其中,entry是key的包装类,包括了以下几个参数:
private final String key; //缓存的key值
private final long[] lengths; //缓存的大小
private boolean readable; //缓存是否已可读
private Editor currentEditor; //缓存当前的editor,editor用于存储数据
private long sequenceNumber; //缓存提交的次数
如下往lruEntries
添加数据:
状态 | 意图 |
---|---|
ROMOVE | 移除key; |
CLEAN | 添加该key,同时设置readable为true。只有当readable为true,调用diskLruCache.get 才会有返回值。 |
DIRTY | 添加该key,但readable为false,并给entry设置一个editor。前文说过,DIRTY是调用edit()时写入的,它代表的意义就是进入编辑态,可用于写入缓存,但写入提交前不可读。 |
READ | 由于调用了一次get来判断key是否存在,故会导致该数据移动至队尾。 |
初始化时,通过读日志一样的记录文件Journal,DiskLruCache重新进入了之前的工作状态:不仅将key加入到了内存里,便于快速判断缓存文件是否存在;同时通过READ保证了Lru顺序的正确性。
2.计算当前size。
存储数据
初始化完毕后,就可以开始缓存数据了。
缓存数据,首先调用diskLruCache.edit(key)
,edit这个方法的源码如下:
private synchronized Editor edit(String key, long expectedSequenceNumber) throws IOException {
checkNotClosed();
validateKey(key);
Entry entry = lruEntries.get(key);
...
if (entry == null) {
entry = new Entry(key);
lruEntries.put(key, entry);
} else if (entry.currentEditor != null) {
return null; // Another edit is in progress.
}
Editor editor = new Editor(entry);
entry.currentEditor = editor;
// Flush the journal before creating files to prevent file leaks.
journalWriter.write(DIRTY + ' ' + key + '\n');
journalWriter.flush();
return editor;
}
这个方法做了三件事:
- 判断合法性。DiskLruCache是否已经关闭、key字符是否合法。
- 创建editor用于写入数据,并返回。
- Journal日志文件写入DIRTY日志。
接下来,调用OutputStream os = edit.newOutputStream(0);
,通过editor获取写入数据的OutputStream。
public OutputStream newOutputStream(int index) throws IOException {
synchronized (DiskLruCache.this) {
...
File dirtyFile = entry.getDirtyFile(index);
FileOutputStream outputStream;
try {
outputStream = new FileOutputStream(dirtyFile);
} catch (FileNotFoundException e) {
directory.mkdirs();
try {
outputStream = new FileOutputStream(dirtyFile);
} catch (FileNotFoundException e2) {
return NULL_OUTPUT_STREAM;
}
}
return new FaultHidingOutputStream(outputStream);
}
}
public File getDirtyFile(int i) {
return new File(directory, key + "." + i + ".tmp");
}
可见,以上俩步,核心就是返回了一个key值对应的FileOutputStream,用于写入缓存。
写入完毕,通过edit.commit()
提交。
edit.commit()
最终调用了completeEdit
,它的核心代码如下:
private synchronized void completeEdit(Editor editor, boolean success) throws IOException {
Entry entry = editor.entry;
...
for (int i = 0; i < valueCount; i++) {
File dirty = entry.getDirtyFile(i);
if (success) {
if (dirty.exists()) {
File clean = entry.getCleanFile(i);
dirty.renameTo(clean);
long oldLength = entry.lengths[i];
long newLength = clean.length();
entry.lengths[i] = newLength;
size = size - oldLength + newLength;
}
} else {
deleteIfExists(dirty);
}
}
redundantOpCount++;
entry.currentEditor = null;
if (entry.readable | success) {
entry.readable = true;
journalWriter.write(CLEAN + ' ' + entry.key + entry.getLengths() + '\n');
if (success) {
entry.sequenceNumber = nextSequenceNumber++;
}
} else {
lruEntries.remove(entry.key);
journalWriter.write(REMOVE + ' ' + entry.key + '\n');
}
journalWriter.flush();
if (size > maxSize || journalRebuildRequired()) {
executorService.submit(cleanupCallable);
}
}
completeEdit
的工作如下:
- 将FileOutputStream写入的临时文件转成正式缓存文件,并重新计算size。
- 写入Journal日志文件。CLEAN。
- 判断size是否超限,超限则移除队首元素,直到满足条件。
至此,缓存写入完毕、日志写入完毕、size限制完毕。
获取缓存
对于缓存的获取,首先会调用如下方法:
DiskLruCache.Snapshot snapshot = diskLruCache.get(MD5Util.md5(img).toLowerCase());
它的源码如下:
public synchronized Snapshot get(String key) throws IOException {
checkNotClosed();
validateKey(key);
Entry entry = lruEntries.get(key);
if (entry == null) {
return null;
}
if (!entry.readable) {
return null;
}
InputStream[] ins = new InputStream[valueCount];
try {
for (int i = 0; i < valueCount; i++) {
ins[i] = new FileInputStream(entry.getCleanFile(i));
}
} catch (FileNotFoundException e) {
for (int i = 0; i < valueCount; i++) {
if (ins[i] != null) {
Util.closeQuietly(ins[i]);
} else {
break;
}
}
return null;
}
redundantOpCount++;
journalWriter.append(READ + ' ' + key + '\n');
if (journalRebuildRequired()) {
executorService.submit(cleanupCallable);
}
return new Snapshot(key, entry.sequenceNumber, ins, entry.lengths);
}
get相对简单:
- 判断合法性。
- 读取key对应的缓存文件。
- 写入日志文件。READ。
- 将key、缓存文件等数据包装成
Snapshot
返回。
于是可知,Snapshot已经包含了缓存文件的InputStream,只需要直接调用即可。
snapshot.getInputStream(0);
这里的参数0,表示key值的第一个缓存数据。上面说过,valueCount表示一个key可以缓存多少个数据。valueCount为1,表示一个key对应一个缓存数据。
结论
通过源码分析,我们知道DiskLruCache分别在不同阶段做了不同的事情:
DiskLruCache.open 初始化
初始化,读Journal日志文件,获取缓存key的列表,并Lru排序。
edit.newOutputStream、edit.commit() 存储
返回FileOutputStream供写入缓存、写入日志以及删除超限缓存。
diskLruCache.get 缓存获取
根据key获取缓存文件inputStream、写入日志。
综上:
DiskLruCache利用Journal日志和LinkHashMap结合的方式实现了持久化数据的LruCache。其本质仍然只是File的写入和读取。故只要理解Journal与LinkHashMap的结合即可理解DiskLruCache原理。
Journal与LinkHashMap的结合使用
LinkHashMap的作用:维护了一个有序的key值队列,便于在超限时删除队首的元素。
LinkHashMap的顺序会在如下时机变化:
- DiskLruCache初始化时。(即读Journal时)
- get缓存时。
- 进入编辑态时。(注意是进入编辑态,而不是commit。由于在edit时就会将key添加到LinkHashMap,故commit无须再次添加)
Journal的作用:持久化有序的key值队列。
Journal文档的正文会在如下时机修改:
- edit进入编辑态时。新增DITRY。
- commit提交缓存时。新增CLEAN。
- get获取缓存时。新增READ。
- 超限或其它原因删除缓存时。新增REMOVE。
可见,DiskLruCache和LruCache区别不大,都是利用LinkHashMap实现缓存算法。只不过DiskLruCache是硬盘缓存,故需要持久化LinkHashMap中维持的Lru顺序关系。
Journal详细的记录了缓存的操作记录,以便于app启动时,可以根据之前的操作记录,恢复LinkHashMap的数据。这份数据包括:有哪些缓存,以及这些缓存的Lru顺序。