Glide 系列-3:Glide 快取的實現原理(4.8.0)

WngShhng發表於2019-01-08

1、在 Glide 中配置快取的方式

首先,我們可以在自定義的 GlideModule 中制定詳細的快取策略。即在 applyOptions() 中通過直接呼叫 GlideBuilder 的方法來指定快取的資訊:

    @Override
    public void applyOptions(@NonNull Context context, @NonNull GlideBuilder builder) {
        builder.setDiskCache(new InternalCacheDiskCacheFactory(context, DISK_CACHE_DIR, DISK_CACHE_SIZE));
        builder.setMemoryCache(...);
        builder.setDiskCache(...);
        // ... 略
    }
複製程式碼

另外,我們在每個圖片載入請求中自定義當前圖片載入請求的快取策略,

    Glide.with(getContext())
        .load("https://3-im.guokr.com/0lSlGxgGIQkSQVA_Ja0U3Gxo0tPNIxuBCIXElrbkhpEXBAAAagMAAFBO.png")
        .apply(RequestOptions.diskCacheStrategyOf(DiskCacheStrategy.AUTOMATIC))
        .apply(RequestOptions.skipMemoryCacheOf(false))
        .into(getBinding().iv);
複製程式碼

以上是兩個比較常用的快取的配置方式,具體的 API 可以檢視相關的原始碼瞭解.

不論 Glide 還是其他的框架的快取無非就是基於記憶體的快取和基於磁碟的快取兩種,而且快取的管理演算法基本都是 LRU. 針對記憶體快取,Android 中提供了 LruCache,筆者在之前的文章中曾經分析過這個框架:

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

至於磁碟快取, Glide 和 OkHttp 都是基於 DiskLruCache 進行了封裝。這個框架本身的邏輯並不複雜,只是指定了一系列快取檔案的規則,讀者可以自行檢視原始碼學習。本文中涉及上述兩種框架的地方不再詳細追究快取框架的原始碼。

2、Glide 快取的原始碼分析

2.1 快取配置

首先, 我們在 applyOptions() 方法中的配置會在例項化單例的 Glide 物件的時候被呼叫. 所以, 這些方法的作用範圍是全域性的, 對應於整個 Glide. 下面的方法是 RequestBuilderbuild() 方法, 也就是我們最終完成構建 Glide 的地方. 我們可以在這個方法中瞭解 RequestBuilder 為我們提供了哪些與快取相關的方法. 以及預設的快取配置.

  Glide build(@NonNull Context context) {
    // ... 無關程式碼, 略

    if (diskCacheExecutor == null) {
      diskCacheExecutor = GlideExecutor.newDiskCacheExecutor();
    }

    if (memorySizeCalculator == null) {
      memorySizeCalculator = new MemorySizeCalculator.Builder(context).build();
    }

    if (bitmapPool == null) {
      int size = memorySizeCalculator.getBitmapPoolSize();
      if (size > 0) {
        bitmapPool = new LruBitmapPool(size);
      } else {
        bitmapPool = new BitmapPoolAdapter();
      }
    }

    if (arrayPool == null) {
      arrayPool = new LruArrayPool(memorySizeCalculator.getArrayPoolSizeInBytes());
    }

    if (memoryCache == null) { // 預設的快取配置
      memoryCache = new LruResourceCache(memorySizeCalculator.getMemoryCacheSize());
    }

    if (diskCacheFactory == null) {
      diskCacheFactory = new InternalCacheDiskCacheFactory(context);
    }

    if (engine == null) {
      engine = new Engine(/*各種引數*/);
    }

    return new Glide(/*各種方法*/);
  }
複製程式碼

