Android進階寶典 -- 學會Bitmap內存管理,你的App內存還會暴增嗎?

語言: CN / TW / HK

相信夥伴們在日常的開發中,一定對圖片加載有所涉獵,而且對於圖片加載現有的第三方庫也很多,例如Glide、coil等,使用這些三方庫我們好像就沒有啥擔憂的,他們內部的內存管理和緩存策略做的很好,但是一旦在某些場景中無法使用圖片加載庫,或者項目中沒有使用圖片加載庫而且重構難度大的情況下,對於Bitmap內存的管理就顯得尤為重要了,一旦使用出現問題,那麼OOM是常有的事。

在Android 8.0之後,Bitmap的內存分配從Java堆轉移到了Native堆中,所以我們可以通過Android profiler性能檢測工具查看內存使用情況。

未經過內存管理,列表滑動前內存狀態:

image.png

列表滑動時,內存狀態: image.png

通過上面兩張圖我們可以發現,Java堆區的內存沒有變化,但是Native的內存發生了劇烈的抖動,而且伴隨着頻繁的GC,如果有了解JVM的夥伴,這種情況下必定伴隨着應用的卡頓,所以對於Bitmap加載,就要避免頻繁地創建和回收,因此本章將會着重介紹Bitmap的內存管理。

1 Bitmap“整容”

首先我們需要明確一點,既然是內存管理,難道只是對圖片壓縮保證不會OOM嗎?其實不是的,內存管理一定是多面多點的,壓縮是一方面,為什麼起標題為“整容”,是因為最終加載到內存的Bitmap一定不是單純地通過decodeFile就能完成的。

1.1 Bitmap內存複用

上圖內存狀態對應的列表代碼如下: ```kotlin override fun onBindViewHolder(holder: ViewHolder, position: Int) { bindBitmap(holder) }

///sdcard/img.png private fun bindBitmap(holder: ViewHolder) { val bitmap = BitmapFactory.decodeFile("/sdcard/img.png") holder.binding.ivImg.setImageBitmap(bitmap) } ```

如果熟悉RecyclerView的緩存機制應該瞭解,當RecyclerView的Item移出頁面之後,會放在緩存池當中;當下面的item顯示的時候,首先會從緩存池中取出緩存,直接調用onBindViewHolder方法,所以依然會重新創建一個Bitmap,因此針對列表的緩存特性可以選擇Bitmap內存複用機制。

image.png

看上面這張圖,因為頂部的Item在新建的時候,已經在native堆區中分配了一塊內存,所以當這塊區域被移出屏幕的時候,下面顯示的Item不需要再次分配內存空間,而是複用移出屏幕的Item的內存區域,從而避免了頻繁地創建Bitmap導致內存抖動。

```kotlin override fun onBindViewHolder(holder: ViewHolder, position: Int) { bindBitmap(holder) }

///sdcard/img.png private fun bindBitmap(holder: ViewHolder) {

if (option == null) {
    option = BitmapFactory.Options()
    //開啟內存複用
    option?.inMutable = true
}
val bitmap = BitmapFactory.decodeFile("/sdcard/img.png", option)
option?.inBitmap = bitmap
holder.binding.ivImg.setImageBitmap(bitmap)

} ```

那麼如何實現內存複用,在BitmapFactory中提供了Options選項,當設置inMutable屬性為true之後,就代表開啟了內存複用,此時如果新建了一個Bitmap,並將其添加到inBitmap中,那麼後續所有Bitmap的創建,只要比這塊內存小,那麼都會放在這塊內存中,避免重複創建。

滑動前:

image.png 滑動時:

image.png

通過上圖我們發現,即便是在滑動的時候,Native內存都沒有明顯的變化。

1.2 Bitmap壓縮

像1.1中這種加載形式,其實都是會直接將Bitmap加載到native內存中,例如我們設置的ImageView只有100*100,那麼圖片的大小為1000 * 800,其實是不需要將這麼大體量的圖片直接加載到內存中,那麼有沒有一種方式,在圖片加載到內存之前就能拿到這些基礎信息呢?

