Android-Universal-Image-Loader快取處理機制分析

部落格園發表於2014-09-10

講到快取,平時流水線上的碼農一定覺得這是一個高大上的東西。看過網上各種講快取原理的文章,總感覺那些文章講的就是玩具,能用嗎?這次我將帶你一起看過UIL這個國內外大牛都追捧的圖片快取類庫的快取處理機制。看了UIL中的快取實現,才發現其實這個東西不難,沒有太多的程式排程,沒有各種記憶體讀取控制機制、沒有各種異常處理。反正UIL中不單程式碼寫的簡單,連處理都簡單。但是這個類庫這麼好用,又有這麼多人用,那麼非常有必要看看他是怎麼實現的。先了解UIL中快取流程的原理圖。

原理示意圖

主體有三個,分別是UI,快取模組和資料來源(網路)。它們之間的關係如下:

① UI:請求資料,使用唯一的Key值索引Memory Cache中的Bitmap。

 記憶體快取:快取搜尋,如果能找到Key值對應的Bitmap,則返回資料。否則執行第三步。

 硬碟儲存:使用唯一Key值對應的檔名,檢索SDCard上的檔案。

 如果有對應檔案,使用BitmapFactory.decode*方法,解碼Bitmap並返回資料,同時將資料寫入快取。如果沒有對應檔案,執行第五步。

 下載圖片:啟動非同步執行緒,從資料來源下載資料(Web)。

⑥ 若下載成功,將資料同時寫入硬碟和快取,並將Bitmap顯示在UI中。

接下來,我們回顧一下UIL中快取的配置(具體的見《UNIVERSAL IMAGE LOADER.PART 2》)。重點關注註釋部分,我們可以根據自己需要配置記憶體、磁碟快取的實現。

File cacheDir = StorageUtils.getCacheDirectory(context,
"UniversalImageLoader/Cache");

ImageLoaderConfiguration config = new
ImageLoaderConfiguration .Builder(getApplicationContext())
.maxImageWidthForMemoryCache(800)
.maxImageHeightForMemoryCache(480)
.httpConnectTimeout(5000)
.httpReadTimeout(20000)
.threadPoolSize(5)
.threadPriority(Thread.MIN_PRIORITY + 3)
.denyCacheImageMultipleSizesInMemory()
.memoryCache(new UsingFreqLimitedCache(2000000)) // 你可以傳入自己的記憶體快取
.discCache(new UnlimitedDiscCache(cacheDir)) // 你可以傳入自己的磁碟快取
.defaultDisplayImageOptions(DisplayImageOptions.createSimple())
.build();

UIL中的記憶體快取策略

1. 只使用的是強引用快取

  • LruMemoryCache(這個類就是這個開源框架預設的記憶體快取類,快取的是bitmap的強引用,下面我會從原始碼上面分析這個類)

2.使用強引用和弱引用相結合的快取有

UsingFreqLimitedMemoryCache(如果快取的圖片總量超過限定值,先刪除使用頻率最小的bitmap)

  • LRULimitedMemoryCache(這個也是使用的lru演算法,和LruMemoryCache不同的是,他快取的是bitmap的弱引用)
  • FIFOLimitedMemoryCache(先進先出的快取策略,當超過設定值,先刪除最先加入快取的bitmap)
  • LargestLimitedMemoryCache(當超過快取限定值,先刪除最大的bitmap物件)
  • LimitedAgeMemoryCache(當 bitmap加入快取中的時間超過我們設定的值,將其刪除)

3.只使用弱引用快取

WeakMemoryCache(這個類快取bitmap的總大小沒有限制,唯一不足的地方就是不穩定,快取的圖片容易被回收掉)

 

我們直接選擇UIL中的預設配置快取策略進行分析。

ImageLoaderConfiguration config = ImageLoaderConfiguration.createDefault(context);

ImageLoaderConfiguration.createDefault(…)這個方法最後是呼叫Builder.build()方法建立預設的配置引數的。預設的記憶體快取實現是LruMemoryCache,磁碟快取是UnlimitedDiscCache。

LruMemoryCache解析

LruMemoryCache:一種使用強引用來儲存有數量限制的Bitmap的cache(在空間有限的情況,保留最近使用過的Bitmap)。每次Bitmap被訪問時,它就被移動到一個佇列的頭部。當Bitmap被新增到一個空間已滿的cache時,在佇列末尾的Bitmap會被擠出去並變成適合被GC回收的狀態。
注意:這個cache只使用強引用來儲存Bitmap。

