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

WngShhng發表於2019-03-02

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 的方法。這裡我們不再對它們進行分析。


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

相關文章