Android中的緩存策略--DiskLruCache

LruCache是一種內存緩存策略,但是當存在大量圖片的時候,我們指定的緩存內存空間可能很快就會用完,這個時候,LruCache就會頻繁的進行trimToSize()操作,不斷的將最近最少使用的數據移除,當再次需要該數據時,又得從網絡上重新加載。爲此,Google提供了一種磁盤緩存的解決方案——DiskLruCache

1 DiskLruCache實現原理

使用了DiskLruCache緩存策略的APP,緩存目錄如下圖:
這裏寫圖片描述

可以看到,緩存目錄中有一堆文件名很長的文件,這些文件就是我們緩存的一張張圖片數據,在最後有一個文件名journal的文件,這個journal文件是DiskLruCache的一個日誌文件,即保存着每張緩存圖片的操作記錄,journal文件正是實現DiskLruCache的核心。看到出現了journal文件,基本可以說明這個APP使用了DiskLruCache緩存策略。

根據對LruCache的分析,要實現LRU,最重要的是要有一種數據結構能夠基於訪問順序來保存緩存中的對象,LinkedHashMap是一種非常合適的數據結構,爲此,DiskLruCache也選擇了LinkedHashMap作爲維護訪問順序的數據結構,但是,對於DiskLruCache來說,單單LinkedHashMap是不夠的,因爲我們不能像LruCache一樣,直接將數據放置到LinkedHashMap的value中,也就是處於內存當中,在DiskLruCache中,數據是緩存到了本地文件,這裏的LinkedHashMap中的value只是保存的是value的一些簡要信息Entry,如唯一的文件名稱、大小、是否可讀等信息,
Entry .class

private final class Entry {
    private final String key;
    /** Lengths of this entry's files. */
    private final long[] lengths;
    /** True if this entry has ever been published */
    private boolean readable;
    /** The ongoing edit or null if this entry is not being edited. */
    private Editor currentEditor;
    /** The sequence number of the most recently committed edit to this entry. */
    private long sequenceNumber;
    private Entry(String key) {
        this.key = key;
        this.lengths = new long[valueCount];
    }
    public String getLengths() throws IOException {
        StringBuilder result = new StringBuilder();
        for (long size : lengths) {
            result.append(' ').append(size);
    }
    return result.toString();
}

    /**
     * Set lengths using decimal numbers like "10123".
     */
    private void setLengths(String[] strings) throws IOException {
        if (strings.length != valueCount) {
            throw invalidLengths(strings);
        }

        try {
            for (int i = 0; i < strings.length; i++) {
                lengths[i] = Long.parseLong(strings[i]);
            }
        } catch (NumberFormatException e) {
            throw invalidLengths(strings);
        }
    }

    private IOException invalidLengths(String[] strings) throws IOException {
        throw new IOException("unexpected journal line: " + Arrays.toString(strings));
    }

    public File getCleanFile(int i) {
        return new File(directory, key + "." + i);
    }

    public File getDirtyFile(int i) {
        return new File(directory, key + "." + i + ".tmp");
    }
}

DiskLruCache中對於LinkedHashMap定義如下:

private final LinkedHashMap<String, Entry> lruEntries
  = new LinkedHashMap<String, Entry>(0, 0.75f, true);

在LruCache中,由於數據是直接緩存中內存中,map中數據的建立是在使用LruCache緩存的過程中逐步建立的,而對於DiskLruCache,由於數據是緩存在本地文件,相當於是持久保存下來的一個文件,即使程序退出文件還在,因此,map中數據的建立,除了在使用DiskLruCache過程中建立外,map還應該包括之前已經存在的緩存文件,因此,在獲取DiskLruCache的實例時,DiskLruCache會去讀取journal這個日誌文件,根據這個日誌文件中的信息,建立map的初始數據,同時,會根據journal這個日誌文件,維護本地的緩存文件。構造DiskLruCache的方法如下:

public static DiskLruCache open(File directory, int appVersion, int valueCount, long maxSize)
        throws IOException {
    if (maxSize <= 0) {
        throw new IllegalArgumentException("maxSize <= 0");
    }
    if (valueCount <= 0) {
        throw new IllegalArgumentException("valueCount <= 0");
    }

    // 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();
            cache.journalWriter = new BufferedWriter(new FileWriter(cache.journalFile, true),
                    IO_BUFFER_SIZE);
            return cache;
        } catch (IOException journalIsCorrupt) {
            cache.delete();
        }
    }

    // create a new empty cache
    directory.mkdirs();
    cache = new DiskLruCache(directory, appVersion, valueCount, maxSize);
    cache.rebuildJournal();
    return cache;
}