這裡我們對 MemorySizeCalculator 這個引數進行一些說明. 顧名思義, 它是快取大小的計算器, 即用來根據當前裝置的環境計算可用的快取空間 (主要針對的時基於記憶體的快取).

  MemorySizeCalculator(MemorySizeCalculator.Builder builder) {
    this.context = builder.context;

    arrayPoolSize =
        isLowMemoryDevice(builder.activityManager)
            ? builder.arrayPoolSizeBytes / LOW_MEMORY_BYTE_ARRAY_POOL_DIVISOR
            : builder.arrayPoolSizeBytes;
    // 計算APP可申請最大使用記憶體,再乘以乘數因子,記憶體過低時乘以0.33,一般情況乘以0.4
    int maxSize =
        getMaxSize(
            builder.activityManager, builder.maxSizeMultiplier, builder.lowMemoryMaxSizeMultiplier);

    // ARGB_8888 ,每個畫素佔用4個位元組記憶體
    // 計算螢幕這麼大尺寸的圖片佔用記憶體大小
    int screenSize = widthPixels * heightPixels * BYTES_PER_ARGB_8888_PIXEL;
    // 計算目標點陣圖池記憶體大小
    int targetBitmapPoolSize = Math.round(screenSize * builder.bitmapPoolScreens);
    // 計算目標Lrucache記憶體大小,也就是螢幕尺寸圖片大小乘以2
    int targetMemoryCacheSize = Math.round(screenSize * builder.memoryCacheScreens);
    // 最終APP可用記憶體大小
    int availableSize = maxSize - arrayPoolSize;
    if (targetMemoryCacheSize + targetBitmapPoolSize <= availableSize) {
      // 如果目標點陣圖記憶體大小+目標Lurcache記憶體大小小於APP可用記憶體大小,則OK
      memoryCacheSize = targetMemoryCacheSize;
      bitmapPoolSize = targetBitmapPoolSize;
    } else {
      // 否則用APP可用記憶體大小等比分別賦值
      float part = availableSize / (builder.bitmapPoolScreens + builder.memoryCacheScreens);
      memoryCacheSize = Math.round(part * builder.memoryCacheScreens);
      bitmapPoolSize = Math.round(part * builder.bitmapPoolScreens);
    }
  }
複製程式碼

2.2 記憶體快取

對於, 每個載入請求時對應的 DiskCacheStrategy 的設定, 我們之前的文章中已經提到過它的作用位置, 你可以參考之前的文章瞭解,

《Glide 系列-2:主流程原始碼分析(4.8.0)》

DiskCacheStrategy 的作用位置恰好也是 Glide 的快取最初發揮作用的地方, 即 Engine 的 load() 方法. 這裡我們只保留了與快取相關的邏輯, 從下面的方法中也可以看出, 當根據各個引數構建了用於快取的鍵之後先後從兩個快取當中載入資料, 拿到了資料之後就進行回撥, 否則就需要從原始的資料來源中載入資料.

  public <R> LoadStatus load(/*各種引數*/) {
    // 根據請求引數得到快取的鍵
    EngineKey key = keyFactory.buildKey(model, signature, width, height, transformations,
        resourceClass, transcodeClass, options);

    // 檢查記憶體中弱引用是否有目標圖片
    EngineResource<?> active = loadFromActiveResources(key, isMemoryCacheable); // 1
    if (active != null) {
      cb.onResourceReady(active, DataSource.MEMORY_CACHE);
      return null;
    }

    // 檢查記憶體中Lrucache是否有目標圖片
    EngineResource<?> cached = loadFromCache(key, isMemoryCacheable); // 2
    if (cached != null) {
      cb.onResourceReady(cached, DataSource.MEMORY_CACHE);
      return null;
    }

    // ...記憶體中沒有圖片構建任務往下執行, 略

    return new LoadStatus(cb, engineJob);
  }
複製程式碼

