收攏圖片,可以優化內存避免 OOM,但是收攏不是說說而已!(以Glide舉例)

題圖 by @rayyu

一. 序

圖片一直是 App 中吃內存的大戶,當我們做內存優化的時候,永遠也繞不開對圖片內存的優化。可能你很多其他方案一起上,最後還不如對 Bitmap 進行常規優化來的有效。

對圖片的優化前提是對圖片操作的收攏,這樣我們纔可以做整體的策略控制。例如對於一些低端設備,我們可以將圖片格式從 ARGB_8888 變爲 RGB_565,這樣一個簡單的調整,可以讓圖片內存的佔用減少一半;又例如在適當的時機,主動回收掉一些圖片緩存,避免被 Low Memory Kiiler 盯上。

但是這一切的前提,就是我們要收攏對圖片的操作。通常我們會使用一些開源的圖片庫,來簡化對圖片的操作,例如 Glide、Fresco 或者其他一些自研的圖片加載庫。

我們當然不會在一個項目中重複集成多個圖片加載庫,但是很多時候我們會忽略掉一些 Android 下原生操作 Bitmap 的 API,例如 Bitmap.createBitmap()、BitmapFactory 等。

這些系統提供的 API,也是我們收攏圖片操作時需要注意的,否者必然有一些圖片不是受約束的。那麼接下來,我們就以最常用的 Glide 來舉例,看看如何替換掉 Bitmap.createBitmap() 和 BitmapFactory 的相關操作,來收緊對這些 API 的操作。

ps:本文內容,以 Glide v4.11.0 舉例。

二. 收攏哪些 Bitmap 操作

2.1 替換 createBitmap()

Bitmap.createBitmap() 方法,從名字上就可以看出,它是爲了創建一個 Bitmap 對象,這在我們做一些圖片變換繪製時,經常會用到。而想要利用 Glide 的來優化此步驟,就需要用到 BitmapPool。

BitmapPool 本身是一個接口,我們通常會使用到它的實現類 LruBitmapPool,從名稱就可以看出,它基於 LRU 的規則,在一定的內存限制下,緩存和管理一些可供重用的 Bitmap 對象。

接下來我們看看具體的使用。

1. 使用 BitmapPool

在 Glide 中,BitmapPool 滲透到邏輯代碼的方方面面。我們想要拿到 BitmapPool 對象也非常的簡單,只需要使用 getBitmapPool() 方法即可。

既然是一個池化的方案,那麼肯定會有對應的 get()put() 方法。

val bitmapPool = Glide.get(this).bitmapPool
val bitmap = bitmapPool.get(100,100,Bitmap.Config.ARGB_8888)
// 處理 → 使用 bitmap
// ......

// 用完回收 bitmap
bitmapPool.put(bitmap)

沒什麼特殊的操作,只是將 Bitmap.createBitmap() 方法,替換成 bitmapPool.get() 方法,在使用完成後,再調用 put() 方法回收圖片。

2. bitmapPool.get() 都做了什麼?

爲什麼使用 bitmapPool.get() 替換掉 createBitmap() 就可以達到對圖片內存的優化呢?

要知道所有的池化技術,都是基於享元模式,將一些比較重要的資源,最大限度的進行緩存,並以期待下一次的使用時可以直接複用。

所以實際上,bitmapPool.get() 並沒有那麼神奇的,它只是先從緩存池中找是否有對應可用的 Bitmap 資源,有就重用,沒有時依然需要調用 Bitmap.createBitmap() 去創建一個圖片。

// LruBitmapPool.java
public Bitmap get(int width, int height, Bitmap.Config config) {
  Bitmap result = getDirtyOrNull(width, height, config);
  if (result != null) {
    // 擦除"髒像素"
    result.eraseColor(Color.TRANSPARENT);
  } else {
    // 通過 Bitmap.createBitmap() 創建圖片
    result = createBitmap(width, height, config);
  }
  return result;
}

這裏會先嚐試通過 getDirtyOrNull() 獲取緩存池的 Bitmap 資源,如果沒有可用的資源,依然是調用 createBitmap() 去構造一個新的 Bitmap 對象。

如果 getDirtyOrNull() 找到了可複用的 Bitmap 資源,則會調用 eraseColor() 方法,將 Bitmap 的"髒像素"進行擦除,以避免舊圖對新圖的影響。

3. BitmapPool 如何緩存 Bitmap?

一個圖片資源,加載到內存中之後,其實是包含兩部分內存佔用的,一個是 Bitmap 對象引用,還有一部分是圖片的像素數據,在 Android 不同版本的迭代過程中,圖片的像素數據存放的位置是挪了又挪。

但是不管像素數據最終放在哪裏,其實佔用內存的大頭一直的都是圖片的像素數據,而像素數據佔用的空間,又受到圖片資源的像素尺寸以及單像素的佔用的內存尺寸。

