DiskLruCache与LruCache都实现了Lru缓存功能,两者都用于图片的三重缓存中。
LruCache将图片保存在内存,存取速度较快,退出APP后缓存会失效;而DiskLruCache将图片保存在磁盘中,下次进入应用后缓存依旧存在,它的存取速度相比LruCache会慢上一些。
DiskLruCache最大的特点就是持久化存储,所有的缓存以文件的形式存在。在用户进入APP时,它根据日志文件将DiskLruCache恢复到用户上次退出时的情况,日志文件journal保存每个文件的下载、访问和移除的信息,在恢复缓存时逐行读取日志并检查文件来恢复缓存。
一、基本介绍
1.1 官方介绍
DiskLruCache是一种存在于文件系统上的缓存,用户可以为它的存储空间设定一个最大值。每个缓存实体被称为Entry,它有一个String类型的Key,一个Key对应特定数量的Values,每个Key必须满足[a-z0-9_-]{1,64}
这个正则表达式。缓存的Value是字节流,可以通过Stream或者文件访问,每个文件的字节大小需要在(0, Integer.MAX_VALUE)之间。
缓存被保存在文件系统的一个文件夹内,该文件夹必须是当前DiskLruCache专用的,DiskLruCache可能会对该文件夹内的文件删除或重写。多进程同时使用相同的缓存文件夹会引发错误。
DiskLruCache对保存在文件系统中的总字节大小设定了最大值,当大小超过最大值时会在后台移除部分缓存实体直到缓存大小达标。该最大值不是严格的,当DiskLruCache在删除Entry时,缓存的整体大小可能会临时超过预设的最大值。该最大值不包括文件系统开销和journal日志文件,因此空间敏感型应用可以设置一个保守的最大值。
用户调用edit()
方法来创建或者更改一个Entry的Value,一个Entry在同一时刻只能拥有一个Editor,如果一个Value无法被修改,那么edit()
方法会返回null。当一个Entry被创建时,需要为该Entry提供完整的Value集合,如有必要,应该使用空的Value作为占位符。当一个Entry被修改的时候,没有必要对所有的Value都设置新的值,Value默认使用原先的值。
每一个edit()方法调用都与一个commit()
或者abort()
调用配对,commit操作是原子的,一个Read操作会观察commit前后的完整Value集合。用户通过get()
方法读取一个Entry的快照,此时读取到的是get()
方法被调用时的值,之后的更新操作并不会影响正在进行中的Read操作。
DiskLruCache可以容忍IO错误,如果某些缓存文件消失,对应的Entry会被删除。如果在写一个缓存Value时出现了一个错误,edit会静默失败。对于其他错误,调用方需要捕获IOException并处理。
1.2 关于日志文件journal
来看一个官方提供的日志文件示例。
libcore.io.DiskLruCache
1
100
2
CLEAN 3400330d1dfc7f3f7f4b8d4d803dfcf6 832 21054
DIRTY 335c4c6028171cfddfbaae1a9c313c52
CLEAN 335c4c6028171cfddfbaae1a9c313c52 3934 2342
REMOVE 335c4c6028171cfddfbaae1a9c313c52
DIRTY 1ab96a171faeeee38496d8b330771a7a
CLEAN 1ab96a171faeeee38496d8b330771a7a 1600 234
READ 335c4c6028171cfddfbaae1a9c313c52
READ 3400330d1dfc7f3f7f4b8d4d803dfcf6
日志文件第一行是固定字符串,表示使用的是DiskLruCache;第二行表示当前缓存的版本号,恒定为1;第三行表示应用的版本号;第四行为valueCount,这里为2,表示一个Entry中有两个文件。
接下来的每一行都代表一个Entry的记录,每一行包括:状态、Key以及缓存文件的大小。下面来看各个状态的含义:
① DIRTY: 该状态表示一个Entry正在被创建或正在被更新,任意一个成功的DIRTY操作后面都会有一个CLEAN或REMOVE操作。如果一个DIRTY操作后面没有CLEAN或者REMOVE操作,那就表示这是一个临时文件,应该将其删除。
② CLEAN: 该状态表示一个缓存Entry已经被成功发布了并且可以读取,该行后面会有每个Value的大小。
③ READ: 在LRU缓存中被读取了。
④ REMOVE: 表示被删除的缓存Entry。
在对缓存进行操作时,DiskLruCache会在journal日志后面追加记录,日志文件会偶尔删除多余的行数进行压缩,在压缩时会使用一个临时的日志文件"journal.tmp",如果打开缓存时该临时文件存在,那么应该将其删除。
二、基本用法
DiskLruCache通过静态方法open(File directory, int appVersion, int valueCount, long maxSize)
创建实例。directory表示文件的目录,开发人员在调用open(...)
方法前应该确保directory目录存在;appVersion表示应用的版本号,如果版本号更新,DiskLruCache会清除之前的缓存;valueCount表示一个key最多可以对应多少个文件;maxSize表示缓存的最大大小。
private void initDiskLruCache() {
try {
String dir = getExternalCacheDir() + File.separator + "disk_lru";
File file = new File(dir);
if (!file.exists()) {
file.mkdirs();
}
mDiskLruCache = DiskLruCache.open(file, 1, 1, 10 * 1024 * 1024);
} catch (IOException e) {
e.printStackTrace();
}
}
DiskLruCache的读取都是通过流进行操作的,保存数据时通过Editor得到Entry对应index文件的输出流,通过该输出流写入文件。downloadFile(...)
方法就是读取url的InputStream输出到OutputStream中,该方法不再赘述。
private void saveFile(String key, String url) {
try {
DiskLruCache.Editor editor = mDiskLruCache.edit(key);
if (editor != null) {
OutputStream outputStream = editor.newOutputStream(0);
if (downloadFile(url, outputStream)) {
editor.commit();
} else {
editor.abort();
}
}
mDiskLruCache.flush();
} catch (IOException e) {
e.printStackTrace();
}
}
读取文件时得到该Key对应的缓存快照,然后得到输入流即可。
private void getInputStream(String key) {
InputStream in = null;
try {
DiskLruCache.Snapshot snapShot = mDiskLruCache.get(key);
if (snapShot != null) {
in= snapShot.getInputStream(0);
}
} catch (IOException e) {
e.printStackTrace();
}
return in;
}
三、源码解析
3.1 初始化
DiskLruCache通过open()
方法新建一个实例,大概流程为:首先处理日志文件,判断是否存在可用的日志文件,如果存在就读取日志到内存,如果不存在就新建一个日志文件。
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) {
cache.delete();
}
}
// 此时日志文件不存在或读取出错,新建一个DiskLruCache实例
directory.mkdirs();
cache = new DiskLruCache(directory, appVersion, valueCount, maxSize);
cache.rebuildJournal();
return cache;
}
DiskLruCache的关键就是日志文件,这里主要关注日志文件的读取过程,来看readJournal()
和processJournal()
这两个方法。
readJournal()
方法其实就是通过readJournalLine(reader.readLine())
方法读取日志文件中的每一行,最终会读取到lruEntries中,lruEntries是DiskLruCache在内存中的表现形式。
private void readJournal() throws IOException {
StrictLineReader reader = new StrictLineReader(new FileInputStream(journalFile), Util.US_ASCII);
try {
// ......
int lineCount = 0;
while (true) {
try {
readJournalLine(reader.readLine());
lineCount++;
} catch (EOFException endOfJournal) {
break;
}
}
redundantOpCount = lineCount - lruEntries.size();
} finally {
Util.closeQuietly(reader);
}
}
先来看readJournalLine(String line)
方法,该方法用于读取每一行日志。上面提到,日志文件的每一行都是DIRTY、CLEAN、READ或REMOVE四种行为之一,那么该方法就需要对这4中情况分别处理。
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);
// 如果是REMOVE,则将该key代表的缓存从lruEntries中移除
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);
}
// 如果是CLEAN、DIRTY或READ
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);
}
}
阅读代码发现,readJournalLine(String line)
首先取出该行记录的key,然后根据该记录是否为REMOVE进行不同的操作,如果是REMOVE,则将该key的缓存从lruEntries中移除。
如果不是REMOVE,说明该key存在一个对应的缓存实体Entry,则先新建一个Entry并添加到lruEntries中。之后再判断日志的类型,如果日志是CLEAN,代表该文件已经保存完毕了,将currentEditor设置为null;如果日志是DIRTY,代表文件没有保存完毕,为其currentEditor新建一个Editor。
为什么要这么做呢?之前提到,保存一个文件时会先写入DIRTY日志,保存成功后再写入CLEAN日志,一般来说这两条日志会成对出现。这里的currentEditor相当于一个标志位,如果为空,表示文件完整,如果不为空,表示该文件是临时文件。
再来看processJournal()
方法,该方法主要用于统计缓存文件的总体大小,并删除脏文件。
private void processJournal() throws IOException {
deleteIfExists(journalFileTmp);
for (Iterator i = lruEntries.values().iterator(); i.hasNext(); ) {
Entry entry = i.next();
if (entry.currentEditor == null) {
for (int t = 0; t < valueCount; t++) {
size += entry.lengths[t];
}
} else {·
entry.currentEditor = null;
for (int t = 0; t < valueCount; t++) {
deleteIfExists(entry.getCleanFile(t));
deleteIfExists(entry.getDirtyFile(t));
}
i.remove();
}
}
}
private static void deleteIfExists(File file) throws IOException {
if (file.exists() && !file.delete()) {
throw new IOException();
}
}
3.2 写缓存
写缓存的时候需要先通过edit(String key)
方法新建一个Editor,然后将数据写入Editor的输出流中,最后成功则调用Editor.commit()
,失败则调用Editor.abort()
。
先从edit(String key)
方法开始,方法取出当前key对应的缓存Entry,如果Entry不存在则新建并添加到lruEntries中,如果存在且entry.currentEditor不为空,表示Entry正在进行缓存编辑。随后新建一个Editor,并在日志文件中输出一行DIRTY日志表示开始编辑缓存文件。
public Editor edit(String key) throws IOException {
return edit(key, ANY_SEQUENCE_NUMBER);
}
private synchronized Editor edit(String key, long expectedSequenceNumber) throws IOException {
checkNotClosed();
validateKey(key);
Entry entry = lruEntries.get(key);
if (expectedSequenceNumber != ANY_SEQUENCE_NUMBER && (entry == null
|| entry.sequenceNumber != expectedSequenceNumber)) {
return null; // Snapshot已经过期
}
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;
// 为了防止文件泄露,在创建文件前,将日志立即写入journal中
journalWriter.write(DIRTY + ' ' + key + '\n');
journalWriter.flush();
return editor;
}
随后通过Editor新建一个输出流,该方法返回一个没有buffer的输出流,参数的index表示该key的第几个缓存文件。如果该输出流在写入时发生错误,这次编辑会在commit()方法被调用的时候终止。该方法返回的输出流是FaultHidingOutputStream,该输出流不抛出IO异常,但是通过标志位标记本次IO操作出错。
public OutputStream newOutputStream(int index) throws IOException {
synchronized (DiskLruCache.this) {
if (entry.currentEditor != this) {
throw new IllegalStateException();
}
if (!entry.readable) {
written[index] = true;
}
// dirtyFile是后缀名为.tmp的临时文件
File dirtyFile = entry.getDirtyFile(index);
FileOutputStream outputStream;
try {
outputStream = new FileOutputStream(dirtyFile);
} catch (FileNotFoundException e) {
// Attempt to recreate the cache directory.
directory.mkdirs();
try {
outputStream = new FileOutputStream(dirtyFile);
} catch (FileNotFoundException e2) {
// We are unable to recover. Silently eat the writes.
return NULL_OUTPUT_STREAM;
}
}
return new FaultHidingOutputStream(outputStream);
}
}
得到OutputStream后通过它来保存文件,成功后调用Editor.commit()
,失败后调用Editor.abort()
方法,代码如下。
之前提到newOutputStream(int index)
返回的输出流是FaultHidingOutputStream,它捕获所有的IO异常而不是抛出,如果它捕获到IO异常就会将hasErrors设置为true。不管保存文件成功或失败,最终调用的都是completeEdit(Editor editor, boolean success)
方法。
public void commit() throws IOException {
if (hasErrors) {
completeEdit(this, false);
remove(entry.key);
} else {
completeEdit(this, true);
}
committed = true;
}
public void abort() throws IOException {
completeEdit(this, false);
}
来看completeEdit(Editor editor, boolean success)
方法,该方法首先根据文件写入是否成功来重命名或者删除tmp文件,随后向journal写入日志,最后判断是否需要清理磁盘空间。
private synchronized void completeEdit(Editor editor, boolean success) throws IOException {
Entry entry = editor.entry;
if (entry.currentEditor != editor) {
throw new IllegalStateException();
}
// 如果当前编辑是第一次创建Entry,那么每个索引上都应该有值
// valueCount表示一个Entry中的value数量
if (success && !entry.readable) {
for (int i = 0; i < valueCount; i++) {
if (!editor.written[i]) {
editor.abort();
throw new IllegalStateException("Newly created entry didn't create value for index " + i);
}
if (!entry.getDirtyFile(i).exists()) {
editor.abort();
return;
}
}
}
// 遍历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赋值, 用于标记snapshot是否过期
// 如果Entry和snapshot的sequenceNumber不同, 则表示数据已经过期了
entry.sequenceNumber = nextSequenceNumber++;
}
} else {
lruEntries.remove(entry.key);
journalWriter.write(REMOVE + ' ' + entry.key + '\n');
}
journalWriter.flush();
// 判断是否需要清理磁盘空间
if (size > maxSize || journalRebuildRequired()) {
executorService.submit(cleanupCallable);
}
}
3.3 读缓存
读缓存时返回的是一个Snapshot快照,该方法会一次性地打开所有的输入流,即使之后文件被删除,该输入流依旧可用。
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;
}
// Open all streams eagerly to guarantee that we see a single published
// snapshot. If we opened streams lazily then the streams could come
// from different edits.
InputStream[] ins = new InputStream[valueCount];
try {
for (int i = 0; i < valueCount; i++) {
ins[i] = new FileInputStream(entry.getCleanFile(i));
}
} catch (FileNotFoundException e) {
// 除非用户手动删了文件, 否则不会执行到这里...
return null;
}
redundantOpCount++;
journalWriter.append(READ + ' ' + key + '\n');
if (journalRebuildRequired()) {
executorService.submit(cleanupCallable);
}
return new Snapshot(key, entry.sequenceNumber, ins, entry.lengths);
}
3.4 移除缓存
移除缓存的逻辑比较简单,删除文件并添加日志即可,如果当前Entry正在被编辑就直接返回。
public synchronized boolean remove(String key) throws IOException {
checkNotClosed();
validateKey(key);
Entry entry = lruEntries.get(key);
if (entry == null || entry.currentEditor != null) {
return false;
}
for (int i = 0; i < valueCount; i++) {
File file = entry.getCleanFile(i);
if (file.exists() && !file.delete()) {
throw new IOException("failed to delete " + file);
}
size -= entry.lengths[i];
entry.lengths[i] = 0;
}
redundantOpCount++;
journalWriter.append(REMOVE + ' ' + key + '\n');
lruEntries.remove(key);
if (journalRebuildRequired()) {
executorService.submit(cleanupCallable);
}
return true;
}