從零實現ImageLoader(六)—— 磁盤緩存DiskLruCache

目錄

從零實現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會同時更新LinkedHashMapjournal文件的信息。

就是利用journal文件的信息記錄和LinkedHashMap的淘汰機制雙管齊下,DiskLruCache僅僅用了不到1000行的代碼就實現了如此強大又高效的磁盤緩存。

神奇的journal文件

在探究DiskLruCache的源碼前,我們先來看一看journal文件的真實面目,這也是DiskLruCache的精髓所在:

這就是journal文件的內部,我們先來看前5行:

  • 第一行:libcore.io.DiskLruCache代表這是一個DiskLruCachejournal文件。
  • 第二行:表示磁盤緩存的版本,恒為1。
  • 第三行:表示軟件的版本,在版本變化后需要重建緩存。
  • 第四行:key值與緩存文件是一對多的關系,這里的2就表示一個key值對應兩個緩存文件,我們一般使用1。
  • 第五行:空行,為了和下面的緩存信息分隔開。

可以有人可能不明白這個key值緩存一對多的關系究竟是什么樣的,這里我放一個截圖大家就明白了:

可以看到緩存文件是以key.index的形式命名的,由于我們這里一個key值只對應了一個緩存文件,所以文件都是以.0結尾的。

我們接著看journal文件的構成,接下來的幾行每一行都代表了一個操作,我將它們分為了兩組,一組讀,對應之前的get()方法,一組寫,對應edit()方法。

  • READ:后跟緩存的key值。代表一次讀操作。

  • DIRTY: 后跟緩存的key值。代表緩存正在被編輯,也就是調用了edit()方法,還沒有commit()。它的下一條操作必定是CLEANREMOVE。

  • 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的實現有點過于復雜,如果一上來就看,很可能看不清楚,建議放在最后。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 229,963評論 6 542
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,348評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事?!?“怎么了?”我有些...
    開封第一講書人閱讀 178,083評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,706評論 1 317
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,442評論 6 412
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,802評論 1 328
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,795評論 3 446
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,983評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,542評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,287評論 3 358
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,486評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,030評論 5 363
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,710評論 3 348
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,116評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,412評論 1 294
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,224評論 3 398
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,462評論 2 378

推薦閱讀更多精彩內容