這裡存在兩個方法,即 1 處的從弱引用中獲取快取資料,以及 2 處的從記憶體快取中獲取快取資料。它們兩者之間有什麼區別呢?

  1. 弱引用的快取會在記憶體不夠的時候被清理掉,而基於 LruCache 的記憶體快取是強引用的,因此不會因為記憶體的原因被清理掉。LruCache 只有當快取的資料達到了快取空間的上限的時候才會將最近最少使用的快取資料清理出去。
  2. 兩個快取的實現機制都是基於雜湊表的,只是 LruCahce 除了具有雜湊表的資料結構還維護了一個連結串列。而弱引用型別的快取的鍵與 LruCache 一致,但是值是弱引用型別的。
  3. 除了記憶體不夠的時候被釋放,弱引用型別的快取還會在 Engine 的資源被釋放的時候清理掉。
  4. 基於弱引用的快取是一直存在的,無法被使用者禁用,但使用者可以關閉基於 LruCache 的快取。
  5. 本質上基於弱引用的快取與基於 LruCahce 的快取針對於不同的應用場景,弱引用的快取算是快取的一種型別,只是這種快取受可用記憶體的影響要大於 LruCache.

接下來讓我們先看下基於弱引用的快取相關的邏輯,從上面的 1 處的程式碼開始:

  // Engine#loadFromActiveResources
  private EngineResource<?> loadFromActiveResources(Key key, boolean isMemoryCacheable) {
    if (!isMemoryCacheable) {
      return null;
    }
    EngineResource<?> active = activeResources.get(key); // 1
    if (active != null) {
      active.acquire(); // 2
    }
    return active;
  }

  // ActiveResources#get()
  EngineResource<?> get(Key key) {
    ResourceWeakReference activeRef = activeEngineResources.get(key);
    if (activeRef == null) {
      return null;
    }
    EngineResource<?> active = activeRef.get();
    if (active == null) {
      cleanupActiveReference(activeRef); // 3
    }
    return active;
  }

  // ActiveResources#cleanupActiveReference()
  void cleanupActiveReference(@NonNull ResourceWeakReference ref) {
    activeEngineResources.remove(ref.key);
    if (!ref.isCacheable || ref.resource == null) { // 4
      return;
    }
    EngineResource<?> newResource =
        new EngineResource<>(ref.resource, /*isCacheable=*/ true, /*isRecyclable=*/ false);
    newResource.setResourceListener(ref.key, listener);
    listener.onResourceReleased(ref.key, newResource); // 5
  }

  // Engine#onResourceReleased()
  public void onResourceReleased(Key cacheKey, EngineResource<?> resource) {
    Util.assertMainThread();
    activeResources.deactivate(cacheKey);
    if (resource.isCacheable()) {
      cache.put(cacheKey, resource); // 將資料快取到 LruCahce
    } else {
      resourceRecycler.recycle(resource);
    }
  }
複製程式碼

這裡的 1 處會先呼叫 ActiveResources 的 get() 從弱引用中拿資料。當拿到了資料之後呼叫 acquire() 方法將 EngineResource 的引用計數加 1. 當這個資源被釋放的時候,又會將引用計數減 1(參考 EngineResource 的 release() 方法).

當發現了弱引用中引用的 EngineResource 不存在的時候會在 3 處執行一次清理的邏輯。並在 5 處呼叫回撥介面將弱引用中快取的資料快取到 LruCache 裡面。

這裡在將資料快取之前會先在 4 處判斷快取是否可用。這裡使用到了 isCacheable 這個欄位。通過檢視原始碼我們可以追蹤到這個欄位最初傳入的位置是在 RequestOptions 裡面。也就是說,這個欄位是針對一次請求的,我們可以在構建 Glide 請求的時候通過 apply() 設定這個引數的值(這個欄位預設是 true,也就是預設是啟用記憶體快取的)。

  Glide.with(getContext())
    .load("https://3-im.guokr.com/0lSlGxgGIQkSQVA_Ja0U3Gxo0tPNIxuBCIXElrbkhpEXBAAAagMAAFBO.png")
    .apply(RequestOptions.skipMemoryCacheOf(false)) // 不忽略記憶體快取,即啟用
    .into(getBinding().iv);
複製程式碼

2.3 磁碟快取

