Android開源框架原始碼鑑賞:LruCache與DiskLruCache

蘇策發表於2018-01-28

關於作者

郭孝星,程式設計師,吉他手,主要從事Android平臺基礎架構方面的工作,歡迎交流技術方面的問題,可以去我的Github提issue或者發郵件至guoxiaoxingse@163.com與我交流。

文章目錄

  • 一 Lru演算法
  • 二 LruCache原理分析
    • 2.1 寫入快取
    • 2.2 讀取快取
    • 2.3 刪除快取
  • 三 DiskLruCache原理分析
    • 3.1 寫入快取
    • 3.2 讀取快取
    • 3.3 刪除快取

更多Android開源框架原始碼分析文章請參見Android open framework analysis

一 Lru演算法

在分析LruCache與DiskLruCache之前,我們先來簡單的瞭解下LRU演算法的核心原理。

LRU演算法可以用一句話來描述,如下所示:

LRU是Least Recently Used的縮寫,最近最久未使用演算法,從它的名字就可以看出,它的核心原則是如果一個資料在最近一段時間沒有使用到,那麼它在將來被 訪問到的可能性也很小,則這類資料項會被優先淘汰掉。

LRU演算法流程圖如下所示:

Android開源框架原始碼鑑賞:LruCache與DiskLruCache

瞭解了演算法原理,我們來思考一下如果是我們來做,應該如何實現這個演算法。從上圖可以看出,雙向連結串列是一個好主意。

假設我們從表尾訪問資料,在表頭刪除資料,當訪問的資料項在連結串列中存在時,則將該資料項移動到表尾,否則在表尾新建一個資料項。當連結串列容量超過一定閾值,則移除表頭的資料。

好,以上便是整個Lru演算法的原理,我們接著來分析LruCache與DiskLruCache的實現。

二 LruCache原理分析

理解了Lru演算法的原理,我們接著從LruCache的使用入手,逐步分析LruCache的原始碼實現。

? LruCache.java

在分析LruCache的原始碼實現之前,我們先來看看LruCache的簡單使用,如下所示:

int maxMemorySize = (int) (Runtime.getRuntime().totalMemory() / 1024);
int cacheMemorySize = maxMemorySize / 8;
LruCache<String, Bitmap> lrucache = new LruCache<String, Bitmap>(cacheMemorySize) {

    @Override
    protected int sizeOf(String key, Bitmap value) {
        return getBitmapSize(value);
    }

    @Override
    protected void entryRemoved(boolean evicted, String key, Bitmap oldValue, Bitmap newValue) {
        super.entryRemoved(evicted, key, oldValue, newValue);
    }

    @Override
    protected Bitmap create(String key) {
        return super.create(key);
    }
};
複製程式碼

注:getBitmapSize()用來計算圖片佔記憶體的大小,具體方法參見附錄。

可以發現,在使用LruCache的過程中,需要我們關注的主要有三個方法:

  • sizeOf():覆寫此方法實現自己的一套定義計算entry大小的規則。
  • V create(K key):如果key物件快取被移除了,則呼叫次方法重建快取。
  • entryRemoved(boolean evicted, K key, V oldValue, V newValue) :當key對應的快取被刪除時回撥該方法。

我們來看看這三個方法的預設實現,如下所示:

public class LruCache<K, V> {
    
    //該方法預設返回1,也就是以entry的數量來計算entry的大小,這通常不符合我們的需求,所以我們一般會覆寫此方法。
    protected int sizeOf(K key, V value) {
        return 1;
    }
    protected void entryRemoved(boolean evicted, K key, V oldValue, V newValue) {}
    protected V create(K key) {
        return null;
    }
}
複製程式碼

可以發現entryRemoved()方法為空實現,create()方法也預設返回null。sizeOf()方法預設返回1,也就是以entry的數量來計算entry的大小,這通常不符合我們的需求,所以我們一般會覆寫此方法。

我們前面提到,要實現Lru演算法,可以利用雙向連結串列。

假設我們從表尾訪問資料,在表頭刪除資料,當訪問的資料項在連結串列中存在時,則將該資料項移動到表尾,否則在表尾新建一個資料項。當連結串列容量超過一定閾值,則移除表頭的資料。

LruCache使用的是LinkedHashMap,為什麼會選擇LinkedHashMap呢??

