美文网首页
源码分析 DiskLruCache

源码分析 DiskLruCache

作者: Parallel_Lines | 来源:发表于2018-08-06 16:40 被阅读0次

    功能介绍

    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顺序。

    相关文章

      网友评论

          本文标题:源码分析 DiskLruCache

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