源码分析 DiskLruCache

2018-08-06  本文已影响0人  Parallel_Lines

功能介绍

DiskLruCache是一个硬盘缓存工具类,它可以将数据持久化到硬盘上,且可以根据Lru算法,超限后删除长久不用的数据。

误区

先确定这些误区,对源码理解很有帮助。

1.DiskLruCache只有存储和获取缓存的功能,当无缓存时返回null,不对任何网络情景进行判断。即即使对于某个key本地有缓存,调用DiskLruCache的存储方法时,会不加判断的进行覆盖。

2.LinkHashMap只有put、get时排序的功能,长久不用的排在队首,最近使用的排在队尾。LinkHashMap不会自动删除长久不用的数据,这个功能是DiskLruCache实现的。

Volley 缓存使用机制(未看此节可忽略)中给出了下面的图。
这里更准确的说法应该是:是否走缓存?返回缓存:网络获取。即这里的'内存、硬盘缓存'模块仅是返回已经获取到的缓存,但是缓存的获取时机并不在这里。
源码中是否走缓存的执行顺序是:是否有缓存?->是否max-age?->是否304?从这里可以看到,是否有缓存这一步,就从硬盘中获取了缓存,只不过是在满足条件时,才返回它。

概览1.jpg

使用

初始化

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.png

DiskLruCache维护了如上图所示的一份文件,文件名为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;
  }

这个方法做了三件事:

  1. 判断合法性。DiskLruCache是否已经关闭、key字符是否合法。
  2. 创建editor用于写入数据,并返回。
  3. 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的工作如下:

  1. 将FileOutputStream写入的临时文件转成正式缓存文件,并重新计算size。
  2. 写入Journal日志文件。CLEAN。
  3. 判断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相对简单:

  1. 判断合法性。
  2. 读取key对应的缓存文件。
  3. 写入日志文件。READ。
  4. 将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的顺序会在如下时机变化:

  1. DiskLruCache初始化时。(即读Journal时)
  2. get缓存时。
  3. 进入编辑态时。(注意是进入编辑态,而不是commit。由于在edit时就会将key添加到LinkHashMap,故commit无须再次添加)

Journal的作用:持久化有序的key值队列。
Journal文档的正文会在如下时机修改:

  1. edit进入编辑态时。新增DITRY。
  2. commit提交缓存时。新增CLEAN。
  3. get获取缓存时。新增READ。
  4. 超限或其它原因删除缓存时。新增REMOVE。

可见,DiskLruCache和LruCache区别不大,都是利用LinkHashMap实现缓存算法。只不过DiskLruCache是硬盘缓存,故需要持久化LinkHashMap中维持的Lru顺序关系

Journal详细的记录了缓存的操作记录,以便于app启动时,可以根据之前的操作记录,恢复LinkHashMap的数据。这份数据包括:有哪些缓存,以及这些缓存的Lru顺序。

上一篇下一篇

猜你喜欢

热点阅读