上面介紹了記憶體快取,下面我們分析一下磁碟快取。

正如我們最初的示例那樣,我們可以通過在構建請求的時候指定快取的策略。我們的圖片載入請求會得到一個 RequestOptions,我們通過檢視該類的程式碼也可以看出,預設的快取策略是 AUTOMATIC 的。

這裡的 AUTOMATIC 定義在 DiskCacheStrategy 中,除了 AUTOMATIC 還有其他幾種快取策略,那麼它們之間又有什麼區別呢?

  1. ALL:既快取原始圖片,也快取轉換過後的圖片;對於遠端圖片,快取 DATARESOURCE;對於本地圖片,只快取 RESOURCE
  2. AUTOMATIC (預設策略):嘗試對本地和遠端圖片使用最佳的策略。當你載入遠端資料(比如,從 URL 下載)時,AUTOMATIC 策略僅會儲存未被你的載入過程修改過 (比如,變換、裁剪等) 的原始資料(DATA),因為下載遠端資料相比調整磁碟上已經存在的資料要昂貴得多。對於本地資料,AUTOMATIC 策略則會僅儲存變換過的縮圖(RESOURCE),因為即使你需要再次生成另一個尺寸或型別的圖片,取回原始資料也很容易。
  3. DATA:只快取未被處理的檔案。我的理解就是我們獲得的 stream。它是不會被展示出來的,需要經過裝載 decode,對圖片進行壓縮和轉換,等等操作,得到最終的圖片才能被展示。
  4. NONE:表示不快取任何內容。
  5. RESOURCE:表示只快取轉換過後的圖片(也就是經過decode,轉化裁剪的圖片)。

那麼這些快取的策略是在哪裡使用到的呢?回顧上一篇文章,首先,我們是在 DecodeJob 的狀態模式中用到了磁碟快取策略:

  private Stage getNextStage(Stage current) {
    switch (current) {
      case INITIALIZE:
        // 是否解碼快取的轉換圖片,就是隻做過變換之後的快取資料
        return diskCacheStrategy.decodeCachedResource() ? Stage.RESOURCE_CACHE : getNextStage(Stage.RESOURCE_CACHE);
      case RESOURCE_CACHE:
        // 是否解碼快取的原始資料,就是指快取的未做過變換的資料
        return diskCacheStrategy.decodeCachedData() ? Stage.DATA_CACHE : getNextStage(Stage.DATA_CACHE);
      case DATA_CACHE:
        return onlyRetrieveFromCache ? Stage.FINISHED : Stage.SOURCE;
      case SOURCE:
      case FINISHED:
        return Stage.FINISHED;
      default:
        throw new IllegalArgumentException("Unrecognized stage: " + current);
    }
  }

  private DataFetcherGenerator getNextGenerator() {
    switch (stage) {
      case RESOURCE_CACHE:
        return new ResourceCacheGenerator(decodeHelper, this);
      case DATA_CACHE:
        return new DataCacheGenerator(decodeHelper, this);
      case SOURCE:
        return new SourceGenerator(decodeHelper, this);
      case FINISHED:
        return null;
      default:
        throw new IllegalStateException("Unrecognized stage: " + stage);
    }
  }
複製程式碼

首先會根據當前所處的階段 current 以及快取策略判斷應該使用哪個 DataFetcherGenerator 載入資料。我們分別來看一下它們:

首先是 ResourceCacheGenerator,它用來從快取中得到變換之後資料。當從快取中拿資料的時候會呼叫到它的 startNext() 方法如下。從下面的方法也可以看出,當從快取中拿資料的時候會先在程式碼 1 處構建一個用於獲取快取資料 key。在構建這個 key 的時候傳入了圖片大小、變換等各種引數,即根據各種變換後的條件獲取快取資料。因此,這個類是用來獲取變換之後的快取資料的。

  public boolean startNext() {
    List<Key> sourceIds = helper.getCacheKeys();
    if (sourceIds.isEmpty()) {
      return false;
    }
    List<Class<?>> resourceClasses = helper.getRegisteredResourceClasses();
    if (resourceClasses.isEmpty()) {
      if (File.class.equals(helper.getTranscodeClass())) {
        return false;
      }
    }
    while (modelLoaders == null || !hasNextModelLoader()) {
      resourceClassIndex++;
      if (resourceClassIndex >= resourceClasses.size()) {
        sourceIdIndex++;
        if (sourceIdIndex >= sourceIds.size()) {
          return false;
        }
        resourceClassIndex = 0;
      }

      Key sourceId = sourceIds.get(sourceIdIndex);
      Class<?> resourceClass = resourceClasses.get(resourceClassIndex);
      Transformation<?> transformation = helper.getTransformation(resourceClass);
      currentKey =
          new ResourceCacheKey( // 1 構建獲取快取資訊的鍵
              helper.getArrayPool(),
              sourceId,
              helper.getSignature(),
              helper.getWidth(),
              helper.getHeight(),
              transformation,
              resourceClass,
              helper.getOptions());
      cacheFile = helper.getDiskCache().get(currentKey); // 2 從快取中獲取快取資訊
      if (cacheFile != null) {
        sourceKey = sourceId;
        modelLoaders = helper.getModelLoaders(cacheFile);
        modelLoaderIndex = 0;
      }
    }

    loadData = null;
    boolean started = false;
    while (!started && hasNextModelLoader()) {
      ModelLoader<File, ?> modelLoader = modelLoaders.get(modelLoaderIndex++); // 3 使用檔案方式從快取中讀取快取資料
      loadData = modelLoader.buildLoadData(cacheFile,
          helper.getWidth(), helper.getHeight(), helper.getOptions());
      if (loadData != null && helper.hasLoadPath(loadData.fetcher.getDataClass())) {
        started = true;
        loadData.fetcher.loadData(helper.getPriority(), this);
      }
    }

    return started;
  }
複製程式碼

當找到了快取的值之後會使用 File 型別的 ModelLoader 載入資料。這個比較容易理解,因為資料存在磁碟上面,需要用檔案的方式開啟。

另外,我們再關注下 2 處的程式碼,它會使用 helpergetDiskCache() 方法獲取 DiskCache 物件。我們一直追蹤這個物件就會找到一個名為 DiskLruCacheWrapper 的類,它內部包裝了 DiskLruCache。所以,最終從磁碟載入資料是使用 DiskLruCache 來實現的。對於最終使用 DiskLruCache 獲取資料的邏輯我們不進行說明了,它的邏輯並不複雜,都是單純的檔案讀寫,只是設計了一套快取的規則。

上面是從磁碟讀取資料的,那麼資料又是在哪裡向磁碟快取資料的呢?

在之前的文章中我們也分析過這部分內容,即當從網路中開啟輸入流之後會回到 DecodeJob 中,進入下一個階段,並再次呼叫 SourceGeneratorstartNext() 方法。此時會進入到 cacheData() 方法,並將資料快取到磁碟上:

  private void cacheData(Object dataToCache) {
    long startTime = LogTime.getLogTime();
    try {
      Encoder<Object> encoder = helper.getSourceEncoder(dataToCache);
      DataCacheWriter<Object> writer =
          new DataCacheWriter<>(encoder, dataToCache, helper.getOptions());
      originalKey = new DataCacheKey(loadData.sourceKey, helper.getSignature());
      helper.getDiskCache().put(originalKey, writer); // 將資料快取到磁碟上面
    } finally {
      loadData.fetcher.cleanup();
    }

    sourceCacheGenerator =
        new DataCacheGenerator(Collections.singletonList(loadData.sourceKey), helper, this);
  }
複製程式碼

然後構建一個 DataCacheGenerator 再從磁碟上面讀取出快取的資料,顯示到控制元件上面。

