美文网首页
Android—DiskLruCache基本用法与源码解析

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

作者: 东方未曦 | 来源:发表于2020-10-23 23:10 被阅读0次

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

    相关文章

      网友评论

          本文标题:Android—DiskLruCache基本用法与源码解析

          本文链接:https://www.haomeiwen.com/subject/laqepktx.html