LruMemoryCache實現MemoryCache,而MemoryCache繼承自MemoryCacheAware。

public interface MemoryCache extends MemoryCacheAware<String, Bitmap>

下面給出繼承關係圖

Android-Universal-Image-Loader.LruMemoryCache

LruMemoryCache.get(…)

我相信接下去你看到這段程式碼的時候會跟我一樣驚訝於程式碼的簡單,程式碼中除了異常判斷,就是利用synchronized進行同步控制。

/**
     * Returns the Bitmap for {@code key} if it exists in the cache. If a Bitmap was returned, it is moved to the head
     * of the queue. This returns null if a Bitmap is not cached.
     */
    @Override
    public final Bitmap get(String key) {
        if (key == null) {
            throw new NullPointerException("key == null");
        }

        synchronized (this) {
            return map.get(key);
        }
    }

我們會好奇,這不是就簡簡單單將Bitmap從map中取出來嗎?但LruMemoryCache聲稱保留在空間有限的情況下保留最近使用過的Bitmap。不急,讓我們細細觀察一下map。他是一個LinkedHashMap<String, Bitmap>型的物件。

LinkedHashMap中的get()方法不僅返回所匹配的值,並且在返回前還會將所匹配的key對應的entry調整在列表中的順序(LinkedHashMap使用雙連結串列來儲存資料),讓它處於列表的最後。當然,這種情況必須是在LinkedHashMap中accessOrder==true的情況下才生效的,反之就是get()方法不會改變被匹配的key對應的entry在列表中的位置。

@Override public V get(Object key) {
 2         /*
 3          * This method is overridden to eliminate the need for a polymorphic
 4          * invocation in superclass at the expense of code duplication.
 5          */
 6         if (key == null) {
 7             HashMapEntry<K, V> e = entryForNullKey;
 8             if (e == null)
 9                 return null;
10             if (accessOrder)
11                 makeTail((LinkedEntry<K, V>) e);
12             return e.value;
13         }
14 
15         // Replace with Collections.secondaryHash when the VM is fast enough (http://b/8290590).
16         int hash = secondaryHash(key);
17         HashMapEntry<K, V>[] tab = table;
18         for (HashMapEntry<K, V> e = tab[hash & (tab.length - 1)];
19                 e != null; e = e.next) {
20             K eKey = e.key;
21             if (eKey == key || (e.hash == hash && key.equals(eKey))) {
22                 if (accessOrder)
23                     makeTail((LinkedEntry<K, V>) e);
24                 return e.value;
25             }
26         }
27         return null;
28     }

程式碼第11行的makeTail()就是調整entry在列表中的位置,其實就是雙向連結串列的調整。它判斷accessOrder

。到現在我們就清楚LruMemoryCache使用LinkedHashMap來快取資料,在LinkedHashMap.get()方法執行後,LinkedHashMap中entry的順序會得到調整。那麼我們怎麼保證最近使用的項不會被剔除呢?接下去,讓我們看看LruMemoryCache.put(…)。

LruMemoryCache.put(…)

注意到程式碼第8行中的size+= sizeOf(key, value),這個size是什麼呢?我們注意到在第19行有一個trimToSize(maxSize),trimToSize(…)這個函式就是用來限定LruMemoryCache的大小不要超過使用者限定的大小,cache的大小由使用者在LruMemoryCache剛開始初始化的時候限定。

@Override
 2     public final boolean put(String key, Bitmap value) {
 3         if (key == null || value == null) {
 4             throw new NullPointerException("key == null || value == null");
 5         }
 6 
 7         synchronized (this) {
 8             size += sizeOf(key, value);
 9             //map.put()的返回值如果不為空,說明存在跟key對應的entry,put操作只是更新原有key對應的entry
10             Bitmap previous = map.put(key, value);
11             if (previous != null) {
12                 size -= sizeOf(key, previous);
13             }
14         }
15 
16         trimToSize(maxSize);
17         return true;
18     }

其實不難想到,當Bitmap快取的大小超過原來設定的maxSize時應該是在trimToSize(…)這個函式中做到的。這個函式做的事情也簡單,遍歷map,將多餘的項(程式碼中對應toEvict)剔除掉,直到當前cache的大小等於或小於限定的大小。