這跟LinkedHashMap的特性有關,LinkedHashMap的建構函式裡有個布林引數accessOrder,當它為true時,LinkedHashMap會以訪問順序為序排列元素,否則以插入順序為序排序元素。

public class LruCache<K, V> {
   public LinkedHashMap(int initialCapacity,
                        float loadFactor,
                        boolean accessOrder) {
       super(initialCapacity, loadFactor);
       this.accessOrder = accessOrder;
   } 
}
複製程式碼

我們來寫個小例子驗證一下。

Map<Integer, Integer> map = new LinkedHashMap<>(5, 0.75F, true);
map.put(1, 1);
map.put(2, 2);
map.put(3, 3);
map.put(4, 4);
map.put(5, 5);

Log.d(TAG, "before visit");

for (Map.Entry<Integer, Integer> entry : map.entrySet()) {
    Log.d(TAG, String.valueOf(entry.getValue()));
}

//訪問3,4兩個元素
map.get(3);
map.get(4);

Log.d(TAG, "after visit");

for (Map.Entry<Integer, Integer> entry : map.entrySet()) {
    Log.d(TAG, String.valueOf(entry.getValue()));
}
複製程式碼

程式輸入Log:

Android開源框架原始碼鑑賞:LruCache與DiskLruCache

注:在LinkedHashMap中最近被方位的元素會被移動到表尾,LruCache也是從從表尾訪問資料,在表頭刪除資料,

可以發現,最後訪問的資料就會被移動最尾端,這是符合我們的預期的。所有在LruCache的構造方法中構造了一個這樣的LinkedHashMap。

public LruCache(int maxSize) {
    if (maxSize <= 0) {
        throw new IllegalArgumentException("maxSize <= 0");
    }
    this.maxSize = maxSize;
    this.map = new LinkedHashMap<K, V>(0, 0.75f, true);
}
複製程式碼

我們再來看看LruCache是如何進行快取的寫入、獲取和刪除的。

2.1 寫入快取

寫入快取是通過LruCache的put()方法實現的,如下所示:

public class LruCache<K, V> {
    
    public final V put(K key, V value) {
        if (key == null || value == null) {
            throw new NullPointerException("key == null || value == null");
        }

        V previous;
        //加鎖,執行緒安全
        synchronized (this) {
            //插入的數量自增
            putCount++;
            //利用我們提供的sizeOf()方法計算當前項的大小,並增加已有快取size的大小
            size += safeSizeOf(key, value);
            //插入當前項、
            previous = map.put(key, value);
            //previous如果不為空,則說明該項在原來的連結串列中以及存在,已有快取大小size恢復到
            //以前的大小
            if (previous != null) {
                size -= safeSizeOf(key, previous);
            }
        }

        //回撥entryRemoved()方法
        if (previous != null) {
            entryRemoved(false, key, previous, value);
        }

        //調整快取大小,如果快取滿了,則按照Lru演算法刪除對應的項。
        trimToSize(maxSize);
        return previous;
    }
    
    public void trimToSize(int maxSize) {
        //開啟死迴圈,知道快取不滿為止
        while (true) {
            K key;
            V value;
            synchronized (this) {
                //引數檢查
                if (size < 0 || (map.isEmpty() && size != 0)) {
                    throw new IllegalStateException(getClass().getName()
                            + ".sizeOf() is reporting inconsistent results!");
                }

                //如果快取為滿,直接返回                
                if (size <= maxSize) {
                    break;
                }

                //返回最近最久未使用的元素,也就是連結串列的表頭元素
                Map.Entry<K, V> toEvict = map.eldest();
                if (toEvict == null) {
                    break;
                }

                key = toEvict.getKey();
                value = toEvict.getValue();
                //刪除該表頭元素
                map.remove(key);
                //減少總快取大小
                size -= safeSizeOf(key, value);
                //被刪除的項的數量自增
                evictionCount++;
            }
            //回到entryRemoved()方法
            entryRemoved(true, key, value, null);
        }
    }
}
複製程式碼

整個插入元素的方法put()實現邏輯是很簡單的,如下所示:

  1. 插入元素,並相應增加當前快取的容量。
  2. 呼叫trimToSize()開啟一個死迴圈,不斷的從表頭刪除元素,直到當前快取的容量小於最大容量為止。

2.2 讀取快取

讀取快取是通過LruCache的get()方法實現的,如下所示:

public class LruCache<K, V> {
    