其中,
cache.readJournal();
cache.processJournal();
正是去讀取journal日誌文件,建立起map中的初始數據,同時維護緩存文件。
那journal日誌文件到底保存了什麼信息呢,一個標準的journal日誌文件信息如下:

libcore.io.DiskLruCache  // MAGIC       固定內容,聲明
1                        // VERSION_1   cache的版本號,恆爲1
1                        // appVersion  APP的版本號
1                        // valueCount  一個key,可以存放多少條數據

DIRTY 335c4c6028171cfddfbaae1a9c313c52
CLEAN 335c4c6028171cfddfbaae1a9c313c52 3934
REMOVE 335c4c6028171cfddfbaae1a9c313c52
DIRTY 1ab96a171faeeee38496d8b330771a7a
CLEAN 1ab96a171faeeee38496d8b330771a7a 1600 234
READ 335c4c6028171cfddfbaae1a9c313c52
READ 3400330d1dfc7f3f7f4b8d4d803dfcf6

前五行稱爲journal日誌文件的頭,下面部分的每一行會以四種前綴之一開始:DIRTY、CLEAN、REMOVE、READ。
以一個DIRTY前綴開始的,後面緊跟着緩存圖片的key。以DIRTY這個這個前綴開頭,意味着這是一條髒數據。每當我們調用一次DiskLruCache的edit()方法時,都會向journal文件中寫入一條DIRTY記錄,表示我們正準備寫入一條緩存數據,但不知結果如何。然後調用commit()方法表示寫入緩存成功,這時會向journal中寫入一條CLEAN記錄,意味着這條“髒”數據被“洗乾淨了”,調用abort()方法表示寫入緩存失敗,這時會向journal中寫入一條REMOVE記錄。也就是說,每一行DIRTY的key,後面都應該有一行對應的CLEAN或者REMOVE的記錄,否則這條數據就是“髒”的,會被自動刪除掉。

在CLEAN前綴和key後面還有一個數值,代表的是該條緩存數據的大小。

因此,我們可以總結DiskLruCache中的工作流程:

1)初始化:通過open()方法,獲取DiskLruCache的實例,在open方法中通過readJournal(); 方法讀取journal日誌文件,根據journal日誌文件信息建立map中的初始數據;然後再調用processJournal();方法對剛剛建立起的map數據進行分析,分析的工作,一個是計算當前有效緩存文件(即被CLEAN的)的大小,一個是清理無用緩存文件;

2)數據緩存與獲取緩存:上面的初始化工作完成後,我們就可以在程序中進行數據的緩存功能和獲取緩存的功能了;

緩存數據的操作是藉助DiskLruCache.Editor這個類完成的,這個類也是不能new的,需要調用DiskLruCache的edit()方法來獲取實例,如下所示:

public Editor edit(String key) throws IOException

在寫入完成後,需要進行commit()。如下一個簡單示例:

new Thread(new Runnable() {  
    @Override  
    public void run() {  
        try {  
            String imageUrl = "https://img-my.csdn.net/uploads/201309/01/1378037235_7476.jpg";  
            String key = hashKeyForDisk(imageUrl);  //MD5對url進行加密,這個主要是爲了獲得統一的16位字符
            DiskLruCache.Editor editor = mDiskLruCache.edit(key);  //拿到Editor,往journal日誌中寫入DIRTY記錄
            if (editor != null) {  
                OutputStream outputStream = editor.newOutputStream(0);  
                if (downloadUrlToStream(imageUrl, outputStream)) {  //downloadUrlToStream方法爲下載圖片的方法,並且將輸出流放到outputStream
                    editor.commit();  //完成後記得commit(),成功後,再往journal日誌中寫入CLEAN記錄
                } else {  
                    editor.abort();  //失敗後,要remove緩存文件,往journal文件中寫入REMOVE記錄
                }  
            }  
            mDiskLruCache.flush();  //將緩存操作同步到journal日誌文件,不一定要在這裏就調用
        } catch (IOException e) {  
            e.printStackTrace();  
        }  
    }  
}).start(); 

注意每次調用edit()時,會向journal日誌文件寫入DIRTY爲前綴的一條記錄;文件保存成功後,調用commit()時,也會向journal日誌中寫入一條CLEAN爲前綴的一條記錄,如果失敗,需要調用abort(),abort()裏面會向journal文件寫入一條REMOVE爲前綴的記錄。

獲取緩存數據是通過get()方法實現的,如下一個簡單示例:

try {  
    String imageUrl = "https://img-my.csdn.net/uploads/201309/01/1378037235_7476.jpg";  
    String key = hashKeyForDisk(imageUrl);  //MD5對url進行加密,這個主要是爲了獲得統一的16位字符
     //通過get拿到value的Snapshot,裏面封裝了輸入流、key等信息,調用get會向journal文件寫入READ爲前綴的記錄
    DiskLruCache.Snapshot snapShot = mDiskLruCache.get(key); 
    if (snapShot != null) {  
        InputStream is = snapShot.getInputStream(0);  
        Bitmap bitmap = BitmapFactory.decodeStream(is);  
        mImage.setImageBitmap(bitmap);  
    }  
} catch (IOException e) {  
    e.printStackTrace();  
} 

