1. 程式人生 > >Android 記憶體快取框架 LruCache 的原始碼分析

Android 記憶體快取框架 LruCache 的原始碼分析

LruCache 是 Android 提供的一種基於記憶體的快取框架。LRU 是 Least Recently Used 的縮寫,即最近最少使用。當一塊記憶體最近很少使用的時候就會被從快取中移除。在這篇文章中,我們會先簡單介紹 LruCache 的使用,然後我們會對它的原始碼進行分析。

1、基本的使用示例

首先,讓我們來簡單介紹一下如何使用 LruCache 實現記憶體快取。下面是 LruCache 的一個使用示例。

這裡我們實現的是對 RecyclerView 的列表的截圖的功能。因為我們需要將列表的每個項的 Bitmap 儲存下來,然後當所有的列表項的 Bitmap 都拿到的時候,將其按照順序和位置繪製到一個完整的 Bitmap 上面。如果我們不使用 LruCache 的話,當然也能夠是實現這個功能——將所有的列表項的 Bitmap 放置到一個 List 中即可。但是那種方式存在缺點:因為是強引用型別,所以當記憶體不足的時候會導致 OOM。

在下面的方法中,我們先獲取了記憶體的大小的 8 分之一作為快取空間的大小,用來初始化 LruCache 物件,然後從 RecyclerView 的介面卡中取出所有的 ViewHolder 並獲取其對應的 Bitmap,然後按照鍵值對的方式將其放置到 LruCache 中。當所有的列表項的 Bitmap 都拿到之後,我們再建立最終的 Bitmap 並將之前的 Bitmap 依次繪製到最終的 Bitmap 上面:

public static Bitmap shotRecyclerView(RecyclerView view) {
    RecyclerView.Adapter adapter = view.getAdapter();
    Bitmap bigBitmap = null;
    if (adapter != null) {
        int size = adapter.getItemCount();
        int height = 0;
        Paint paint = new Paint();
        int iHeight = 0;
        final int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024);

        // 使用記憶體的 8 分之一作為該快取框架的快取空間
        final int cacheSize = maxMemory / 8;
        LruCache<String, Bitmap> bitmaCache = new LruCache<>(cacheSize);
        for (int i = 0; i < size; i++) {
            RecyclerView.ViewHolder holder = adapter.createViewHolder(view, adapter.getItemViewType(i));
            adapter.onBindViewHolder(holder, i);
            holder.itemView.measure(
                    View.MeasureSpec.makeMeasureSpec(view.getWidth(), View.MeasureSpec.EXACTLY),
                    View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED));
            holder.itemView.layout(0, 0, holder.itemView.getMeasuredWidth(),
                    holder.itemView.getMeasuredHeight());
            holder.itemView.setDrawingCacheEnabled(true);
            holder.itemView.buildDrawingCache();
            Bitmap drawingCache = holder.itemView.getDrawingCache();
            if (drawingCache != null) {
                bitmaCache.put(String.valueOf(i), drawingCache);
            }
            height += holder.itemView.getMeasuredHeight();
        }

        bigBitmap = Bitmap.createBitmap(view.getMeasuredWidth(), height, Bitmap.Config.ARGB_8888);
        Canvas bigCanvas = new Canvas(bigBitmap);
        Drawable lBackground = view.getBackground();
        if (lBackground instanceof ColorDrawable) {
            ColorDrawable lColorDrawable = (ColorDrawable) lBackground;
            int lColor = lColorDrawable.getColor();
            bigCanvas.drawColor(lColor);
        }

        for (int i = 0; i < size; i++) {
            Bitmap bitmap = bitmaCache.get(String.valueOf(i));
            bigCanvas.drawBitmap(bitmap, 0f, iHeight, paint);
            iHeight += bitmap.getHeight();
            bitmap.recycle();
        }
    }

    return bigBitmap;
}
複製程式碼

因此,我們可以總結出 LruCahce 的基本用法如下:

首先,你要宣告一個快取空間的大小,在這裡我們用了執行時記憶體的 8 分之 1 作為快取空間的大小

LruCache<String, Bitmap> bitmaCache = new LruCache<>(cacheSize);
複製程式碼

但是應該注意的一個問題是快取空間的單位的問題。因為 LruCache 的鍵值對的值可能是任何型別的,所以你傳入的型別的大小如何統計需要自己去指定。後面我們在分析它的原始碼的時候會指出它的單位的問題。LruCahce 的 API 中也已經提供了計算傳入的值的大小的方法。我們只需要在例項化一個 LruCache 的時候覆寫該方法即可。而這裡我們認為一個 Bitmap 物件所佔用的記憶體的大小不超過 1KB.

然後,我們可以像普通的 Map 一樣呼叫它的 put() 和 get() 方法向快取中插入和從快取中取出資料:

bitmaCache.put(String.valueOf(i), drawingCache);
Bitmap bitmap = bitmaCache.get(String.valueOf(i));
複製程式碼

2、LruCahce 原始碼分析

2.1 分析之前:當我們自己實現一個 LruCache 的時候,我們需要考慮什麼

在我們對 LruCache 的原始碼進行分析之前,我們現來考慮一下當我們自己去實現一個 LruCache 的時候需要考慮哪些東西,以此來帶著問題閱讀原始碼。

因為我們需要對資料進行儲存,並且又能夠根據指定的 id 將資料從快取中取出,所以我們需要使用雜湊表表結構。或者使用兩個陣列,一個作為鍵一個作為值,然後使用它們的索引來實現對映也行。但是,後者的效率不如前者高。

此外,我們還要對插入的元素進行排序,因為我們需要移除那些使用頻率最小的元素。我們可以使用連結串列來達到這個目的,每當一個數據被用到的時候,我們可以將其移向連結串列的頭節點。這樣當要插入的元素大於快取的最大空間的時候,我們就將連結串列末位的元素移除,以在快取中騰出空間。

綜合這兩點,我們需要一個既有雜湊表功能,又有佇列功能的資料結構。在 Java 的集合中,已經為我們提供了 LinkedHashMap 用來實現這個功能。

實際上在 Android 中的 LruCache 也正是使用 LinkedHashMap 來實現的。LinkedHashMap 拓展自HashMap。如果理解 HashMap 的話,它的原始碼就不難閱讀。LinkedHashMap 僅在 HashMap 的基礎之上,又將各個節點放進了一個雙向連結串列中。每次增加和刪除一個元素的時候,被操作的元素會被移到到連結串列的末尾。Android 中的 LruCahce 就是在 LinkedHashMap 基礎之上進行了一層拓展,不過 Android 中的 LruCache 的實現具有一些很巧妙的地方值得我們學習。

2.2 LruCache 原始碼分析

從上面的分析中我們知道了選擇 LinkedHashMap 作為底層資料結構的原因。下面我們分析其中的一些方法。這個類的實現還有許多的細節考慮得非常周到,非常值得我們借鑑和學習。

2.2.1 快取的最大可用空間

在 LruCache 中有兩個欄位 size 和 maxSize. maxSize 會在 LruCache 的構造方法中被賦值,用來表示該快取的最大可用的空間:

int cacheSize = 4 * 1024 * 1024; // 4MiB,cacheSize 的單位是 KB
LruCache<String, Bitmap> bitmapCache = new LruCache<String, Bitmap>(cacheSize) {
    protected int sizeOf(String key, Bitmap value) {
        return value.getByteCount();
    }
}};
複製程式碼

這裡我們使用 4MB 來設定快取空間的大小。我們知道 LruCache 的原理是指定了空間的大小之後,如果繼續插入元素時,空間超出了指定的大小就會將那些“可以被移除”的元素移除掉,以此來為新的元素騰出空間。那麼,因為插入的型別時不確定的,所以具體被插入的物件如何計算大小就應該交給使用者來實現。

在上面的程式碼中,我們直接使用了 Bitmap 的 getByteCount() 方法來獲取 Bitmap 的大小。同時,我們也注意到在最初的例子中,我們並沒有這樣去操作。那樣的話一個 Bitmap 將會被當作 1KB 來計算。

這裡的 sizeOf() 是一個受保護的方法,顯然是希望使用者自己去實現計算的邏輯。它的預設值是 1,單位和設定快取大小指定的 maxSize 的單位相同:

protected int sizeOf(K key, V value) {
    return 1;
}
複製程式碼

這裡我們還需要提及一下:雖然這個方法交給使用者來實現,但是在 LruCache 的原始碼中,不會直接呼叫這個方法,而是

private int safeSizeOf(K key, V value) {
    int result = sizeOf(key, value);
    if (result < 0) {
        throw new IllegalStateException("Negative size: " + key + "=" + value);
    }
    return result;
}
複製程式碼

所以,這裡又增加了一個檢查,防止引數錯誤。其實,這個考慮是非常周到的,試想如果傳入了一個非法的引數,導致了意外的錯誤,那麼錯誤的地方就很難跟蹤了。如果我們自己想設計 API 給別人用並且提供給他們自己可以覆寫的方法的時候,不妨借鑑一下這個設計。

2.2.2 LruCache 的 get() 方法

下面我們分析它的 get() 方法。它用來從 LruCahce 中根據指定的鍵來獲取對應的值:

/**
 * 1). 獲取指定 key 對應的元素,如果不存在的話就用 craete() 方法建立一個。
 * 2). 當返回一個元素的時候,該元素將被移動到佇列的首位;
 * 3). 如果在快取中不存在又不能建立,就返回n ull
 */