    public final V get(K key) {
          if (key == null) {
              throw new NullPointerException("key == null");
          }
  
          V mapValue;
          synchronized (this) {
              //呼叫LinkedHashMap的get()方法,注意如果該元素存在,且accessOrder為true,這個方法會
              //將該元素移動到表尾
              mapValue = map.get(key);
              if (mapValue != null) {
                  hitCount++;
                  return mapValue;
              }
              //
              missCount++;
          }
            
          //前面我們就提到過,可以覆寫create()方法,當獲取不到和key對應的元素時,嘗試呼叫create()方法
          //建立建元素,以下就是建立的過程,和put()方法流程相同。
          V createdValue = create(key);
          if (createdValue == null) {
              return null;
          }
  
          synchronized (this) {
              createCount++;
              mapValue = map.put(key, createdValue);
  
              if (mapValue != null) {
                  // There was a conflict so undo that last put
                  map.put(key, mapValue);
              } else {
                  size += safeSizeOf(key, createdValue);
              }
          }
  
          if (mapValue != null) {
              entryRemoved(false, key, createdValue, mapValue);
              return mapValue;
          } else {
              trimToSize(maxSize);
              return createdValue;
          }
      }
}
複製程式碼

獲取元素的邏輯如下所示:

  1. 呼叫LinkedHashMap的get()方法,注意如果該元素存在,且accessOrder為true,這個方法會將該元素移動到表尾.
  2. 當獲取不到和key對應的元素時,嘗試呼叫create()方法建立建元素,以下就是建立的過程,和put()方法流程相同。

2.3 刪除快取

刪除快取是通過LruCache的remove()方法實現的,如下所示:

public class LruCache<K, V> {
    
    public final V remove(K key) {
        if (key == null) {
            throw new NullPointerException("key == null");
        }

        V previous;
        synchronized (this) {
            //呼叫對應LinkedHashMap的remove()方法刪除對應元素
            previous = map.remove(key);
            if (previous != null) {
                size -= safeSizeOf(key, previous);
            }
        }

        if (previous != null) {
            entryRemoved(false, key, previous, null);
        }

        return previous;
    }

}    
複製程式碼

刪除元素的邏輯就比較簡單了,呼叫對應LinkedHashMap的remove()方法刪除對應元素。

三 DiskLruCache原理分析

? DiskLruCache.java

在分析DiskLruCache的實現原理之前,我們先來寫個簡單的小例子,從例子出發去分析DiskLruCache的實現原理。

File directory = getCacheDir();
int appVersion = 1;
int valueCount = 1;
long maxSize = 10 * 1024;
DiskLruCache diskLruCache = DiskLruCache.open(directory, appVersion, valueCount, maxSize);

DiskLruCache.Editor editor = diskLruCache.edit(String.valueOf(System.currentTimeMillis()));
BufferedOutputStream bufferedOutputStream = new BufferedOutputStream(editor.newOutputStream(0));
Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.scenery);
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, bufferedOutputStream);

editor.commit();
diskLruCache.flush();
diskLruCache.close();
複製程式碼

這個就是DiskLruCache的大致使用流程,我們來看看這個入口方法的實現,如下所示:

public final class DiskLruCache implements Closeable {
    
     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");
        }
    
        File backupFile = new File(directory, JOURNAL_FILE_BACKUP);
        //如果備份檔案存在
        if (backupFile.exists()) {
          File journalFile = new File(directory, JOURNAL_FILE);
          // 如果journal檔案存在,則把備份檔案journal.bkp是刪了
          if (journalFile.exists()) {
            backupFile.delete();
          } else {
            //如果journal檔案不存在,則將備份檔案命名為journal
            renameTo(backupFile, journalFile, false);
          }
        }
    
        DiskLruCache cache = new DiskLruCache(directory, appVersion, valueCount, maxSize);
        
        //判斷journal檔案是否存在
        if (cache.journalFile.exists()) {
          //如果日誌檔案以及存在
          try {
            //讀取journal檔案,根據記錄中不同的操作型別進行相應的處理。
            cache.readJournal();
            //計算當前快取容量的大小
            cache.processJournal();
            cache.journalWriter = new BufferedWriter(
                new OutputStreamWriter(new FileOutputStream(cache.journalFile, true), Util.US_ASCII));
            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);
        //呼叫新的方法建立新的journal檔案
        cache.rebuildJournal();
        return cache;
      }
}
複製程式碼