例如一個 ARGB_8888 的圖片,它像素數據佔用內存的計算方式:

BitmapRam = BitmapWidth * BitmapHeight * 4 bytes

其中 4 Bytes 就是 ARGB_8888 單像素佔用的內存。

在 BitmapPool 中,也是基於這 3 個條件來唯一定位一個可用的圖片資源,反映到代碼中,就是圖片的 width、height 以及 Bitmap.Config。

在 BitmapPool 中有一個 strategy 對象,它是一個 LruPoolStrategy 類型,這是一個接口,我們通常會用到它的實現類 SizeConfigStrategy。

// LruBitmapPool.java
private synchronized Bitmap getDirtyOrNull(
  int width, int height, @Nullable Bitmap.Config config) {
  final Bitmap result = strategy.get(width, height, config != null ? config : DEFAULT_CONFIG);
  // ...
  return result;
}

繼續看看 SizeConfigStrategy,在其中維護了一個 groupedMap 結構,它的類型是 GroupedLinkedMap,我們可以將它簡單的理解爲一個 Key-Value 的鍵值對,同時它也實現了 LRU 算法。

// GroupedLinkedMap.java
public Bitmap get(int width, int height, Bitmap.Config config) {
  int size = Util.getBitmapByteSize(width, height, config);
  Key bestKey = findBestKey(size, config);

  Bitmap result = groupedMap.get(bestKey);
  // ...
  return result;
}

這裏的 get() 方式,就是通過 width、height、config 找到一個對應的 Key,再從 groupMap 中基於此 Key 獲取到緩存池中的圖片。

4. BitmapPool 如何回收資源

在 Bitmap 使用完之後,我們還需要將其進行回收,回收資源就是調用 LruBitmapPool 的 put() 方法。

public synchronized void put(Bitmap bitmap) {
    // 驗證 Bitmap 有效性代碼,省略
  if (!bitmap.isMutable() || strategy.getSize(bitmap) > maxSize
      || !allowedConfigs.contains(bitmap.getConfig())) {
    // 不符合緩存條件,直接 recycle()
    bitmap.recycle();
    return;
  }

  final int size = strategy.getSize(bitmap);
  strategy.put(bitmap);
  // Other code ...
}

put() 方法中,會對待回收的 Bitmap 做一個基本的校驗,例如是一個可變的 Bitmap;尺寸必須不能大於 maxSize 等。如果條件不滿足,直接將圖片回收(recycle)。

滿足這些前置條件之後,會將其放入 strategy 進行緩存,這就是前面 get() 方法從緩存池中獲取圖片操作的數據結構,就不再贅述了。

5. 查缺補漏

前面也提到 BitmapPool 並沒有什麼神奇的,如果資源池中沒有需要的 Bitmap,它依然會通過 createBitmap() 構造一個新的 Bitmap 對象。

但是在 Glide 的整個邏輯中,大量的使用到了 BitmapPool,所以可能你需要的 Bitmap 對象,之前被其他邏輯使用並回收。例如在 Glide 的 BitmapResource 中,recycle() 回收的邏輯,就是直接將圖片嘗試放入 BitmapPool 中。

// BitmapResource.java
public class BitmapResource implements Resource<Bitmap>,
    Initializable {
  @Override
  public void recycle() {
    bitmapPool.put(bitmap);
  }
}

退一步說,就算我們使用的 Bitmap 不在資源池中,我們只需要使用後,通過 put() 方法將其回收到資源池中,下次依然可以複用。

圖片是一個佔內存的大頭,頻繁的構造小尺寸 Bitmap,多數情況下是不會直接造成 OOM,但是可能會造成頻繁的 GC,表現出來就是內存的抖動,這在 Dalvik 虛擬機上尤其明顯。雖然在 ART 虛擬機上,對 GC 已經做了一些優化,但是資源的複用依然是一種提高效率的手段。

同時 BitmapPool 本身也會根據 onTrimMemory() 回調,來處理緩存的 Bitmap 的清理邏輯,這無需我們開發者再關心其回收的規則。

public void trimMemory(int level) {
  if (Log.isLoggable(TAG, Log.DEBUG)) {
    Log.d(TAG, "trimMemory, level=" + level);
  }
  if (level >= android.content.ComponentCallbacks2.TRIM_MEMORY_BACKGROUND) {
    clearMemory();
  } else if (level >= android.content.ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN
             || level == android.content.ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL) {
    trimToSize(getMaxSize() / 2);
  }
}

另外,對 Bitmap 資源需要謹慎回收,一定要確保這個圖片資源不被使用,再將其進行回收,否則會出現一些異常情況

其實很好理解,BitmapPool 的 put() 回收資源時,可能兩種操作,bitmap.recycle() 或者將其放入 BitmapPool 以待後續使用。

那麼如果一個外部的 View 還在使用的 Bitmap 被 BitmapPool 回收,可能會出現 Cannot draw a recycled Bitmap   錯誤;還有個場景是 BitmapPool 持有了一個外部被回收的 Bitmap 後,下次使用時,會出現 Can't call reconfigure() on a recycled bitmap 錯誤。