當然有了,這裏還是要搬出BitmapFactory.Option這個類,其中inJustDecodeBounds這個屬性的含義,從字面意思上就可以看出,只解碼邊界,也就是意味着在加載內存之前,是會拿到Bitmap的寬高的,注意需要成對出現,開啟後也需要關閉。 ```kotlin private fun bindBitmap(holder: ViewHolder) {

if (option == null) {
    option = BitmapFactory.Options()
    //開啟內存複用
    option?.inMutable = true
}

//在加載到內存之前,獲取圖片的基礎信息
option?.inJustDecodeBounds = true
BitmapFactory.decodeFile("/sdcard/img.png",option)
//獲取寬高
val outWidth = option?.outWidth ?: 100
val outHeight = option?.outHeight ?: 100
//計算縮放係數
option?.inSampleSize = calculateSampleSize(outWidth, outHeight, 100, 100)
option?.inJustDecodeBounds = false
val bitmap = BitmapFactory.decodeFile("/sdcard/img.png", option)
option?.inBitmap = bitmap
holder.binding.ivImg.setImageBitmap(bitmap)

}

private fun calculateSampleSize( outWidth: Int, outHeight: Int, maxWidth: Int, maxHeight: Int ): Int? { var sampleSize = 1 Log.e("TAG","outWidth $outWidth outHeight $outHeight") if (outWidth > maxWidth && outHeight > maxHeight) { sampleSize = 2 while (outWidth / sampleSize > maxWidth && outHeight / sampleSize > maxHeight) { sampleSize *= 2 } } return sampleSize } ``` 然後會需要計算一個壓縮的係數,給BitmapFactory.Option類的inSampleSize賦值,這樣Bitmap就完成了縮放,我們再次看運行時的內存狀態。

image.png

Native內存幾乎下降了一半。

2 手寫圖片緩存框架

在第一節中,我們對於Bitmap自身做了一些處理,例如壓縮、內存複用。雖然做了這些處理,但是不足以作為一個優秀的框架對外輸出。

為什麼呢?像1.2節中,我們雖然做了內存複用以及壓縮,但是每次加載圖片都需要重新調用decodeFile拿到一個bitmap對象,其實這都是同一張圖片,即便是在項目中,肯定也存在相同的圖片,那麼我們肯定不能重複加載,因此對於加載過的圖片我們想緩存起來,等到下次加載的時候,直接拿緩存中的Bitmap,其實也是加速了響應時間。

2.1 內存緩存

image.png 首先一個成熟的圖片加載框架,三級緩存是必須的,像Glide、coil的緩存策略,如果能把這篇文章搞懂了,那麼就全通了。

在Android中,提供了LruCache這個類,也是內存緩存的首選,如果熟悉LruCache的夥伴,應該明白其中的原理。它其實是一個雙向鏈表,以最近少用原則,當緩存中的數據長時間不用,而且有新的成員加入進來之後,就會移除尾部的成員,那麼我們首先搞定內存緩存。

```kotlin class BitmapImageCache {

private var context: Context? = null

//默認關閉
private var isEnableMemoryCache: Boolean = false
private var isEnableDiskCache: Boolean = false

constructor(builder: Builder) {
    this.context = context
    this.isEnableMemoryCache = builder.isEnableMemoryCache
    this.isEnableDiskCache = builder.isEnableDiskCache
}


class Builder {

    var context: Context? = null

    //是否開啟內存緩存
    var isEnableMemoryCache: Boolean = false

    //是否開啟磁盤緩存
    var isEnableDiskCache: Boolean = false

    fun with(context: Context): Builder {
        this.context = context
        return this
    }

    fun enableMemoryCache(isEnable: Boolean): Builder {
        this.isEnableMemoryCache = isEnable
        return this
    }

    fun enableDiskCache(isEnable: Boolean): Builder {
        this.isEnableDiskCache = isEnable
        return this
    }

    fun build(): BitmapImageCache {
        return BitmapImageCache(this)
    }
}

} ``` 基礎框架採用建造者設計模式,基本都是一些開關,控制是否開啟內存緩存,或者磁盤緩存,接下來進行一些初始化操作。