private void trimToSize(int maxSize) {
 2         while (true) {
 3             String key;
 4             Bitmap value;
 5             synchronized (this) {
 6                 if (size < 0 || (map.isEmpty() && size != 0)) {
 7                     throw new IllegalStateException(getClass().getName() + ".sizeOf() is reporting inconsistent results!");
 8                 }
 9 
10                 if (size <= maxSize || map.isEmpty()) {
11                     break;
12                 }
13 
14                 Map.Entry<String, Bitmap> toEvict = map.entrySet().iterator().next();
15                 if (toEvict == null) {
16                     break;
17                 }
18                 key = toEvict.getKey();
19                 value = toEvict.getValue();
20                 map.remove(key);
21                 size -= sizeOf(key, value);
22             }
23         }
24     }

這時候我們會有一個以為,為什麼遍歷一下就可以將使用最少的bitmap快取給剔除,不會誤刪到最近使用的bitmap快取嗎?首先,我們要清楚,LruMemoryCache定義的最近使用是指最近用get或put方式操作到的bitmap快取。其次,之前我們直到LruMemoryCache的get操作其實是通過其內部欄位LinkedHashMap.get(…)實現的,當LinkedHashMap的accessOrder==true時,每一次get或put操作都會將所操作項(圖中第3項)移動到連結串列的尾部(見下圖,連結串列頭被認為是最少使用的,連結串列尾被認為是最常使用的。),每一次操作到的項我們都認為它是最近使用過的,當記憶體不夠的時候被剔除的優先順序最低。需要注意的是一開始的LinkedHashMap連結串列是按插入的順序構成的,也就是第一個插入的項就在連結串列頭,最後一個插入的就在連結串列尾。假設只要剔除圖中的1,2項就能讓LruMemoryCache小於原先限定的大小,那麼我們只要從連結串列頭遍歷下去(從1→最後一項)那麼就可以剔除使用最少的項了。

           

至此,我們就知道了LruMemoryCache快取的整個原理,包括他怎麼put、get、剔除一個元素的的策略。接下去,我們要開始分析預設的磁碟快取策略了。

UIL中的磁碟快取策略

像新浪微博、花瓣這種應用需要載入很多圖片,本來圖片的載入就慢了,如果下次開啟的時候還需要再一次下載上次已經有過的圖片,相信使用者的流量會讓他們的叫罵聲很響亮。對於圖片很多的應用,一個好的磁碟快取直接決定了應用在使用者手機的留存時間。我們自己實現磁碟快取,要考慮的太多,幸好UIL提供了幾種常見的磁碟快取策略,當然如果你覺得都不符合你的要求,你也可以自己去擴充套件

  • FileCountLimitedDiscCache(可以設定快取圖片的個數,當超過設定值,刪除掉最先加入到硬碟的檔案)
  • LimitedAgeDiscCache(設定檔案存活的最長時間,當超過這個值,就刪除該檔案)
  • TotalSizeLimitedDiscCache(設定快取bitmap的最大值,當超過這個值,刪除最先加入到硬碟的檔案)
  • UnlimitedDiscCache(這個快取類沒有任何的限制)

在UIL中有著比較完整的儲存策略,根據預先指定的空間大小,使用頻率(生命週期),檔案個數的約束條件,都有著對應的實現策略。最基礎的介面DiscCacheAware和抽象類BaseDiscCache

UnlimitedDiscCache解析

UnlimitedDiscCache實現disk cache介面,是ImageLoaderConfiguration中預設的磁碟快取處理。用它的時候,磁碟快取的大小是不受限的。

接下來我們來看看實現UnlimitedDiscCache的原始碼,通過原始碼我們發現他其實就是繼承了BaseDiscCache,這個類內部沒有實現自己獨特的方法,也沒有重寫什麼,那麼我們就直接看BaseDiscCache這個類。在分析這個類之前,我們先想想自己實現一個磁碟快取需要做多少麻煩的事情:

1、圖片的命名會不會重。你沒有辦法知道使用者下載的圖片原始的檔名是怎麼樣的,因此很可能因為檔案重名將有用的圖片給覆蓋掉了。

2、當應用卡頓或網路延遲的時候,同一張圖片反覆被下載。

3、處理圖片寫入磁碟可能遇到的延遲和同步問題。

BaseDiscCache建構函式

首先,我們看一下BaseDiscCache的建構函式:

cacheDir:檔案快取目錄
reserveCacheDir:備用的檔案快取目錄,可以為null。它只有當cacheDir不能用的時候才有用。
fileNameGenerator:檔名生成器。為快取的檔案生成檔名。