先來說一下這個入口方法的四個引數的含義:

  • File directory:快取目錄。
  • int appVersion:應用版本號。
  • int valueCount:一個key對應的快取檔案的數目,如果我們傳入的引數大於1,那麼快取檔案字尾就是.0,.1等。
  • long maxSize:快取容量上限。

DiskLruCache的構造方法並沒有做別的事情,只是簡單的將對應成員變數進行初始化,open()方法主要圍繞著journal檔案的建立與讀寫而展開的,如下所示:

  • readJournal():讀取journal檔案,主要是讀取檔案頭裡的資訊進行檢驗,然後呼叫readJournalLine()逐行去讀取,根據讀取的內容,執行相應的快取 新增、移除等操作。
  • rebuildJournal():重建journal檔案,重建journal檔案主要是寫入檔案頭(上面提到的journal檔案都有的前面五行的內容)。
  • rocessJournal():計算當前快取容量的大小。

我們接著來分析什麼是journal檔案,以及它的建立與讀寫流程。

3.1 journal檔案的建立

在前面分析的open()方法中,主要圍繞著journal檔案的建立和讀寫來展開的,那麼journal檔案是什麼呢??

我們如果去開啟快取目錄,就會發現除了快取檔案,還會發現一個journal檔案,journal檔案用來記錄快取的操作記錄的,如下所示:

libcore.io.DiskLruCache
1
1
1

DIRTY 1517126350519
CLEAN 1517126350519 5325928
REMOVE 1517126350519
複製程式碼

注:這裡的快取目錄是應用的快取目錄/data/data/pckagename/cache,未root的手機可以通過以下命令進入到該目錄中或者將該目錄整體拷貝出來:


//進入/data/data/pckagename/cache目錄
adb shell
run-as com.your.packagename 
cp /data/data/com.your.packagename/

//將/data/data/pckagename目錄拷貝出來
adb backup -noapk com.your.packagename
複製程式碼

我們來分析下這個檔案的內容:

  • 第一行:libcore.io.DiskLruCache,固定字串。
  • 第二行:1,DiskLruCache原始碼版本號。
  • 第三行:1,App的版本號,通過open()方法傳入進去的。
  • 第四行:1,每個key對應幾個檔案,一般為1.
  • 第五行:空行
  • 第六行及後續行:快取操作記錄。

第六行及後續行表示快取操作記錄,關於操作記錄,我們需要了解以下三點:

  1. DIRTY 表示一個entry正在被寫入。寫入分兩種情況,如果成功會緊接著寫入一行CLEAN的記錄;如果失敗,會增加一行REMOVE記錄。注意單獨只有DIRTY狀態的記錄是非法的。
  2. 當手動呼叫remove(key)方法的時候也會寫入一條REMOVE記錄。
  3. READ就是說明有一次讀取的記錄。
  4. CLEAN的後面還記錄了檔案的長度,注意可能會一個key對應多個檔案,那麼就會有多個數字。

這幾種操作對應到DiskLruCache原始碼中,如下所示:

private static final String CLEAN = "CLEAN";
private static final String DIRTY = "DIRTY";
private static final String REMOVE = "REMOVE";
private static final String READ = "READ";

複製程式碼

那麼構建一個新的journal檔案呢?上面我們也說過這是呼叫rebuildJournal()方法來完成的。

rebuildJournal()

public final class DiskLruCache implements Closeable {
    
