Android 开源项目DiskLruCache 详解
有兴趣的同学可以读完这篇文章以后 可以看看这个硬盘缓存和volley 或者是其他 图片缓存框架中使用的硬盘缓存有什么异同点。
讲道理的话,其实硬盘缓存这个模块并不难写,难就难在 你要考虑到百分之0.1的那种情况,比如写文件的时候 手机突然没电了
之类的,你得保证文件正确性,唯一性等等。今天就来看看这个DiskLruCache是怎么实现这些内容的。
用法大家就自己去谷歌吧,在这里提一句,DiskLruCache 在4.0以上的源码中被编译到了platform 下面的libcore.io这个包路径下
所以你们看的那些博客如果告诉你 要把这个DiskLruCache 放在自己app下的libcore.io下 这是错的。因为你这么做,你自己app的类
和platform里面的类就重复了,你在运行以后,虽然不会报错,功能也正常,但实际上代码是不会走你app包路径下的DiskLruCache的。
他走的是platform 下面的,这一点一定要注意,不要被很多不负责任的博客坑了。。你就随便放在一个包路径下就可以了,只要不是
libcore.io这个路径下。另外自己可以先分析下这个DiskLruCache的日志 可以加深对这篇文章的理解,比如这种
libcore.io.DiskLruCache 1 1 1 DIRTY e37775b7868532e0d2986b1ff384c078 CLEAN e37775b7868532e0d2986b1ff384c078 152313
我们先来看看这个类的open函数,也是初始化的关键
1 public static DiskLruCache open(File directory, int appVersion, int valueCount, long maxSize) 2 throws IOException { 3 4 if (maxSize <= 0) { 5 throw new IllegalArgumentException("maxSize <= 0"); 6 } 7 if (valueCount <= 0) { 8 throw new IllegalArgumentException("valueCount <= 0"); 9 } 10 11 // 看备份文件是否存在 12 File backupFile = new File(directory, JOURNAL_FILE_BACKUP); 13 //如果备份文件存在,而正经的文件 不存在的话 就把备份文件 重命名为正经的journal文件 14 //如果正经的journal文件存在 那就把备份文件删除掉。 15 if (backupFile.exists()) { 16 File journalFile = new File(directory, JOURNAL_FILE); 17 if (journalFile.exists()) { 18 backupFile.delete(); 19 } else { 20 renameTo(backupFile, journalFile, false); 21 } 22 } 23 24 //这个构造函数 无非就是 把值赋给相应的对象罢了 25 DiskLruCache cache = new DiskLruCache(directory, appVersion, valueCount, maxSize); 27 //如果这个日志文件存在的话 就开始读里面的信息并返回 28 //主要就是构建entry列表 29 if (cache.journalFile.exists()) { 31 try { 32 cache.readJournal(); 33 cache.processJournal(); 34 return cache; 35 } catch (IOException journalIsCorrupt) { 36 System.out 37 .println("DiskLruCache " 38 + directory 39 + " is corrupt: " 40 + journalIsCorrupt.getMessage() 41 + ", removing"); 42 cache.delete(); 43 } 44 } 45 46 //如果日志文件不存在 就新建 47 directory.mkdirs(); 48 cache = new DiskLruCache(directory, appVersion, valueCount, maxSize); 49 cache.rebuildJournal(); 50 return cache; 51 }
这个open函数 其实还是挺好理解的。我们主要分两条线来看,一条线是 如果journal这个日志文件存在的话 就直接去构建entry列表。如果不存在 就去构建日志文件。
我们先来看 构建文件的这条线:
看49行 其实主要是调用了这个函数来完成构建。
1 //这个就是我们可以直接在disk里面看到的journal文件 主要就是对他的操作 2 private final File journalFile; 3 //journal文件的temp 缓存文件,一般都是先构建这个缓存文件,等待构建完成以后将这个缓存文件重新命名为journal 4 private final File journalFileTmp; 5 6 private synchronized void rebuildJournal() throws IOException { 7 if (journalWriter != null) { 8 journalWriter.close(); 9 } 10 11 //这个地方要注意了 writer 是指向的journalFileTmp 这个日志文件的缓存文件 12 Writer writer = new BufferedWriter( 13 new OutputStreamWriter(new FileOutputStream(journalFileTmp), Util.US_ASCII)); 14 //写入日志文件的文件头 15 try { 16 writer.write(MAGIC); 17 writer.write("\n"); 18 writer.write(VERSION_1); 19 writer.write("\n"); 20 writer.write(Integer.toString(appVersion)); 21 writer.write("\n"); 22 writer.write(Integer.toString(valueCount)); 23 writer.write("\n"); 24 writer.write("\n"); 25 26 for (Entry entry : lruEntries.values()) { 27 if (entry.currentEditor != null) { 28 writer.write(DIRTY + ' ' + entry.key + '\n'); 29 } else { 30 writer.write(CLEAN + ' ' + entry.key + entry.getLengths() + '\n'); 31 } 32 } 33 } finally { 34 writer.close(); 35 } 36 37 if (journalFile.exists()) { 38 renameTo(journalFile, journalFileBackup, true); 39 } 40 //所以这个地方 构建日志文件的流程主要就是先构建出日志文件的缓存文件,如果缓存构建成功 那就直接重命名这个缓存文件 41 //可以想想这么做有什么好处 42 renameTo(journalFileTmp, journalFile, false); 43 journalFileBackup.delete(); 44 45 //这里也是把写入日志文件的writer初始化 46 journalWriter = new BufferedWriter( 47 new OutputStreamWriter(new FileOutputStream(journalFile, true), Util.US_ASCII)); 48 }
这条线 我们分析完毕以后 再来看看如果open的时候 缓存文件存在的时候 做了哪些操作。
回到open函数,看25-35行 发现是先调用的readJournalLine函数,然后调用了processJournal函数。
1 private void readJournal() throws IOException { 2 //StrictLineReader 这个类挺好用的,大家可以拷出来,这个类的源码大家可以自己分析 不难 以后还可以自己用 3 StrictLineReader reader = new StrictLineReader(new FileInputStream(journalFile), Util.US_ASCII); 4 try { 5 //这一段就是读文件头的信息 6 String magic = reader.readLine(); 7 String version = reader.readLine(); 8 String appVersionString = reader.readLine(); 9 String valueCountString = reader.readLine(); 10 String blank = reader.readLine(); 11 if (!MAGIC.equals(magic) 12 || !VERSION_1.equals(version) 13 || !Integer.toString(appVersion).equals(appVersionString) 14 || !Integer.toString(valueCount).equals(valueCountString) 15 || !"".equals(blank)) { 16 throw new IOException("unexpected journal header: [" + magic + ", " + version + ", " 17 + valueCountString + ", " + blank + "]"); 18 } 19 20 //从这边开始 就要开始读下面的日志信息了 前面的都是日志头 21 int lineCount = 0; 22 //利用读到文件末尾的异常来跳出循环 23 while (true) { 24 try { 25 //就是在这里构建的lruEntries entry列表的 26 readJournalLine(reader.readLine()); 27 lineCount++; 28 } catch (EOFException endOfJournal) { 29 break; 30 } 31 } 32 redundantOpCount = lineCount - lruEntries.size(); 33 34 // If we ended on a truncated line, rebuild the journal before appending to it. 35 if (reader.hasUnterminatedLine()) { 36 rebuildJournal(); 37 } else { 38 //在这里把写入日志文件的Writer 初始化 39 journalWriter = new BufferedWriter(new OutputStreamWriter( 40 new FileOutputStream(journalFile, true), Util.US_ASCII)); 41 } 42 } finally { 43 Util.closeQuietly(reader); 44 } 45 }
然后给你们看下这个函数里 主要的几个变量:
1 //每个entry对应的缓存文件的格式 一般为1 2 private final int valueCount; 3 private long size = 0; 4 //这个是专门用于写入日志文件的writer 5 private Writer journalWriter; 6 private final LinkedHashMap<String, Entry> lruEntries = 7 new LinkedHashMap<String, Entry>(0, 0.75f, true); 8 //这个值大于一定数目时 就会触发对journal文件的清理了 9 private int redundantOpCount;
1 private final class Entry { 2 private final String key; 3 4 /** 5 * Lengths of this entry's files. 6 * 这个entry中 每个文件的长度,这个数组的长度为valueCount 一般都是1 7 */ 8 private final long[] lengths; 9 10 /** 11 * True if this entry has ever been published. 12 * 曾经被发布过 那他的值就是true 13 */ 14 private boolean readable; 15 16 /** 17 * The ongoing edit or null if this entry is not being edited. 18 * 这个entry对应的editor 19 */ 20 private Editor currentEditor; 21 22 @Override 23 public String toString() { 24 return "Entry{" + 25 "key='" + key + '\'' + 26 ", lengths=" + Arrays.toString(lengths) + 27 ", readable=" + readable + 28 ", currentEditor=" + currentEditor + 29 ", sequenceNumber=" + sequenceNumber + 30 '}'; 31 } 32 33 /** 34 * The sequence number of the most recently committed edit to this entry. 35 * 最近编辑他的序列号 36 */ 37 private long sequenceNumber; 38 39 private Entry(String key) { 40 this.key = key; 41 this.lengths = new long[valueCount]; 42 } 43 44 public String getLengths() throws IOException { 45 StringBuilder result = new StringBuilder(); 46 for (long size : lengths) { 47 result.append(' ').append(size); 48 } 49 return result.toString(); 50 } 51 52 /** 53 * Set lengths using decimal numbers like "10123". 54 */ 55 private void setLengths(String[] strings) throws IOException { 56 if (strings.length != valueCount) { 57 throw invalidLengths(strings); 58 } 59 60 try { 61 for (int i = 0; i < strings.length; i++) { 62 lengths[i] = Long.parseLong(strings[i]); 63 } 64 } catch (NumberFormatException e) { 65 throw invalidLengths(strings); 66 } 67 } 68 69 private IOException invalidLengths(String[] strings) throws IOException { 70 throw new IOException("unexpected journal line: " + java.util.Arrays.toString(strings)); 71 } 72 73 //臨時文件創建成功了以後 就會重命名為正式文件了 74 public File getCleanFile(int i) { 75 Log.v("getCleanFile","getCleanFile path=="+new File(directory, key + "." + i).getAbsolutePath()); 76 return new File(directory, key + "." + i); 77 } 78 79 //tmp开头的都是临时文件 80 public File getDirtyFile(int i) { 81 Log.v("getDirtyFile","getDirtyFile path=="+new File(directory, key + "." + i + ".tmp").getAbsolutePath()); 82 return new File(directory, key + "." + i + ".tmp"); 83 } 84 85 86 }
好,到了这里,我们DiskLruCache的open函数的主要流程就基本走完了,那么就再走2个流程结束本篇的源码分析,当然了,一个是GET操作,一个是SAVE操作了。
我们先看get操作
1 //通过key 来取 该key对应的snapshot 2 public synchronized Snapshot get(String key) throws IOException { 3 checkNotClosed(); 4 validateKey(key); 5 Entry entry = lruEntries.get(key); 6 if (entry == null) { 7 return null; 8 } 9 10 if (!entry.readable) { 11 return null; 12 } 13 14 // Open all streams eagerly to guarantee that we see a single published 15 // snapshot. If we opened streams lazily then the streams could come 16 // from different edits. 17 InputStream[] ins = new InputStream[valueCount]; 18 try { 19 for (int i = 0; i < valueCount; i++) { 20 ins[i] = new FileInputStream(entry.getCleanFile(i)); 21 } 22 } catch (FileNotFoundException e) { 23 // A file must have been deleted manually! 24 for (int i = 0; i < valueCount; i++) { 25 if (ins[i] != null) { 26 Util.closeQuietly(ins[i]); 27 } else { 28 break; 29 } 30 } 31 return null; 32 } 33 34 redundantOpCount++; 35 //在取得需要的文件以后 记得在日志文件里增加一条记录 并检查是否需要重新构建日志文件 36 journalWriter.append(READ + ' ' + key + '\n'); 37 if (journalRebuildRequired()) { 38 executorService.submit(cleanupCallable); 39 } 40 41 return new Snapshot(key, entry.sequenceNumber, ins, entry.lengths); 42 }
看第四行的那个函数:
1 private void validateKey(String key) { 2 Matcher matcher = LEGAL_KEY_PATTERN.matcher(key); 3 if (!matcher.matches()) { 4 throw new IllegalArgumentException("keys must match regex " 5 + STRING_KEY_PATTERN + ": \"" + key + "\""); 6 } 7 }
实际上我们在这里就能发现 存储entry的map的key 就是在这里被验证的,实际上就是正则表达式的验证,所以我们在使用这个cache的时候
key一定要用md5加密,因为图片的url一般都会有特殊字符,是不符合这里的验证的。
然后看37-39行:实际上就是走的这里:
1 final ThreadPoolExecutor executorService = 2 new ThreadPoolExecutor(0, 1, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue<Runnable>()); 3 private final Callable<Void> cleanupCallable = new Callable<Void>() { 4 public Void call() throws Exception { 5 synchronized (DiskLruCache.this) { 6 if (journalWriter == null) { 7 return null; // Closed. 8 } 9 trimToSize(); 10 if (journalRebuildRequired()) { 11 rebuildJournal(); 12 redundantOpCount = 0; 13 } 14 } 15 return null; 16 } 17 };
这边就是分两个部分,一个是校验 总缓存大小是否超出了限制的数量,另外一个10-13行 就是校验 我们的操作数redundantOpCount 是否超出了范围,否则就重构日志文件。
1 private boolean journalRebuildRequired() { 2 final int redundantOpCompactThreshold = 2000; 3 return redundantOpCount >= redundantOpCompactThreshold // 4 && redundantOpCount >= lruEntries.size(); 5 }
最后我们回到get函数看最后一行 发现返回的是一个SnapShot,快照对象
1 /** 2 * A snapshot of the values for an entry. 3 * 这个类持有该entry中每个文件的inputStream 通过这个inputStream 可以读取他的内容 4 */ 5 public final class Snapshot implements Closeable { 6 private final String key; 7 private final long sequenceNumber; 8 private final InputStream[] ins; 9 private final long[] lengths; 10 11 private Snapshot(String key, long sequenceNumber, InputStream[] ins, long[] lengths) { 12 this.key = key; 13 this.sequenceNumber = sequenceNumber; 14 this.ins = ins; 15 this.lengths = lengths; 16 } 17 18 /** 19 * Returns an editor for this snapshot's entry, or null if either the 20 * entry has changed since this snapshot was created or if another edit 21 * is in progress. 22 */ 23 public Editor edit() throws IOException { 24 return DiskLruCache.this.edit(key, sequenceNumber); 25 } 26 27 /** 28 * Returns the unbuffered stream with the value for {@code index}. 29 */ 30 public InputStream getInputStream(int index) { 31 return ins[index]; 32 } 33 34 /** 35 * Returns the string value for {@code index}. 36 */ 37 public String getString(int index) throws IOException { 38 return inputStreamToString(getInputStream(index)); 39 } 40 41 /** 42 * Returns the byte length of the value for {@code index}. 43 */ 44 public long getLength(int index) { 45 return lengths[index]; 46 } 47 48 public void close() { 49 for (InputStream in : ins) { 50 Util.closeQuietly(in); 51 } 52 } 53 }
到这里就明白了get最终返回的其实就是entry根据key 来取的snapshot对象,这个对象直接把inputStream暴露给外面。
最后我们再看看save的过程 先取得editor
1 public Editor edit(String key) throws IOException { 2 return edit(key, ANY_SEQUENCE_NUMBER); 3 } 4 5 //根据传进去的key 创建一个entry 并且将这个key加入到entry的那个map里 然后创建一个对应的editor 6 //同时在日志文件里加入一条对该key的dirty记录 7 private synchronized Editor edit(String key, long expectedSequenceNumber) throws IOException { 8 //因为这里涉及到写文件 所以要先校验一下写日志文件的writer 是否被正确的初始化 9 checkNotClosed(); 10 //这个地方是校验 我们的key的,通常来说 假设我们要用这个缓存来存一张图片的话,我们的key 通常是用这个图片的 11 //网络地址 进行md5加密,而对这个key的格式在这里是有要求的 所以这一步就是验证key是否符合规范 12 validateKey(key); 13 Entry entry = lruEntries.get(key); 14 if (expectedSequenceNumber != ANY_SEQUENCE_NUMBER && (entry == null 15 || entry.sequenceNumber != expectedSequenceNumber)) { 16 return null; // Snapshot is stale. 17 } 18 if (entry == null) { 19 entry = new Entry(key); 20 lruEntries.put(key, entry); 21 } else if (entry.currentEditor != null) { 22 return null; // Another edit is in progress. 23 } 24 25 Editor editor = new Editor(entry); 26 entry.currentEditor = editor; 27 28 // Flush the journal before creating files to prevent file leaks. 29 journalWriter.write(DIRTY + ' ' + key + '\n'); 30 journalWriter.flush(); 31 return editor; 32 }
然后取得输出流
public OutputStream newOutputStream(int index) throws IOException { if (index < 0 || index >= valueCount) { throw new IllegalArgumentException("Expected index " + index + " to " + "be greater than 0 and less than the maximum value count " + "of " + valueCount); } synchronized (DiskLruCache.this) { if (entry.currentEditor != this) { throw new IllegalStateException(); } if (!entry.readable) { written[index] = true; } 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); } }
注意这个index 其实一般传0 就可以了,DiskLruCache 认为 一个key 下面可以对应多个文件,这些文件 用一个数组来存储,所以正常情况下 我们都是
一个key 对应一个缓存文件 所以传0
1 //tmp开头的都是临时文件 2 public File getDirtyFile(int i) { 4 return new File(directory, key + "." + i + ".tmp"); 5 }
然后你这边就能看到,这个输出流,实际上是tmp 也就是缓存文件的 .tmp 也就是缓存文件的 缓存文件 输出流。
这个流 我们写完毕以后 就要commit
1 public void commit() throws IOException { 2 if (hasErrors) { 3 completeEdit(this, false); 4 remove(entry.key); // The previous entry is stale. 5 } else { 6 completeEdit(this, true); 7 } 8 committed = true; 9 } 10 /这个就是根据缓存文件的大小 更新disklrucache的总大小 然后再日志文件里对该key加入clean的log 11 //最后判断是否超过最大的maxSize 以便对缓存进行清理 12 private synchronized void completeEdit(Editor editor, boolean success) throws IOException { 13 Entry entry = editor.entry; 14 if (entry.currentEditor != editor) { 15 throw new IllegalStateException(); 16 } 17 18 // If this edit is creating the entry for the first time, every index must have a value. 19 if (success && !entry.readable) { 20 for (int i = 0; i < valueCount; i++) { 21 if (!editor.written[i]) { 22 editor.abort(); 23 throw new IllegalStateException("Newly created entry didn't create value for index " + i); 24 } 25 if (!entry.getDirtyFile(i).exists()) { 26 editor.abort(); 27 return; 28 } 29 } 30 } 31 32 for (int i = 0; i < valueCount; i++) { 33 File dirty = entry.getDirtyFile(i); 34 if (success) { 35 if (dirty.exists()) { 36 File clean = entry.getCleanFile(i); 37 dirty.renameTo(clean); 38 long oldLength = entry.lengths[i]; 39 long newLength = clean.length(); 40 entry.lengths[i] = newLength; 41 size = size - oldLength + newLength; 42 } 43 } else { 44 deleteIfExists(dirty); 45 } 46 } 47 48 redundantOpCount++; 49 entry.currentEditor = null; 50 if (entry.readable | success) { 51 entry.readable = true; 52 journalWriter.write(CLEAN + ' ' + entry.key + entry.getLengths() + '\n'); 53 if (success) { 54 entry.sequenceNumber = nextSequenceNumber++; 55 } 56 } else { 57 lruEntries.remove(entry.key); 58 journalWriter.write(REMOVE + ' ' + entry.key + '\n'); 59 } 60 journalWriter.flush(); 61 62 if (size > maxSize || journalRebuildRequired()) { 63 executorService.submit(cleanupCallable); 64 } 65 }
大家看那个32-40行,就是你commit以后 就会把tmp文件转正 ,重命名为 真正的缓存文件了。
这个里面的流程和日志文件的rebuild 是差不多的,都是为了防止写文件的出问题。所以做了这样的冗余处理。
基本上到这就结束了,大家主要通过这个框架可以学习到一些文件读写操作 的知识 ,另外可以看一下
硬盘缓存到底是怎么做的,大概需要一个什么样的流程,以后做到微博类的应用的时候 也可以快速写出一个硬盘缓存(非缓存图片的)