這些都是直接的錯誤,如果圖片沒有被回收(recycle),而是被重用了,也可能會導致外部某個 View 展示的圖像,被刷新了,這雖然不會直接拋異常,但是依然是一個邏輯錯誤。

所以謹記,在通過 BitmapPool 回收圖片資源時,一定要確保外部沒有使用此 Bitmap 的地方,最好立即切斷其引用,避免不必要的錯誤。

2.2 替換 BitmapFactory

說完 Bitmap.createBitmap() 再就是到了 BitmapFactory 了,它的替換比較簡單。

BitmapFactory 最主要的功能,就是利用 decodeXxx() 方法,通過不同的源來加載 Bitmap 資源。

而在 Glide 中,我們使用最多的就是從某個源中加載圖片,並直接顯示在 ImageView 上。

Glide.with(fragment)
    .load(url)
    .into(imageView);

如果想通過 Glide 直接加載圖片,並獲得 Bitmap 對象,需要用到 asXxx() 的方法和 Target,我們接下來就來看看,從不同的源加載 Bitmap 的情況,以及同步和異步的區別。

1. 同步加載 Bitmap 對象

有時我們需要在子線程中獲取 Bitmap 對象,就需要同步獲取的方式。

val bitmap = Glide.with(activity)
    .asBitmap()
    .load(imageUrl)
    .submit().get()

藉助 asBitmap()submit().get() 就可以從某個源中,直接獲得 Bitmap 對象。

submit() 還可以約束加載圖片的尺寸,方便我們處理。

FutureTarget<TranscodeType> submit()
FutureTarget<TranscodeType> submit(int width, int height)

2. 異步加載 Bitmap 對象

Glide 也支持異步加載 Bitmap,異步加載,就涉及到線程的切換問題。

Glide.with(activity)
    .asBitmap()
    .load(imageUrl)
    .into(object:CustomTarget<Bitmap>(){
      override fun onLoadCleared(placeholder: Drawable?) {
      }

      override fun onResourceReady(resource: Bitmap, 
                                   transition: Transition<in Bitmap>?) {
        val loadBitmap = resource
      }
    })

異步加載,需要用到 Target,這裏直接使用 Glide 提供的 CustomTarget。

3. 加載圖片的 File

我們知道用 Glide 加載的圖片,在緩存容量允許的範圍內,Glide 都會幫我們將圖片文件緩存到本地磁盤。

那麼我們如何通過 Glide 加載一個圖片資源,然後獲得緩存的圖片文件呢?

其實只需要將上面的 asBitmap() 換成 asFile() 即可。

// sync
val bitmapFile = Glide.with(this)
    .asFile()
    .load(imageUrl)
    .submit().get()

// async
Glide.with(this)
    .asFile()
    .load(imageUrl)
    .into(object:CustomTarget<File>(){
      override fun onLoadCleared(placeholder: Drawable?) {
      }

      override fun onResourceReady(resource: File, transition: 
                                   Transition<in File>?) {
          val bitmapFile = resource
      }
    })

除了 asBitmap()asFile(),還有一些其他的方法,例如 asDrawable() 等,有興趣可以自行了解。

4. 查缺補漏

Glide 的 load() 方法,本身就支持很多圖片資源的加載,我們只需要使用標準的 API 即可。相比於 BitmapFacory 的源來說,還有 InputStream 這個是 Glide 沒有支持的。

這也很好理解,既然是一個 Stream,那麼它前身肯定是一個本地的文件或者一個網絡數據流,最終體現出來就是一個 File 或者一個 Uri,這些都是 Glide 支持的。

如果實在對 InputStream 的輸入有要求,可以自行實現 Glide 的 ModelLoader。

參考:https://muyangmin.github.io/glide-docs-cn/tut/custom-modelloader.html

三. 小結

今天我們強化了在 Android 中,收攏圖片調用的概念,不僅僅是限制一個項目中只使用一個圖片加載庫,而是要對一些系統 Api 進行收攏,例如 BitmapFactory 和 Bitmap.createBitmap() 等。

  • 對於 BitmapFactory,我們只需要利用 asBitmap()/asFile() 配合 submit()/CustomTarget 就可以替換。

  • 對於 Bitmap.createBitmap() 則需要用到 Glide 的 BitmapPool 即可,用 bitmapPool.get() 替換 createBitmap(),使用完成後通過 bitmap.put() 將圖片回收。另外我們還聊了 BitmapPool 的部分邏輯,讓我們使用的更放心。

今天就到這裏,本文對你有幫助嗎?留言、轉發、點好看是最大的支持,謝謝!

公衆號後臺回覆成長『成長』,將會得到我準備的學習資料。

發佈了327 篇原創文章 · 獲贊 8 · 訪問量 4萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章