     static final String MAGIC = "libcore.io.DiskLruCache";
    
     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檔案過程就是寫入檔案頭的過程,檔案頭內容包含前面我們說的appVersion、valueCount、空行等五行內容。

我們再來看看如何讀取journal檔案裡的內容。

readJournal()

public final class DiskLruCache implements Closeable {
   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 {
             //開啟死迴圈,逐行讀取journal內容
             readJournalLine(reader.readLine());
             //檔案以及讀取的行數
             lineCount++;
           } catch (EOFException endOfJournal) {
             break;
           }
         }
         //lineCount表示檔案總行數,lruEntries.size()表示最終快取的個數,redundantOpCount
         //就表示非法快取記錄的個數,這些非法快取記錄會被移除掉。
         redundantOpCount = lineCount - lruEntries.size();
       } finally {
         Util.closeQuietly(reader);
       }
     }
   
     private void readJournalLine(String line) throws IOException {
       //每行記錄都是用空格開分隔的,這裡取第一個空格出現的位置
       int firstSpace = line.indexOf(' ');
       //如果沒有空格,則說明是非法的記錄
       if (firstSpace == -1) {
         throw new IOException("unexpected journal line: " + line);
       }
   
       //第一個空格前面就是CLEAN、READ這些操作型別,接下來針對不同的操作型別進行
       //相應的處理
       int keyBegin = firstSpace + 1;
       int secondSpace = line.indexOf(' ', keyBegin);
       final String key;
       if (secondSpace == -1) {
         key = line.substring(keyBegin);
         //1. 如果該條記錄以REMOVE為開頭,則執行刪除操作。
         if (firstSpace == REMOVE.length() && line.startsWith(REMOVE)) {
           lruEntries.remove(key);
           return;
         }
       } else {
         key = line.substring(keyBegin, secondSpace);
       }
   
       //2. 如果該key不存在,則新建Entry並加入lruEntries。
       Entry entry = lruEntries.get(key);
       if (entry == null) {
         entry = new Entry(key);
         lruEntries.put(key, entry);
       }
   
       //3. 如果該條記錄以CLEAN為開頭,則初始化entry,並設定entry.readable為true、設定entry.currentEditor為
       //null,初始化entry長度。
       //CLEAN 3400330d1dfc7f3f7f4b8d4d803dfcf6 832 21054
       if (secondSpace != -1 && firstSpace == CLEAN.length() && line.startsWith(CLEAN)) {
         //陣列中其實是數字,其實就是檔案的大小。因為可以通過valueCount來設定一個key對應的value的個數,
         //所以檔案大小也是有valueCount個
         String[] parts = line.substring(secondSpace + 1).split(" ");
         entry.readable = true;
         entry.currentEditor = null;
         entry.setLengths(parts);
       }
       //4. 如果該條記錄以DIRTY為開頭。則設定currentEditor物件。
       //DIRTY 335c4c6028171cfddfbaae1a9c313c52
       else if (secondSpace == -1 && firstSpace == DIRTY.length() && line.startsWith(DIRTY)) {
         entry.currentEditor = new Editor(entry);
       } 
       //5. 如果該條記錄以READ為開頭,則什麼也不做。
       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);
       }
     } 
}
複製程式碼

新來說一下這個lruEntries是什麼,如下所示:

private final LinkedHashMap<String, Entry> lruEntries =
  new LinkedHashMap<String, Entry>(0, 0.75f, true);
複製程式碼

就跟上面的LruCache一樣,它也是一個以訪問順序為序的LinkedHashMap,可以用它來實現Lru演算法。

該方法的邏輯就是根據記錄中不同的操作型別進行相應的處理,如下所示:

  1. 如果該條記錄以REMOVE為開頭,則執行刪除操作。
  2. 如果該key不存在,則新建Entry並加入lruEntries。
  3. 如果該條記錄以CLEAN為開頭,則初始化entry,並設定entry.readable為true、設定entry.currentEditor為null,初始化entry長度。
  4. 如果該條記錄以DIRTY為開頭。則設定currentEditor物件。
  5. 如果該條記錄以READ為開頭,則什麼也不做。

說了這麼多,readJournalLine()方法主要是通過讀取journal檔案的每一行,然後封裝成entry物件,放到了LinkedHashMap集合中。並且根據每一行不同的開頭,設定entry的值。也就是說通過讀取這 個檔案,我們把所有的在本地快取的檔案的key都儲存到了集合中,這樣我們用的時候就可以通過集合來操作了。

processJournal()

public final class DiskLruCache implements Closeable {
    
      private void processJournal() throws IOException {
        //刪除journal.tmp臨時檔案
        deleteIfExists(journalFileTmp);
        //變數快取集合裡的所有元素
        for (Iterator<Entry> i = lruEntries.values().iterator(); i.hasNext(); ) {
          Entry entry = i.next();
          //如果當前元素entry的currentEditor不為空,則計算該元素的總大小,並新增到總快取容量size中去
          if (entry.currentEditor == null) {
            for (int t = 0; t < valueCount; t++) {
              size += entry.lengths[t];
            }
          } 
          //如果當前元素entry的currentEditor不為空,代表該元素時非法快取記錄,該記錄以及對應的快取檔案
          //都會被刪除掉。
          else {
            entry.currentEditor = null;
            for (int t = 0; t < valueCount; t++) {
              deleteIfExists(entry.getCleanFile(t));
              deleteIfExists(entry.getDirtyFile(t));
            }
            i.remove();
          }
        }
      }
}
複製程式碼