首先對於內存緩存,我們使用LruCache,其中有兩個核心的方法:sizeOf和entryRemoved,方法的作用已經在註釋裏了。

```kotlin class BitmapLruCache( val size: Int ) : LruCache(size) {

/**
 * 告訴系統Bitmap內存的大小
 */
override fun sizeOf(key: String, value: Bitmap): Int {
    return value.allocationByteCount
}

/**
 * 當Lru中的成員被移除之後,會走到這個回調
 * @param oldValue 被移除的Bitmap
 */
override fun entryRemoved(evicted: Boolean, key: String, oldValue: Bitmap, newValue: Bitmap?) {
    super.entryRemoved(evicted, key, oldValue, newValue)

}

} ``` 當LruCache中元素被移除之後,我們想是不是就需要回收了,那這樣的話其實就錯了。記不記得我們前面做的內存複用策略,如果當前Bitmap內存是可以被複用的,直接回收掉,那內存複用就沒有意義了,所以針對可複用的Bitmap,可以放到一個複用池中,保證其在內存中

```kotlin /* * 當Lru中的成員被移除之後,會走到這個回調 * @param oldValue 被移除的Bitmap / override fun entryRemoved(evicted: Boolean, key: String, oldValue: Bitmap, newValue: Bitmap?) {

if (oldValue.isMutable) {
    //放入複用池
    reusePool?.add(WeakReference(oldValue))
} else {
    //回收即可
    oldValue.recycle()
}

} ``` 所以這裏加了一個判斷,當這個Bitmap是支持內存複用的話,就加到複用池中,保證其他Item在複用內存的時候不至於找不到內存地址,前提是還沒有被回收;那麼這裏就有一個問題,當複用池中的對象(弱引用)被釋放之後,Bitmap如何回收呢?與弱引用配套的有一個引用隊列,當弱引用被GC回收之後,會被加到引用隊列中。

```kotlin class BitmapLruCache( val size: Int, val reusePool: MutableSet>?, val referenceQueue: ReferenceQueue? ) : LruCache(size) {

/**
 * 告訴系統Bitmap內存的大小
 */
override fun sizeOf(key: String, value: Bitmap): Int {
    return value.allocationByteCount
}

/**
 * 當Lru中的成員被移除之後,會走到這個回調
 * @param oldValue 被移除的Bitmap
 */
override fun entryRemoved(evicted: Boolean, key: String, oldValue: Bitmap, newValue: Bitmap?) {

    if (oldValue.isMutable) {
        //放入複用池
        reusePool?.add(WeakReference(oldValue, referenceQueue))
    } else {
        //回收即可
        oldValue.recycle()
    }
}

} 這裏需要公開一個方法,開啟一個線程一直檢測引用隊列中是否有複用池回收的對象,如果拿到了那麼就主動銷燬即可。kotlin /* * 開啟弱引用回收檢測,目的為了回收Bitmap / fun startWeakReferenceCheck() { //開啟一個線程 Thread { try { while (!shotDown) { val reference = referenceQueue?.remove() val bitmap = reference?.get() if (bitmap != null && !bitmap.isRecycled) { bitmap.recycle() } } } catch (e: Exception) {

    }

}.start()

} 另外再加幾個方法,主要就是往緩存中加數據。kotlin fun putCache(key: String, bitmap: Bitmap) { lruCache?.put(key, bitmap) }

fun getCache(key: String): Bitmap? { return lruCache?.get(key) }

fun clearCache() { lruCache?.evictAll() } ```

初始化的操作,我們把它放在Application中進行初始化操作 ```kotlin class MyApp : Application() {

override fun onCreate() {
    super.onCreate()
    bitmapImageCache = BitmapImageCache.Builder()
        .enableMemoryCache(true)
        .with(this)
        .build()
    //開啟內存檢測
    bitmapImageCache?.startWeakReferenceCheck()
}

companion object {
    @SuppressLint("StaticFieldLeak")
    @JvmStatic
    var bitmapImageCache: BitmapImageCache? = null
}

} ```

從實際的效果中,我們可以看到:

java 2023-02-18 17:54:10.154 32517-32517/com.lay.nowinandroid E/TAG: outWidth 800 outHeight 560 2023-02-18 17:54:10.154 32517-32517/com.lay.nowinandroid E/TAG: 沒有從緩存中獲取 2023-02-18 17:54:10.169 32517-32517/com.lay.nowinandroid E/TAG: 從緩存中獲取 Bitmap 2023-02-18 17:54:10.187 32517-32517/com.lay.nowinandroid E/TAG: 從緩存中獲取 Bitmap 2023-02-18 17:54:16.740 32517-32517/com.lay.nowinandroid E/TAG: 從緩存中獲取 Bitmap 2023-02-18 17:54:16.756 32517-32517/com.lay.nowinandroid E/TAG: 從緩存中獲取 Bitmap 2023-02-18 17:54:16.926 32517-32517/com.lay.nowinandroid E/TAG: 從緩存中獲取 Bitmap 2023-02-18 17:54:17.102 32517-32517/com.lay.nowinandroid E/TAG: 從緩存中獲取 Bitmap

其實加了內存緩存之後,跟inBitmap的價值基本就是等價的了,也是為了避免頻繁地申請內存,可以認為是一個雙保險,加上對圖片壓縮以及LruCache的緩存策略,真正內存打滿的場景還是比較少的。

2.2 複用池的處理

在前面我們提到了,為了保證可複用的Bitmap不被回收,從而加到了一個複用池中,那麼當從緩存中沒有取到數據的時候,就會從複用池中取,相當於是在內存緩存中加了一個二級緩存。

image.png

針對上述圖中的流程,可以對複用池進行處理。 ```kotlin /* * 從複用池中取數據 / fun getBitmapFromReusePool(width: Int, height: Int, sampleSize: Int): Bitmap? {

var bitmap: Bitmap? = null
//遍歷緩存池
val iterator = reusePool?.iterator() ?: return null
while (iterator.hasNext()) {
    val checkedBitmap = iterator.next().get()
    if (checkBitmapIsAvailable(width, height, sampleSize, bitmap)) {
        bitmap = checkedBitmap
        iterator.remove()
        //放在
        break
    }
}

return bitmap

}

/* * 檢查當前Bitmap內存是否可複用 / private fun checkBitmapIsAvailable( width: Int, height: Int, sampleSize: Int, bitmap: Bitmap? ): Boolean { if (bitmap == null) { return false } if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) { return width < bitmap.width && height < bitmap.height && sampleSize == 1 } var realWidth = 0 var realHeight = 0 //支持縮放 if (sampleSize > 1) { realWidth = width / sampleSize realHeight = height / sampleSize } val allocationSize = realHeight * realWidth * getBitmapPixel(bitmap.config) return allocationSize <= bitmap.allocationByteCount }

/* * 獲取Bitmap的像素點位數 / private fun getBitmapPixel(config: Bitmap.Config): Int { return if (config == Bitmap.Config.ARGB_8888) { 4 } else { 2 } } ``` 這裏需要注意一點就是,如果想要複用內存,那麼申請的內存一定要比複用的這塊內存小,否則就不能匹配上。

所以最終的一個流程就是(這裏沒考慮磁盤緩存,如果用過Glide就會知道,磁盤緩存會有問題),首先從內存中取,如果取到了,那麼就直接渲染展示;如果沒有取到,那麼就從複用池中取出一塊內存,然後讓新創建的Bitmap複用這塊內存。

kotlin //從內存中取 var bitmap = BitmapImageCache.getCache(position.toString()) if (bitmap == null) { //從複用池池中取 val reuse = BitmapImageCache.getBitmapFromReusePool(100, 100, 1) Log.e("TAG", "從網絡加載了數據") bitmap = ImageUtils.load(imagePath, reuse) //放入內存緩存 BitmapImageCache.putCache(position.toString(), bitmap) } else { Log.e("TAG", "從內存加載了數據") } 最終的一個呈現就是:

java 2023-02-18 21:31:57.805 29198-29198/com.lay.nowinandroid E/TAG: 從網絡加載了數據 2023-02-18 21:31:57.819 29198-29198/com.lay.nowinandroid E/TAG: outWidth 800 outHeight 560 2023-02-18 21:31:57.830 29198-29198/com.lay.nowinandroid E/TAG: 加入複用池 android.graphics.Bitmap@6c19c7b 2023-02-18 21:31:57.830 29198-29198/com.lay.nowinandroid E/TAG: setImageBitmap android.graphics.Bitmap@473ed07 2023-02-18 21:31:57.849 29198-29198/com.lay.nowinandroid E/TAG: 從網絡加載了數據 2023-02-18 21:31:57.857 29198-29198/com.lay.nowinandroid E/TAG: outWidth 788 outHeight 514 2023-02-18 21:31:57.871 29198-29198/com.lay.nowinandroid E/TAG: 加入複用池 android.graphics.Bitmap@2a7844 2023-02-18 21:31:57.872 29198-29198/com.lay.nowinandroid E/TAG: setImageBitmap android.graphics.Bitmap@4d852a3 2023-02-18 21:31:57.917 29198-29198/com.lay.nowinandroid E/TAG: 從網絡加載了數據 2023-02-18 21:31:57.943 29198-29198/com.lay.nowinandroid E/TAG: outWidth 34 outHeight 8 2023-02-18 21:31:57.958 29198-29198/com.lay.nowinandroid E/TAG: setImageBitmap android.graphics.Bitmap@a3d491e 2023-02-18 21:31:58.651 29198-29198/com.lay.nowinandroid E/TAG: 從內存加載了數據 2023-02-18 21:31:58.651 29198-29198/com.lay.nowinandroid E/TAG: setImageBitmap android.graphics.Bitmap@62fcf27 2023-02-18 21:31:58.706 29198-29198/com.lay.nowinandroid E/TAG: 從內存加載了數據 2023-02-18 21:31:58.707 29198-29198/com.lay.nowinandroid E/TAG: setImageBitmap android.graphics.Bitmap@e2f8a1a 2023-02-18 21:31:58.766 29198-29198/com.lay.nowinandroid E/TAG: 從內存加載了數據 其實真正要保證我們的內存穩定,就是儘量避免重複創建對象,尤其是大圖片,在加載的時候尤其需要注意,在項目中出現內存始終不降的主要原因也是對Bitmap的內存管理不當,所以掌握了上面的內容,就可以針對這些問題進行優化。總之萬變不離其宗,內存是App的生命線,如果在面試的時候問你如何設計一個圖片加載框架,內存管理是核心,當出現文章一開頭那樣的內存曲線的時候,就需要重點關注你的Bitmap是不是又“亂飆”了。

附錄 - ImageUtils

```kotlin object ImageUtils {

private val MAX_WIDTH = 100
private val MAX_HEIGHT = 100

/**
 * 加載本地圖片
 * @param reuse 可以複用的Bitmap內存
 */
fun load(imagePath: String, reuse: Bitmap?): Bitmap {

    val option = BitmapFactory.Options()
    option.inMutable = true
    option.inJustDecodeBounds = true

    BitmapFactory.decodeFile(imagePath, option)
    val outHeight = option.outHeight
    val outWidth = option.outWidth
    option.inSampleSize = calculateSampleSize(outWidth, outHeight, MAX_WIDTH, MAX_HEIGHT)

    option.inJustDecodeBounds = false
    option.inBitmap = reuse
    //新創建的Bitmap複用這塊內存
    return BitmapFactory.decodeFile(imagePath, option)
}

private fun calculateSampleSize(
    outWidth: Int,
    outHeight: Int,
    maxWidth: Int,
    maxHeight: Int
): Int {
    var sampleSize = 1
    Log.e("TAG", "outWidth $outWidth outHeight $outHeight")
    if (outWidth > maxWidth && outHeight > maxHeight) {
        sampleSize = 2
        while (outWidth / sampleSize > maxWidth && outHeight / sampleSize > maxHeight) {
            sampleSize *= 2
        }
    }
    return sampleSize
}

} ```