snapshot.getInputStream(0)得到clean文件的輸入流,然後通過Bitmap bitmap = BitmapFactory.decodeStream(is);方法得到緩存在硬盤的圖片。

3)合適的地方進行flush()
在上面進行數據緩存或獲取緩存的時候,調用不同的方法會往journal中寫入不同前綴的一行記錄,記錄寫入是通過IO下的Writer寫入的,要真正生效,還需要調用writer的flush()方法,而DiskLruCache中的flush()方法中封裝了writer.flush()的操作,因此,我們只需要在合適地方調用DiskLruCache中的flush()方法即可。其作用也就是將操作記錄同步到journal文件中,這是一個消耗效率的IO操作,我們不用每次一往journal中寫數據後就調用flush,這樣對效率影響較大,可以在Activity的onPause()中調用一下即可。

注:

寫入緩存

1、 然後是這個代碼

DiskLruCache.Editor editor  = mDiskLruCache.edit(key);
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;
}

其中的lruEntries是一個LinkedHashMap,用於記錄資源的key與value。當第一次edit時,lruEntries.get(key)返回的是空。這裏會創建一個Entry對象,並在日誌文件中寫入DIRTY + ’ ’ + key + ‘\n’內容。最後返回包含這個entry的Editor。

3.

OutputStream outputStream = editor.newOutputStream(0);
downloadUrlToStream(imageUrl, outputStream);

得到文件輸出流,將從網絡上請求到的資源寫入到文件中。這裏關注下outputStream,它是由newOutputStream方法得到的:

public OutputStream newOutputStream(int index) throws IOException {
    synchronized (DiskLruCache.this) {
        if (entry.currentEditor != this) {
            throw new IllegalStateException();
        }
        return new FaultHidingOutputStream(new FileOutputStream(entry.getDirtyFile(index)));
    }
}

getDirtyFile文件是一個臨時文件,也就是網絡文件寫入到這個臨時文件當中:

public File getDirtyFile(int i) {
    return new File(directory, key + "." + i + ".tmp");
}

4.接着執行到了editor.commit()方法,如果沒有出現錯誤的話:創建一個key + “.” + i的文件,將上述的dirty文件重命名爲key + “.” + i的文件。更新已經緩存的大小,並且刪除上述的dirty文件。

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 (!entry.getDirtyFile(i).exists()) {
                editor.abort();
                throw new IllegalStateException("edit didn't create file " + i);
            }
        }
    }

    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');
    }

    if (size > maxSize || journalRebuildRequired()) {
        executorService.submit(cleanupCallable);
    }
}

5.當執行完寫入之後,日誌文件如下內容:abb7d9d7add6b9fba5314aec6e60c9e6就是上述MD5生成的key,15417是代表緩存的大小。並生成一個abb7d9d7add6b9fba5314aec6e60c9e6.0文件.

libcore.io.DiskLruCache
1
1
1

DIRTY abb7d9d7add6b9fba5314aec6e60c9e6
CLEAN abb7d9d7add6b9fba5314aec6e60c9e6 15417

小結&注意:
(1)我們可以在在UI線程中檢測內存緩存,即主線程中可以直接使用LruCache;

(2)使用DiskLruCache時,由於緩存或獲取都需要對本地文件進行操作,因此需要另開一個線程,在子線程中檢測磁盤緩存、保存緩存數據,磁盤操作從來不應該在UI線程中實現;

(3)LruCache內存緩存的核心是LinkedHashMap,而DiskLruCache的核心是LinkedHashMap和journal日誌文件,相當於把journal看作是一塊“內存”,LinkedHashMap的value只保存文件的簡要信息,對緩存文件的所有操作都會記錄在journal日誌文件中。

DiskLruCache可能的優化方案:
DiskLruCache是基於日誌文件journal的,這就決定了每次對緩存文件的操作都需要進行日誌文件的記錄,我們可以不用journal文件,在第一次構造DiskLruCache的時候,直接從程序訪問緩存目錄下的緩存文件,並將每個緩存文件的訪問時間作爲初始值記錄在map的value中,每次訪問或保存緩存都更新相應key對應的緩存文件的訪問時間,這樣就避免了頻繁的IO操作,這種情況下就需要使用單例模式對DiskLruCache進行構造了,上面的Acache輕量級的數據緩存類就是這種實現方式。

發佈了63 篇原創文章 · 獲贊 6 · 訪問量 3萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章