Android—DiskLruCache基本用法与源码解析

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<Entry> 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;
  }