public BaseDiscCache(File cacheDir, File reserveCacheDir, FileNameGenerator fileNameGenerator) {
        if (cacheDir == null) {
            throw new IllegalArgumentException("cacheDir" + ERROR_ARG_NULL);
        }
        if (fileNameGenerator == null) {
            throw new IllegalArgumentException("fileNameGenerator" + ERROR_ARG_NULL);
        }

        this.cacheDir = cacheDir;
        this.reserveCacheDir = reserveCacheDir;
        this.fileNameGenerator = fileNameGenerator;
    }

我們可以看到一個fileNameGenerator,接下來我們來了解UIL具體是怎麼生成不重複的檔名的。UIL中有3種檔案命名策略,這裡我們只對預設的檔名策略進行分析。預設的檔案命名策略在DefaultConfigurationFactory.createFileNameGenerator()。它是一個HashCodeFileNameGenerator。真的是你意想不到的簡單,就是運用String.hashCode()進行檔名的生成。

public class HashCodeFileNameGenerator implements FileNameGenerator {
    @Override
    public String generate(String imageUri) {
        return String.valueOf(imageUri.hashCode());
    }
}

BaseDiscCache.save()

分析完了命名策略,再看一下BaseDiscCache.save(…)方法。注意到第2行有一個getFile()函式,它主要用於生成一個指向快取目錄中的檔案,在這個函式裡面呼叫了剛剛介紹過的fileNameGenerator來生成檔名。注意第3行的tmpFile,它是用來寫入bitmap的臨時檔案(見第8行),然後就把這個檔案給刪除了。大家可能會困惑,為什麼在save()函式裡面沒有判斷要寫入的bitmap檔案是否存在的判斷,我們不由得要看看UIL中是否有對它進行判斷。還記得我們在《從程式碼分析Android-Universal-Image-Loader的圖片載入、顯示流程》介紹的,UIL載入圖片的一般流程是先判斷記憶體中是否有對應的Bitmap,再判斷磁碟(disk)中是否有,如果沒有就從網路中載入。最後根據原先在UIL中的配置判斷是否需要快取Bitmap到記憶體或磁碟中。也就是說,當需要呼叫BaseDiscCache.save(…)之前,其實已經判斷過這個檔案不在磁碟中。

public boolean save(String imageUri, InputStream imageStream, IoUtils.CopyListener listener) throws IOException {
 2         File imageFile = getFile(imageUri);
 3         File tmpFile = new File(imageFile.getAbsolutePath() + TEMP_IMAGE_POSTFIX);
 4         boolean loaded = false;
 5         try {
 6             OutputStream os = new BufferedOutputStream(new FileOutputStream(tmpFile), bufferSize);
 7             try {
 8                 loaded = IoUtils.copyStream(imageStream, os, listener, bufferSize);
 9             } finally {
10                 IoUtils.closeSilently(os);
11             }
12         } finally {
13             IoUtils.closeSilently(imageStream);
14             if (loaded && !tmpFile.renameTo(imageFile)) {
15                 loaded = false;
16             }
17             if (!loaded) {
18                 tmpFile.delete();
19             }
20         }
21         return loaded;
22     }

BaseDiscCache.get()

BaseDiscCache.get()方法內部呼叫了BaseDiscCache.getFile(…)方法,讓我們來分析一下這個在之前碰過的函式。 第2行就是利用fileNameGenerator生成一個唯一的檔名。第3~8行是指定快取目錄,這時候你就可以清楚地看到cacheDir和reserveCacheDir之間的關係了,當cacheDir不可用的時候,就是用reserveCachedir作為快取目錄了。

最後返回一個指向檔案的物件,但是要注意當File型別的物件指向的檔案不存在時,file會為null,而不是報錯。

protected File getFile(String imageUri) {
 2         String fileName = fileNameGenerator.generate(imageUri);
 3         File dir = cacheDir;
 4         if (!cacheDir.exists() && !cacheDir.mkdirs()) {
 5             if (reserveCacheDir != null && (reserveCacheDir.exists() || reserveCacheDir.mkdirs())) {
 6                 dir = reserveCacheDir;
 7             }
 8         }
 9         return new File(dir, fileName);
10     }

總結

現在,我們已經分析了UIL的快取機制。其實從UIL的快取機制的實現並不是很複雜,雖然有各種快取機制,但是簡單地說:記憶體快取其實就是利用Map介面的物件在記憶體中進行快取,可能有不同的儲存機制。磁碟快取其實就是將檔案寫入磁碟

相關文章