這裡提到了一個非常快取記錄,那麼什麼是非法快取記錄呢??

DIRTY 表示一個entry正在被寫入。寫入分兩種情況,如果成功會緊接著寫入一行CLEAN的記錄;如果失敗,會增加一行REMOVE記錄。注意單獨只有DIRTY狀態的記錄是非法的。

該方法主要用來計算當前的快取總容量,並刪除非法快取記錄以及該記錄對應的檔案。

理解了journal檔案的建立以及讀寫流程,我們來看看硬碟快取的寫入、讀取和刪除的過程。

3.2 寫入快取

DiskLruCache快取的寫入是通過edit()方法來完成的,如下所示:

public final class DiskLruCache implements Closeable {
    
    private synchronized Editor edit(String key, long expectedSequenceNumber) throws IOException {
        checkNotClosed();
        validateKey(key);
        //從之前的快取中讀取對應的entry
        Entry entry = lruEntries.get(key);
        //當前無法寫入磁碟快取
        if (expectedSequenceNumber != ANY_SEQUENCE_NUMBER && (entry == null
            || entry.sequenceNumber != expectedSequenceNumber)) {
          return null; // Snapshot is stale.
        }
        
        //如果entry為空,則新建一個entry物件加入到快取集合中
        if (entry == null) {
          entry = new Entry(key);
          lruEntries.put(key, entry);
        } 
        //currentEditor不為空,表示當前有別的插入操作在執行
        else if (entry.currentEditor != null) {
          return null; // Another edit is in progress.
        }
    
        //為當前建立的entry知道新建立的editor
        Editor editor = new Editor(entry);
        entry.currentEditor = editor;
    
        //向journal寫入一行DIRTY + 空格 + key的記錄,表示這個key對應的快取正在處於被編輯的狀態。
        journalWriter.write(DIRTY + ' ' + key + '\n');
        //重新整理檔案裡的記錄
        journalWriter.flush();
        return editor;
      }
}
複製程式碼

這個方法構建了一個Editor物件,它主要做了兩件事情:

  1. 從集合中找到對應的例項(如果沒有建立一個放到集合中),然後建立一個editor,將editor和entry關聯起來。
  2. 向journal中寫入一行運算元據(DITTY 空格 和key拼接的文字),表示這個key當前正處於編輯狀態。

我們在前面的DiskLruCache的使用例子中,呼叫了Editor的newOutputStream()方法建立了一個OutputStream來寫入快取檔案。如下所示:

public final class DiskLruCache implements Closeable {
    
    public InputStream newInputStream(int index) throws IOException {
      synchronized (DiskLruCache.this) {
        if (entry.currentEditor != this) {
          throw new IllegalStateException();
        }
        if (!entry.readable) {
          return null;
        }
        try {
          return new FileInputStream(entry.getCleanFile(index));
        } catch (FileNotFoundException e) {
          return null;
        }
      }
    }
}
複製程式碼

這個方法的形參index就是我們開始在open()方法裡傳入的valueCount,這個valueCount表示了一個key對應幾個value,也就是說一個key對應幾個快取檔案。那麼現在傳入的這個index就表示 要快取的檔案時對應的第幾個value。

有了輸出流,我們在接著呼叫Editor的commit()方法就可以完成快取檔案的寫入了,如下所示:

public final class DiskLruCache implements Closeable {
     public void commit() throws IOException {
         //如果通過輸出流寫入快取檔案出錯了就把集合中的快取移除掉
          if (hasErrors) {
            completeEdit(this, false);
            remove(entry.key); // The previous entry is stale.
          } else {
            //呼叫completeEdit()方法完成快取寫入。
            completeEdit(this, true);
          }
          committed = true;
        }
}
複製程式碼

可以看到該方法呼叫DiskLruCache的completeEdit()方法來完成快取寫入,如下所示:

public final class DiskLruCache implements Closeable {
    
    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;
          //向journal寫入一行CLEAN開頭的記錄,表示快取成功寫入到磁碟。
          journalWriter.write(CLEAN + ' ' + entry.key + entry.getLengths() + '\n');
          if (success) {
            entry.sequenceNumber = nextSequenceNumber++;
          }
        } else {
          //如果不成功,則從集合中刪除掉這個快取
          lruEntries.remove(entry.key);
          //向journal檔案寫入一行REMOVE開頭的記錄,表示刪除了快取
          journalWriter.write(REMOVE + ' ' + entry.key + '\n');
        }
        journalWriter.flush();
    
        //如果快取總大小已經超過了設定的最大快取大小或者操作次數超過了2000次,
        // 就開一個執行緒將集合中的資料刪除到小於最大快取大小為止並重新寫journal檔案
        if (size > maxSize || journalRebuildRequired()) {
          executorService.submit(cleanupCallable);
        }
      }
}
複製程式碼

這個方法一共做了以下幾件事情:

  1. 如果輸出流寫入資料成功,就把寫入的臨時檔案重新命名為正式的快取檔案
  2. 重新設定當前總快取的大小
  3. 向journal檔案寫入一行CLEAN開頭的字元(包括key和檔案的大小,檔案大小可能存在多個 使用空格分開的)
  4. 如果輸出流寫入失敗,就刪除掉寫入的臨時檔案,並且把集合中的快取也刪除
  5. 向journal檔案寫入一行REMOVE開頭的字元
  6. 重新比較當前快取和最大快取的大小,如果超過最大快取或者journal檔案的操作大於2000條,就把集合中的快取刪除一部分,直到小於最大快取,重新建立新的journal檔案

到這裡,快取的插入流程就完成了。

3.3 讀取快取

讀取快取是由DiskLruCache的get()方法來完成的,如下所示:

public final class DiskLruCache implements Closeable {
    
      public synchronized Snapshot get(String key) throws IOException {
        checkNotClosed();
        validateKey(key);
        //獲取對應的entry
        Entry entry = lruEntries.get(key);
        if (entry == null) {
          return null;
        }
    
        //如果entry不可讀,說明可能在編輯,則返回空。
        if (!entry.readable) {
          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!
          for (int i = 0; i < valueCount; i++) {
            if (ins[i] != null) {
              Util.closeQuietly(ins[i]);
            } else {
              break;
            }
          }
          return null;
        }
    
        redundantOpCount++;
        //向journal寫入一行READ開頭的記錄,表示執行了一次讀取操作
        journalWriter.append(READ + ' ' + key + '\n');
        
         
        //如果快取總大小已經超過了設定的最大快取大小或者操作次數超過了2000次,
        // 就開一個執行緒將集合中的資料刪除到小於最大快取大小為止並重新寫journal檔案
        if (journalRebuildRequired()) {
          executorService.submit(cleanupCallable);
        }
        
        //返回一個快取檔案快照,包含快取檔案大小,輸入流等資訊。
        return new Snapshot(key, entry.sequenceNumber, ins, entry.lengths);
      }
}
複製程式碼

讀取操作主要完成了以下幾件事情:

  1. 獲取對應的entry。
  2. 開啟所有快取檔案的輸入流,等待被讀取。
  3. 向journal寫入一行READ開頭的記錄,表示執行了一次讀取操作。
  4. 如果快取總大小已經超過了設定的最大快取大小或者操作次數超過了2000次,就開一個執行緒將集合中的資料刪除到小於最大快取大小為止並重新寫journal檔案。
  5. 返回一個快取檔案快照,包含快取檔案大小,輸入流等資訊。

該方法最終返回一個快取檔案快照,包含快取檔案大小,輸入流等資訊。利用這個快照我們就可以讀取快取檔案了。

3.4 刪除快取

刪除快取是由DiskLruCache的remove()方法來完成的,如下所示:

public final class DiskLruCache implements Closeable {
    
      public synchronized boolean remove(String key) throws IOException {
        checkNotClosed();
        validateKey(key);
        //獲取對應的entry
        Entry entry = lruEntries.get(key);
        if (entry == null || entry.currentEditor != null) {
          return false;
        }
    
        //刪除對應的快取檔案,並將快取大小置為0.
        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++;
        //向journal檔案新增一行REMOVE開頭的記錄,表示執行了一次刪除操作。
        journalWriter.append(REMOVE + ' ' + key + '\n');
        lruEntries.remove(key);
    
    
        //如果快取總大小已經超過了設定的最大快取大小或者操作次數超過了2000次,
        // 就開一個執行緒將集合中的資料刪除到小於最大快取大小為止並重新寫journal檔案
        if (journalRebuildRequired()) {
          executorService.submit(cleanupCallable);
        }
    
        return true;
      }   
}
複製程式碼