public final V get(K key) {
    if (key == null) {
        throw new NullPointerException("key == null");
    }

    V mapValue;
    synchronized (this) {
        // 在這裡如果返回不為空的話就會將返回的元素移動到佇列頭部,這是在 LinkedHashMap 中實現的
        mapValue = map.get(key);
        if (mapValue != null) {
            // 快取命中
            hitCount++;
            return mapValue;
        }
        // 快取沒有命中,可能是因為這個鍵值對被移除了
        missCount++;
    }

    // 這裡的建立是單執行緒的,在建立的時候指定的 key 可能已經被其他的鍵值對佔用
    V createdValue = create(key);
    if (createdValue == null) {
        return null;
    }

    // 這裡設計的目的是防止建立的時候,指定的 key 已經被其他的 value 佔用,如果衝突就撤銷插入
    synchronized (this) {
        createCount++;
        // 向表中插入一個新的資料的時候會返回該 key 之前對應的值,如果沒有的話就返回 null
        mapValue = map.put(key, createdValue);
        if (mapValue != null) {
            // 衝突了,還要撤銷之前的插入操作
            map.put(key, mapValue);
        } else {
            size += safeSizeOf(key, createdValue);
        }
    }

    if (mapValue != null) {
        entryRemoved(false, key, createdValue, mapValue);
        return mapValue;
    } else {
        trimToSize(maxSize);
        return createdValue;
    }
}
複製程式碼

這裡獲取值的時候對當前的例項進行了加鎖以保證執行緒安全。當用 map 的 get() 方法獲取不到資料的時候用了 create() 方法。因為當指定的鍵值對找不到的時候,可能它本來就不存在,可能是因為快取不足被移除了,所以,我們需要提供這個方法讓使用者來處理這種情況,該方法預設返回 null. 如果使用者覆寫了 create() 方法,並且返回的值不為 null,那麼我們需要將該值插入到雜湊表中。

插入的邏輯也在同步程式碼塊中進行。這是因為,建立的操作可能過長而且是非同步的。當我們再次向指定的 key 插入值的時候,它可能已經存在值了。所以當呼叫 map 的 put() 的時候如果返回不為 null,就表明對應的 key 已經有對應的值了,就需要撤銷插入操作。最後,當 mapValue 非 null,還要呼叫 entryRemoved() 方法。每當一個鍵值對從雜湊表中被移除的時候,這個方法將會被回撥一次。

最後呼叫了 trimToSize() 方法,用來保證新的值被插入之後快取的空間大小不會超過我們指定的值。當發現已經使用的快取超出最大的快取大小的時候,“最近最少使用” 的專案將會被從雜湊表中移除。

那麼如何來判斷哪個是 “最近最少使用” 的專案呢?我們先來看下 trimToSize() 的方法定義:

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(true, key, value, null);
    }
}
複製程式碼

顯然,這裡是使用了 LinkedHashMap 的 eldest() 方法,這個方法的返回值是:

public Map.Entry<K, V> eldest() {
    return head;
}
複製程式碼

也就是 LinkedHashMap 的頭結點。那麼為什麼要移除頭結點呢?這不符合 LRU 的原則啊,這裡分明是直接移除了頭結點。實際上不是這樣,魔力發生在 get() 方法中。在 LruCache 的 get() 方法中,我們呼叫了 LinkedHashMap 的 get() 方法,這個方法中又會在拿到值的時候呼叫下面的方法:

void afterNodeAccess(Node<K,V> e) { // move node to last
    LinkedHashMapEntry<K,V> last;
    if (accessOrder && (last = tail) != e) {
        LinkedHashMapEntry<K,V> p =
            (LinkedHashMapEntry<K,V>)e, b = p.before, a = p.after;
        p.after = null;
        if (b == null)
            head = a;
        else
            b.after = a;
        if (a != null)
            a.before = b;
        else
            last = b;
        if (last == null)
            head = p;
        else {
            p.before = last;
            last.after = p;
        }
        tail = p;
        ++modCount;
    }
}
複製程式碼

這裡的邏輯是把 get() 方法中返回的結點移動到雙向連結串列的末尾。所以,最近最少使用的結點必然就是頭結點了。

3、總結

以上是我們對 LruCache 的是使用和原始碼的總結,這裡我們實際上只分析了 get() 的過程。因為這個方法才是 LruCache 的核心,它包含了插入值和移動最近使用的專案的過程。至於 put()remove() 兩種方法,它們內部實際上直接呼叫了 LinkedHashMap 的方法。這裡我們不再對它們進行分析。不過當使用這個框架的時候,還是要提醒下注意快取的物件的單位問題!


如果您喜歡我的文章,可以在以下平臺關注我:

更多文章:Gihub: Android-notes