目錄
從零實現ImageLoader(一)—— 架構
從零實現ImageLoader(二)—— 基本實現
從零實現ImageLoader(三)—— 線程池詳解
從零實現ImageLoader(四)—— Handler的內心獨白
從零實現ImageLoader(五)—— 內存緩存LruCache
從零實現ImageLoader(六)—— 磁盤緩存DiskLruCache
前言
在上一篇文章里我們講解了內存緩存的原理,今天我們就來講講磁盤緩存的實現。這里我們選擇了Jake Wharton大神的開源項目DiskLruCache,這個項目現在已經成了所有需要磁盤緩存項目的第一選擇。
怎么用
在閱讀源碼之前我們首先要做的還是把它加入我們的項目,下面是將對DiskLruCache
進行封裝的DiskCache
類:
public class DiskCache {
private DiskLruCache mDiskLruCache;
public DiskCache(File directory,int appVersion, int maxSize) throws IOException {
mDiskLruCache = DiskLruCache.open(directory, appVersion, 1, maxSize);
}
public Bitmap get(String key) throws IOException {
try (DiskLruCache.Snapshot snapshot = mDiskLruCache.get(key)) {
if(snapshot != null) {
return BitmapFactory.decodeStream(snapshot.getInputStream(0));
} else {
return null;
}
}
}
public void put(String key, Bitmap bitmap) throws IOException {
DiskLruCache.Editor editor = mDiskLruCache.edit(key);
if(editor == null) return;
try (OutputStream out = editor.newOutputStream(0)) {
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, out);
editor.commit();
}
}
}
創建
-
directory
:緩存文件目錄。 -
appVersion
:緩存版本,應用版本變化后會重新創建緩存。 -
valueCount
:DiskLruCache的鍵值與緩存文件是一對多的關系。這里傳入1,即一個key對應一個緩存文件。 -
maxSize
:緩存文件可以使用的最大容量。
獲取
通過key
獲取到該鍵值所對應緩存文件的Snapshot
對象,再通過Snapshot
獲取到對應緩存的InputStream
,由于我們采用的是鍵值緩存一對一的關系,所以這里只需要取第0
個輸入流就可以了。
存儲
和獲取過程很類似,不過這里變成了用edit()
方法得到Editor
對象,需要注意的是,在寫入操作完成后必須要調用Editor
對象的commit()
方法來結束對該緩存的訪問。
現在在Dispatcher.get()
方法中加入DiskCache
的邏輯就可以了:
public class Dispatcher {
public Bitmap get() throws IOException {
//從內存獲取
Bitmap image = mMemoryCache.get(mKey);
if(image == null) {
//從磁盤獲取
image = mDiskCache.get(mKey);
if(image == null) {
//從網絡獲取
image = NetworkUtil.getBitmap(mUrl);
if(image == null) return null;
mDiskCache.put(mKey, image);
}
mMemoryCache.put(mKey, image);
}
return image;
}
}
雙管齊下
在閱讀DiskLruCache的源碼之前,我們不妨先思考一下,如果讓我們來實現一個磁盤緩存工具,我們會怎么做?
因為這里涉及到了大量緩存數據的記錄,恐怕大多數人首先想到的就是數據庫,但是數據庫的效率其實是比較低的,所以Jake Wharton大神選擇了使用一個獨立的文件來進行緩存信息的記錄,這也是我們后面將要提到的journal
文件,至于journal
文件是如何記錄緩存信息的,咱們暫且按下不表。
實現了緩存信息的記錄,接下來又要考慮另一個問題,怎么實現緩存的淘汰機制?有人就要說了,直接實現一個LRU算法不就行了?這也是DiskLruCache
第二個高明的地方,還記得咱們上一篇講的LruCache
的實現嗎?LruCache
基本上將所有的實現LinkedHashMap
類去完成,這里DiskLruCache
也借助了它。DiskLruCache
在初始化的時候會將journal
文件里的數據通通讀入LinkedHashMap
,而在進行緩存文件存取的時候,DiskLruCache
會同時更新LinkedHashMap
和journal
文件的信息。
就是利用journal
文件的信息記錄和LinkedHashMap
的淘汰機制雙管齊下,DiskLruCache
僅僅用了不到1000行的代碼就實現了如此強大又高效的磁盤緩存。
神奇的journal文件
在探究DiskLruCache
的源碼前,我們先來看一看journal
文件的真實面目,這也是DiskLruCache
的精髓所在:
這就是journal
文件的內部,我們先來看前5行:
- 第一行:
libcore.io.DiskLruCache
代表這是一個DiskLruCache
的journal
文件。 - 第二行:表示磁盤緩存的版本,恒為1。
- 第三行:表示軟件的版本,在版本變化后需要重建緩存。
- 第四行:
key
值與緩存文件是一對多的關系,這里的2
就表示一個key
值對應兩個緩存文件,我們一般使用1
。 - 第五行:空行,為了和下面的緩存信息分隔開。
可以有人可能不明白這個key值緩存一對多的關系究竟是什么樣的,這里我放一個截圖大家就明白了:
可以看到緩存文件是以key.index
的形式命名的,由于我們這里一個key值只對應了一個緩存文件,所以文件都是以.0
結尾的。
我們接著看journal
文件的構成,接下來的幾行每一行都代表了一個操作,我將它們分為了兩組,一組讀,對應之前的get()
方法,一組寫,對應edit()
方法。
讀
- READ:后跟緩存的key值。代表一次讀操作。
寫
DIRTY: 后跟緩存的key值。代表緩存正在被編輯,也就是調用了
edit()
方法,還沒有commit()
。它的下一條操作必定是CLEAN或REMOVE。CLEAN:后跟緩存的key值及對應文件的大?。ㄓ捎谶@里一個key對應兩個文件,所以會出現兩個數值)。該操作代表緩存已經成功寫入了,也就是已經調用了
commit()
方法了。REMOVE: 后跟緩存的key值。表示寫入失敗并調用了
commit()
方法,或者調用了remove()
方法。
The Get, the Edit and the Remove
知道了journal
文件的組成,接下來我們看一下DiskLruCache
到底在讀寫時干了什么。
讀
public synchronized Snapshot get(String key) throws IOException {
...
Entry entry = lruEntries.get(key);
if (entry == null) {
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) {
// A file must have been deleted manually!
...
return null;
}
journalWriter.append(READ + ' ' + key + '\n');
...
return new Snapshot(key, entry.sequenceNumber, ins, entry.lengths);
}
這里的邏輯主要分四步:
- 首先,通過
key
值獲取到lruEntries
也就是LinkedHashMap
中的數據(lruEntries.get()
操作也同時更新了數據在LinkedHashMap
中的位置,這在上一篇文章里有講過)。 - 接著,依次打開該
key
值所對應的幾個緩存文件的輸入流。 - 之后,在
journal
文件中加入一條READ操作。 - 最后,返回內部持有文件輸入流的
Snapshot
。
寫
寫操作比讀操作稍微復雜一點,因為涉及兩步,一步獲取Editor
,一步commit()
提交,但整體的思路是沒有變的。
private synchronized Editor edit(String key, long expectedSequenceNumber) throws IOException {
...
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;
}
與get()
方法相差無幾,不過變成了三步:
- 首先,從
LinkedHashMap
中獲取緩存數據。 - 接著,在
journal
文件中加入一條DIRTY記錄。 - 最后,返回
Editor
對象。
這里少的一步是打開文件流,DiskLruCache
將這一步放到了Editor
中去操作,也就是我們之前使用過的Editor.newOutputStream(0)
方法,這里就不去細看了。
在我們完成寫操作后需要調用commit()
方法,它最終調用了completeEdit()
方法:
private synchronized void completeEdit(Editor editor, boolean success) throws IOException {
Entry entry = editor.entry;
...
entry.currentEditor = null;
if (entry.readable | success) {
entry.readable = true;
journalWriter.write(CLEAN + ' ' + entry.key + entry.getLengths() + '\n');
...
} else {
lruEntries.remove(entry.key);
journalWriter.write(REMOVE + ' ' + entry.key + '\n');
}
journalWriter.flush();
...
}
如果寫入成功,就在journal
文件中插入一條CLEAN記錄;如果失敗,就插入一條REMOVE記錄,同時移除LinkedHashMap
中的數據。
刪
public synchronized boolean remove(String key) throws IOException {
...
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);
}
...
}
...
journalWriter.append(REMOVE + ' ' + key + '\n');
lruEntries.remove(key);
...
return true;
}
- 首先,從
LinkedHashMap
中獲取該緩存數據。 - 接著,刪除該緩存所對應的文件。
- 之后,在
journal
文件中插入REMOVE記錄。 - 最后,從
LinkedHashMap
中移除緩存數據。
而淘汰機制只有短短四行代碼,不斷從LinkedHashMap
中取出最舊的數據,并調用remove()
方法,直到總體積小于指定的大?。?/p>
private void trimToSize() throws IOException {
while (size > maxSize) {
Map.Entry<String, Entry> toEvict = lruEntries.entrySet().iterator().next();
remove(toEvict.getKey());
}
}
看了這幾個操作之后,大家可能依然一頭霧水,一會是對LinkedHashMap
的操作,一會又是對journal
文件的操作。其實理解起來很簡單,DiskLruCache
在初始化之后就已經跟journal
文件一點關系都沒有了,所有的讀寫操作以及淘汰機制都是基于LinkedHashMap
的,可LinkedHashMap
有一點不好就是它只能停留在內存里,應用一關閉就什么都沒了,所以每次對LinkedHashMap
進行操作的時候,同時將這一次的操作記錄在journal
文件里,這樣,應用在下次啟動的時候只需要把LinkedHashMap
再從journal
文件里恢復出來就行了。我們看看DiskLruCache
是不是這樣做的:
journal文件的讀取
在DiskLruCache
初始化的時候,會先讀入前五行,大家可以理解為journal
文件的屬性,接下來DiskLruCache
會將讀取到的每一行都轉化為一個對LinkedHashMap
的操作:
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);
}
}
代碼有點長,不過大多數都是對字符串的處理,我們可以挑重點的看,在這里會根據不同的操作符分別進行處理:
- 如果是REMOVE,直接從
lruEntries
中移除該緩存。 - 如果是DIRTY,則新建一個
Editor
并設置為該緩存的currentEditor
,表示正在編輯。 - 如果是CLEAN,將該緩存的
currentEditor
設置為空表示編輯完成。 - 如果是READ,什么也不做,但其實前面調用的
lruEntries.get()
方法已經完成READ的功能了。
大家從DiskLruCache
讀取journal
文件的代碼里也能看出來,它其實是把每一行都轉換為一個對LinkedHashMap
的操作,相當于把我們之前執行過的所有操作再重新執行一遍,通過這種方式將LinkedHashMap
恢復到上次軟件關閉前的狀態。
寫在最后的話
到這里我們的DiskLruCache
就講解完了,同時,我們的從零實現ImageLoader系列也要告一段落了,大家肯定也發現,這個系列中關于如何實現ImageLoader所占的篇幅并不多,大多數時候還是在講一些底層的實現原理,所以也有點掛羊頭賣狗肉的嫌疑,不過我覺得用一個系列單單只講ImageLoader如何實現有點太可惜了,我們必須從中發現更深層的知識。
之前也一直沒有放項目的源碼,大家如果想看的話,可以在我的GavinLi369/Translator項目里找到。當然,如果喜歡的話也別忘了點個star。
之后我應該會寫幾篇分析目前幾大開源圖片加載項目的文章,不過相信大家在看完這個系列后,再去看這些項目的源碼已經不會有太大的壓力了,強烈建議大家自己先去看看,可以先從一些相對簡單的開始,比如Android-Universal-Image-Loader,picasso等,glide的實現有點過于復雜,如果一上來就看,很可能看不清楚,建議放在最后。