還有一個問題,從上文中我們也可以看出 Glide 在進行快取的時候可以快取轉換之後的資料,也可以快取原始的資料。我們可以通過構建的用於獲取快取的鍵看出這一點:在 ResourceCacheGenerator 中獲取轉換之後的快取資料的時候,我們使用 ResourceCacheKey 並傳入了各種引數構建了快取的鍵;在將資料儲存到磁碟上面的時候我們使用的是 DataCacheKey,並且沒有傳入那麼多引數。這說明獲取的和儲存的並不是同一份資料,那麼轉換之後的資料是在哪裡快取的呢?

我們通過查詢類 ResourceCacheKey 將位置定位在了 DecodeJobonResourceDecoded() 方法中:

  <Z> Resource<Z> onResourceDecoded(DataSource dataSource, Resource<Z> decoded) {
    // ... 略

    Resource<Z> result = transformed;
    boolean isFromAlternateCacheKey = !decodeHelper.isSourceKey(currentSourceKey);
    if (diskCacheStrategy.isResourceCacheable(isFromAlternateCacheKey, dataSource,
        encodeStrategy)) {
      if (encoder == null) {
        throw new Registry.NoResultEncoderAvailableException(transformed.get().getClass());
      }
      final Key key;
      // 根據快取的此略使用不同的快取的鍵
      switch (encodeStrategy) {
        case SOURCE:
          key = new DataCacheKey(currentSourceKey, signature);
          break;
        case TRANSFORMED:
          key =
              new ResourceCacheKey(
                  decodeHelper.getArrayPool(),
                  currentSourceKey,
                  signature,
                  width,
                  height,
                  appliedTransformation,
                  resourceSubClass,
                  options);
          break;
        default:
          throw new IllegalArgumentException("Unknown strategy: " + encodeStrategy);
      }

      LockedResource<Z> lockedResult = LockedResource.obtain(transformed);
      // 將快取的鍵和資料資訊設定到 deferredEncodeManager 中,隨後會將其快取到磁碟上面
      deferredEncodeManager.init(key, encoder, lockedResult);
      result = lockedResult;
    }
    return result;
  }
複製程式碼

顯然,這裡會根據快取的策略構建兩種不同的 key,並將其傳入到 deferredEncodeManager 中。然後將會在 DecodeJobnotifyEncodeAndRelease() 方法中呼叫 deferredEncodeManagerencode() 方法將資料快取到磁碟上:

    void encode(DiskCacheProvider diskCacheProvider, Options options) {
      try {
        // 將資料快取到磁碟上面
        diskCacheProvider.getDiskCache().put(key,
            new DataCacheWriter<>(encoder, toEncode, options));
      } finally {
        toEncode.unlock();
      }
    }
複製程式碼

以上就是 Glide 的磁碟快取的實現原理。

3、總結

在這篇文中我們在之前的兩篇文章的基礎之上分析了 Glide 的快取的實現原理。

首先 Glide 存在兩種記憶體快取,一個基於弱引用的,一個是基於 LruCache 的。兩者存在一些不同,在文中我們已經總結了這部分內容。

然後,我們分析了 Glide 的磁碟快取的實現原理。Glide 的磁碟快取使用了策略模式,存在 4 種既定的快取策略。Glide 不僅可以原始的資料快取到磁碟上面,還可以將做了轉換之後的資料快取到磁碟上面。它們會基於自身的快取方式構建不同的 key 然後底層使用 DiskLruCache 從磁碟種獲取資料。這部分的核心程式碼在 DecodeJob 和三個 DataFetcherGenerator 中。

以上就是 Glide 快取的所有實現原理。

Glide 系列文章:

  1. Glide 系列-1:預熱、Glide 的常用配置方式及其原理
  2. Glide 系列-2:主流程原始碼分析(4.8.0)
  3. Glide 系列-3:Glide 快取的實現原理(4.8.0)

如果你喜歡這篇文章,請點贊哦!你也可以在以下平臺關注我哦:

所有的文章維護在:Gihub: Android-notes

相關文章