Android DiskLruCache 源碼解析 硬盤緩存的絕佳方案
轉載請標明出處:
http://blog.csdn.net/lmj623565791/article/details/47251585;
本文出自:【張鴻洋的博客】
一、概述
依舊是整理東西,所以近期的博客涉及的東西可能會比較老一點,會分析一些經典的框架,我覺得可能也是每個優秀的開發者必須掌握的東西;那么對于Disk Cache,DiskLruCache可以算佼佼者了,所以我們就來分析下其源碼實現。
對于該庫的使用,推薦老郭的blog Android DiskLruCache完全解析,硬盤緩存的最佳方案
如果你不是很了解用法,那么注意下面的幾點描述,不然直接看源碼分析可能雨里霧里的。
- 首先,這個框架會涉及到一個文件,叫做journal,這個文件中會存儲每次讀取操作的記錄;
對于獲取一個DiskLruCache,是這樣的:
DiskLruCache.open(directory, appVersion, valueCount, maxSize) ;
關于存一般是這么使用的:
String key = generateKey(url); DiskLruCache.Editor editor = mDiskLruCache.edit(key); OuputStream os = editor.newOutputStream(0);
因為每個實體都是個文件,所以你可以認為這個os指向一個文件的FileOutputStream,然后把你想存的東西寫入就行了,寫完以后記得調用:
editor.commit()
。關于取一般是這樣的:
DiskLruCache.Snapshot snapShot = mDiskLruCache.get(key); if (snapShot != null) { InputStream is = snapShot.getInputStream(0); }
還是那句,因為每個實體都是文件,所以你返回的is是個FileInputStream,你可以利用is讀取出里面的內容,然后do what you want .
好了,關于Cache最主要就是存取了,了解這幾點,就可以往下去看源碼分析了。
還記得第一點說的journal文件么,首先就是它了。
二、journal文件
journal文件你打開以后呢,是這個格式;
libcore.io.DiskLruCache 1 1 1 DIRTY c3bac86f2e7a291a1a200b853835b664 CLEAN c3bac86f2e7a291a1a200b853835b664 4698 READ c3bac86f2e7a291a1a200b853835b664 DIRTY c59f9eec4b616dc6682c7fa8bd1e061f CLEAN c59f9eec4b616dc6682c7fa8bd1e061f 4698 READ c59f9eec4b616dc6682c7fa8bd1e061f DIRTY be8bdac81c12a08e15988555d85dfd2b CLEAN be8bdac81c12a08e15988555d85dfd2b 99 READ be8bdac81c12a08e15988555d85dfd2b DIRTY 536788f4dbdffeecfbb8f350a941eea3 REMOVE 536788f4dbdffeecfbb8f350a941eea3
首先看前五行:
- 第一行固定字符串
libcore.io.DiskLruCache
- 第二行DiskLruCache的版本號,源碼中為常量1
- 第三行為你的app的版本號,當然這個是你自己傳入指定的
- 第四行指每個key對應幾個文件,一般為1
- 第五行,空行
ok,以上5行可以稱為該文件的文件頭,DiskLruCache初始化的時候,如果該文件存在需要校驗該文件頭。
接下來的行,可以認為是操作記錄。
- DIRTY 表示一個entry正在被寫入(其實就是把文件的OutputStream交給你了)。那么寫入分兩種情況,如果成功會緊接著寫入一行CLEAN的記錄;如果失敗,會增加一行REMOVE記錄。
- REMOVE除了上述的情況呢,當你自己手動調用remove(key)方法的時候也會寫入一條REMOVE記錄。
- READ就是說明有一次讀取的記錄。
- 每個CLEAN的后面還記錄了文件的長度,注意可能會一個key對應多個文件,那么就會有多個數字(參照文件頭第四行)。
從這里看出,只有CLEAN且沒有REMOVE的記錄,才是真正可用的Cache Entry記錄。
分析完journal文件,首先看看DiskLruCache的創建的代碼。
三、DiskLruCache#open
public static DiskLruCache open(File directory, int appVersion, int valueCount, long maxSize) throws IOException { // If a bkp file exists, use it instead. File backupFile = new File(directory, JOURNAL_FILE_BACKUP); if (backupFile.exists()) { File journalFile = new File(directory, JOURNAL_FILE); // If journal file also exists just delete backup file. if (journalFile.exists()) { backupFile.delete(); } else { renameTo(backupFile, journalFile, false); } } // Prefer to pick up where we left off. DiskLruCache cache = new DiskLruCache(directory, appVersion, valueCount, maxSize); if (cache.journalFile.exists()) { try { cache.readJournal(); cache.processJournal(); return cache; } catch (IOException journalIsCorrupt) { System.out .println("DiskLruCache " + directory + " is corrupt: " + journalIsCorrupt.getMessage() + ", removing"); cache.delete(); } } // Create a new empty cache. directory.mkdirs(); cache = new DiskLruCache(directory, appVersion, valueCount, maxSize); cache.rebuildJournal(); return cache; }
首先檢查存不存在journal.bkp(journal的備份文件)
如果存在:然后檢查journal文件是否存在,如果正主在,bkp文件就可以刪除了。
如果不存在,將bkp文件重命名為journal文件。
接下里判斷journal文件是否存在:
如果不存在
創建directory;重新構造disklrucache;調用rebuildJournal建立journal文件
/** * Creates a new journal that omits redundant information. This replaces the * current journal if it exists. */ private synchronized void rebuildJournal() throws IOException { if (journalWriter != null) { journalWriter.close(); } Writer writer = new BufferedWriter( new OutputStreamWriter(new FileOutputStream(journalFileTmp), Util.US_ASCII)); try { writer.write(MAGIC); writer.write("\n"); writer.write(VERSION_1); writer.write("\n"); writer.write(Integer.toString(appVersion)); writer.write("\n"); writer.write(Integer.toString(valueCount)); writer.write("\n"); writer.write("\n"); for (Entry entry : lruEntries.values()) { if (entry.currentEditor != null) { writer.write(DIRTY + ' ' + entry.key + '\n'); } else { writer.write(CLEAN + ' ' + entry.key + entry.getLengths() + '\n'); } } } finally { writer.close(); } if (journalFile.exists()) { renameTo(journalFile, journalFileBackup, true); } renameTo(journalFileTmp, journalFile, false); journalFileBackup.delete(); journalWriter = new BufferedWriter( new OutputStreamWriter(new FileOutputStream(journalFile, true), Util.US_ASCII)); }
可以看到首先構建一個journal.tmp文件,然后寫入文件頭(5行),然后遍歷lruEntries(
lruEntries =
),當然我們這里沒有任何數據。接下來將tmp文件重命名為journal文件。
new LinkedHashMap<String, Entry>(0, 0.75f, true);如果存在
如果已經存在,那么調用
readJournal
。private void readJournal() throws IOException { StrictLineReader reader = new StrictLineReader(new FileInputStream(journalFile), Util.US_ASCII); try { String magic = reader.readLine(); String version = reader.readLine(); String appVersionString = reader.readLine(); String valueCountString = reader.readLine(); String blank = reader.readLine(); if (!MAGIC.equals(magic) || !VERSION_1.equals(version) || !Integer.toString(appVersion).equals(appVersionString) || !Integer.toString(valueCount).equals(valueCountString) || !"".equals(blank)) { throw new IOException("unexpected journal header: [" + magic + ", " + version + ", " + valueCountString + ", " + blank + "]"); } int lineCount = 0; while (true) { try { readJournalLine(reader.readLine()); lineCount++; } catch (EOFException endOfJournal) { break; } } redundantOpCount = lineCount - lruEntries.size(); // If we ended on a truncated line, rebuild the journal before appending to it. if (reader.hasUnterminatedLine()) { rebuildJournal(); } else { journalWriter = new BufferedWriter(new OutputStreamWriter( new FileOutputStream(journalFile, true), Util.US_ASCII)); } } finally { Util.closeQuietly(reader); } }
首先校驗文件頭,接下來調用
readJournalLine
按行讀取內容。我們來看看readJournalLine中的操作。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); } }
大家可以回憶下:每個記錄至少有一個空格,有的包含兩個空格。首先,拿到key,如果是REMOVE的記錄呢,會調用
lruEntries.remove(key)
;如果不是REMOVE記錄,繼續往下,如果該key沒有加入到lruEntries,則創建并且加入。
接下來,如果是CLEAN開頭的合法記錄,初始化entry,設置readable=true,currentEditor為null,初始化長度等。
如果是DIRTY,設置currentEditor對象。
如果是READ,那么直接不管。
ok,經過上面這個過程,大家回憶下我們的記錄格式,一般DIRTY不會單獨出現,會和REMOVE、CLEAN成對出現(正常操作);也就是說,經過上面這個流程,基本上加入到lruEntries里面的只有CLEAN且沒有被REMOVE的key。
好了,回到readJournal方法,在我們按行讀取的時候,會記錄一下lineCount,然后最后給redundantOpCount賦值,這個變量記錄的應該是沒用的記錄條數(文件的行數-真正可以的key的行數)。
最后,如果讀取過程中發現journal文件有問題,則重建journal文件。沒有問題的話,初始化下journalWriter,關閉reader。
readJournal
完成了,會繼續調用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(); } } }
統計所有可用的cache占據的容量,賦值給size;對于所有非法DIRTY狀態(就是DIRTY單獨出現的)的entry,如果存在文件則刪除,并且從lruEntries中移除。此時,剩的就真的只有CLEAN狀態的key記錄了。
ok,到此就初始化完畢了,太長了,根本記不住,我帶大家總結下上面代碼。
根據我們傳入的dir,去找journal文件,如果找不到,則創建個,只寫入文件頭(5行)。
如果找到,則遍歷該文件,將里面所有的CLEAN記錄的key,存到lruEntries中。
這么長的代碼,其實就兩句話的意思。經過open以后,journal文件肯定存在了;lruEntries里面肯定有值了;size存儲了當前所有的實體占據的容量;。
四、存入緩存
還記得,我們前面說過是怎么存的么?
String key = generateKey(url); DiskLruCache.Editor editor = mDiskLruCache.edit(key); OuputStream os = editor.newOutputStream(0); //...after op editor.commit();
那么首先就是editor方法;
/** * Returns an editor for the entry named {@code key}, or null if another * edit is in progress. */ 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 is stale. } 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; }
首先驗證key,可以必須是字母、數字、下劃線、橫線(-)組成,且長度在1-120之間。
然后通過key獲取實體,因為我們是存,只要不是正在編輯這個實體,理論上都能返回一個合法的editor對象。
所以接下來判斷,如果不存在,則創建一個Entry加入到lruEntries中(如果存在,直接使用),然后為entry.currentEditor
進行賦值為new Editor(entry);
,最后在journal文件中寫入一條DIRTY記錄,代表這個文件正在被操作。
注意,如果entry.currentEditor != null不為null的時候,意味著該實體正在被編輯,會retrun null ;
拿到editor對象以后,就是去調用newOutputStream去獲得一個文件輸入流了。
/** * Returns a new unbuffered output stream to write the value at * {@code index}. If the underlying output stream encounters errors * when writing to the filesystem, this edit will be aborted when * {@link #commit} is called. The returned output stream does not throw * IOExceptions. */ 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是否在valueCount范圍內,一般我們使用都是一個key對應一個文件所以傳入的基本都是0。接下來就是通過entry.getDirtyFile(index);
拿到一個dirty File對象,為什么叫dirty file呢,其實就是個中轉文件,文件格式為key.index.tmp。
將這個文件的FileOutputStream通過FaultHidingOutputStream封裝下傳給我們。
最后,別忘了我們通過os寫入數據以后,需要調用commit方法。
public void commit() throws IOException { if (hasErrors) { completeEdit(this, false); remove(entry.key); // The previous entry is stale. } else { completeEdit(this, true); } committed = true; }
首先通過hasErrors判斷,是否有錯誤發生,如果有調用completeEdit(this, false)
且調用remove(entry.key);
。如果沒有就調用completeEdit(this, true);
。
那么這里這個hasErrors哪來的呢?還記得上面newOutputStream的時候,返回了一個os,這個os是FileOutputStream,但是經過了FaultHidingOutputStream封裝么,這個類實際上就是重寫了FilterOutputStream的write相關方法,將所有的IOException給屏蔽了,如果發生IOException就將hasErrors賦值為true.
這樣的設計還是很nice的,否則直接將OutputStream返回給用戶,如果出錯沒法檢測,還需要用戶手動去調用一些操作。
接下來看completeEdit方法。
private synchronized void completeEdit(Editor editor, boolean success) throws IOException { Entry entry = editor.entry; if (entry.currentEditor != editor) { throw new IllegalStateException(); } // If this edit is creating the entry for the first time, every index must have a 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; } } } 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); } }
首先判斷if (success && !entry.readable)
是否成功,且是第一次寫入(如果以前這個記錄有值,則readable=true),內部的判斷,我們都不會走,因為written[i]在newOutputStream的時候被寫入true了。而且正常情況下,getDirtyFile是存在的。
接下來,如果成功,將dirtyFile 進行重命名為 cleanFile,文件名為:key.index。然后刷新size的長度。如果失敗,則刪除dirtyFile.
接下來,如果成功或者readable為true,將readable設置為true,寫入一條CLEAN記錄。如果第一次提交且失敗,那么就會從lruEntries.remove(key)
,寫入一條REMOVE記錄。
寫入緩存,肯定要控制下size。于是最后,判斷是否超過了最大size,或者需要重建journal文件,什么時候需要重建呢?
private boolean journalRebuildRequired() { final int redundantOpCompactThreshold = 2000; return redundantOpCount >= redundantOpCompactThreshold // && redundantOpCount >= lruEntries.size(); }
如果redundantOpCount達到2000,且超過了lruEntries.size()就重建,這里就可以看到redundantOpCount的作用了。防止journal文件過大。
ok,到此我們的存入緩存就分析完成了。再次總結下,首先調用editor,拿到指定的dirtyFile的OutputStream,你可以盡情的進行寫操作,寫完以后呢,記得調用commit.
commit中會檢測是你是否發生IOException,如果沒有發生,則將dirtyFile->cleanFile,將readable=true,寫入CLEAN記錄。如果發生錯誤,則刪除dirtyFile,從lruEntries中移除,然后寫入一條REMOVE記錄。
五、讀取緩存
DiskLruCache.Snapshot snapShot = mDiskLruCache.get(key); if (snapShot != null) { InputStream is = snapShot.getInputStream(0); }
那么首先看get方法:
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) { // A file must have been deleted manually! 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方法比較簡單,如果取到的為null,或者readable=false,則返回null.否則將cleanFile的FileInputStream進行封裝返回Snapshot,且寫入一條READ語句。
然后getInputStream就是返回該FileInputStream了。
好了,到此,我們就分析完成了創建DiskLruCache,存入緩存和取出緩存的源碼。
除此以外,還有一些別的方法我們需要了解的。
六、其他方法
remove()
/** * Drops the entry for {@code key} if it exists and can be removed. Entries * actively being edited cannot be removed. * * @return true if an entry was removed. */ 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; }
如果實體存在且不在被編輯,就可以直接進行刪除,然后寫入一條REMOVE記錄。
與open對應還有個remove方法,大家在使用完成cache后可以手動關閉。
close()
/** Closes this cache. Stored values will remain on the filesystem. */ public synchronized void close() throws IOException { if (journalWriter == null) { return; // Already closed. } for (Entry entry : new ArrayList<Entry>(lruEntries.values())) { if (entry.currentEditor != null) { entry.currentEditor.abort(); } } trimToSize(); journalWriter.close(); journalWriter = null; }
關閉前,會判斷所有正在編輯的實體,調用abort方法,最后關閉journalWriter。至于abort方法,其實我們分析過了,就是存儲失敗的時候的邏輯:
public void abort() throws IOException { completeEdit(this, false); }
到此,我們的整個源碼分析就結束了。可以看到DiskLruCache,利用一個journal文件,保證了保證了cache實體的可用性(只有CLEAN的可用),且獲取文件的長度的時候可以通過在該文件的記錄中讀取。利用FaultHidingOutputStream對FileOutPutStream很好的對寫入文件過程中是否發生錯誤進行捕獲,而不是讓用戶手動去調用出錯后的處理方法。其內部的很多細節都很值得推敲。
不過也可以看到,存取的操作不是特別的容易使用,需要大家自己去操作文件流,但在存儲比較小的數據的時候(不存在內存問題),很多時候還是希望有類似put(key,value),getAsT(key)等方法直接使用。我看了ASimpleCache 提供的API屬于比較好用的了。于是萌生想法,對DiskLruCache公開的API進行擴展,對外除了原有的存取方式以外,提供類似ASimpleCache那樣比較簡單的API用于存儲,而內部的核心實現,依然是DiskLruCache原本的。
github地址: base-diskcache,歡迎star,fork。
歡迎關注我的微博:
http://weibo.com/u/3165018720
群號:463081660,歡迎入群
微信公眾號:hongyangAndroid
(歡迎關注,第一時間推送博文信息)
來自: http://blog.csdn.net//lmj623565791/article/details/47251585