刪除操作主要做了以下幾件事情:

  1. 獲取對應的entry。
  2. 刪除對應的快取檔案,並將快取大小置為0.
  3. 向journal檔案新增一行REMOVE開頭的記錄,表示執行了一次刪除操作。
  4. 如果快取總大小已經超過了設定的最大快取大小或者操作次數超過了2000次,就開一個執行緒將集合中的資料刪除到小於最大快取大小為止並重新寫journal檔案。

好,到這裡LrcCache和DiskLruCache的實現原理都講完了,這兩個類在主流的圖片框架Fresco、Glide和網路框架Okhttp等都有著廣泛的應用,後續的文章後繼續分析LrcCache和DiskLruCache 在這些框架裡的應用。

附錄

圖片佔用記憶體大小的計算

Android裡面快取應用最多的場景就是圖片快取了,誰讓圖片在記憶體裡是個大胖子呢,在做快取的時候我們經常會去計算圖片展記憶體的大小。

那麼如何去獲取一張圖片佔用記憶體的大小呢??

private int getBitmapSize(Bitmap bitmap) {
    //API 19
    if (Build.VERSION.SDK_INT == Build.VERSION_CODES.KITKAT) {
        return bitmap.getAllocationByteCount();
    }
    //API 12
    if (Build.VERSION.SDK_INT == Build.VERSION_CODES.HONEYCOMB_MR1) {
        return bitmap.getByteCount();
    }
    // Earlier Version
    return bitmap.getRowBytes() * bitmap.getHeight();
}
複製程式碼

那麼這三個方法處了版本上的差異,具體有什麼區別呢?

getRowBytes()返回的是每行的畫素值,乘以高度就是總的畫素數,也就是佔用記憶體的大小。 getAllocationByteCount()與getByteCount()的返回值一般情況下都是相等的。只是在圖片 複用的時候,getAllocationByteCount()返回的是複用影象所佔記憶體的大小,getByteCount()返回的是新解碼圖片佔用記憶體的大小。

我們來寫一個小例子驗證一下,如下所示:

BitmapFactory.Options options = new BitmapFactory.Options();
options.inDensity = 320;
options.inTargetDensity = 320;
//要實現複用,影象必須是可變的,也就是inMutable為true。
options.inMutable = true;
Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.scenery, options);
Log.d(TAG, "bitmap.getAllocationByteCount(): " + String.valueOf(bitmap.getAllocationByteCount()));
Log.d(TAG, "bitmap.getByteCount(): " + String.valueOf(bitmap.getByteCount()));
Log.d(TAG, "bitmap.getRowBytes() * bitmap.getHeight(): " + String.valueOf(bitmap.getRowBytes() * bitmap.getHeight()));

BitmapFactory.Options reuseOptions = new BitmapFactory.Options();
reuseOptions.inDensity = 320;
reuseOptions.inTargetDensity = 320;
//要複用的Bitmap
reuseOptions.inBitmap = bitmap;
//要實現複用,影象必須是可變的,也就是inMutable為true。
reuseOptions.inMutable = true;
Bitmap reuseBitmap = BitmapFactory.decodeResource(getResources(), R.drawable.scenery_reuse, reuseOptions);
Log.d(TAG, "reuseBitmap.getAllocationByteCount(): " + String.valueOf(reuseBitmap.getAllocationByteCount()));
Log.d(TAG, "reuseBitmap.getByteCount(): " + String.valueOf(reuseBitmap.getByteCount()));
Log.d(TAG, "reuseBitmap.getRowBytes() * reuseBitmap.getHeight(): " + String.valueOf(reuseBitmap.getRowBytes() * reuseBitmap.getHeight()));
複製程式碼

執行的log如下所示:

Android開源框架原始碼鑑賞:LruCache與DiskLruCache

可以發現reuseBitmap的getAllocationByteCount()和getByteCount()返回不一樣,getAllocationByteCount()返回的是複用bitmap佔用記憶體的大小, getByteCount()返回的是新的reuseOptions實際解碼佔用的記憶體大小。

注意在複用圖片的時候,options.inMutable必須設定為true,否則無法進行復用,如下所示:

Android開源框架原始碼鑑賞:LruCache與DiskLruCache

相關文章