Android進階寶典 -- 學會Bitmap記憶體管理,你的App記憶體還會暴增嗎?
相信夥伴們在日常的開發中,一定對圖片載入有所涉獵,而且對於圖片載入現有的第三方庫也很多,例如Glide、coil等,使用這些三方庫我們好像就沒有啥擔憂的,他們內部的記憶體管理和快取策略做的很好,但是一旦在某些場景中無法使用圖片載入庫,或者專案中沒有使用圖片載入庫而且重構難度大的情況下,對於Bitmap記憶體的管理就顯得尤為重要了,一旦使用出現問題,那麼OOM是常有的事。
在Android 8.0之後,Bitmap的記憶體分配從Java堆轉移到了Native堆中,所以我們可以通過Android profiler效能檢測工具檢視記憶體使用情況。
未經過記憶體管理,列表滑動前記憶體狀態:
列表滑動時,記憶體狀態:
通過上面兩張圖我們可以發現,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記憶體複用機制。
看上面這張圖,因為頂部的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的建立,只要比這塊記憶體小,那麼都會放在這塊記憶體中,避免重複建立。
滑動前:
滑動時:
通過上圖我們發現,即便是在滑動的時候,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就完成了縮放,我們再次看執行時的記憶體狀態。
Native記憶體幾乎下降了一半。
2 手寫圖片快取框架
在第一節中,我們對於Bitmap自身做了一些處理,例如壓縮、記憶體複用。雖然做了這些處理,但是不足以作為一個優秀的框架對外輸出。
為什麼呢?像1.2節中,我們雖然做了記憶體複用以及壓縮,但是每次載入圖片都需要重新呼叫decodeFile拿到一個bitmap物件,其實這都是同一張圖片,即便是在專案中,肯定也存在相同的圖片,那麼我們肯定不能重複載入,因此對於載入過的圖片我們想快取起來,等到下次載入的時候,直接拿快取中的Bitmap,其實也是加速了響應時間。
2.1 記憶體快取
首先一個成熟的圖片載入框架,三級快取是必須的,像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
/**
* 告訴系統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
/**
* 告訴系統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不被回收,從而加到了一個複用池中,那麼當從快取中沒有取到資料的時候,就會從複用池中取,相當於是在記憶體快取中加了一個二級快取。
針對上述圖中的流程,可以對複用池進行處理。 ```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: 加入複用池 [email protected]
2023-02-18 21:31:57.830 29198-29198/com.lay.nowinandroid E/TAG: setImageBitmap [email protected]
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: 加入複用池 [email protected]
2023-02-18 21:31:57.872 29198-29198/com.lay.nowinandroid E/TAG: setImageBitmap [email protected]
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 [email protected]
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 [email protected]
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 [email protected]
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
}
} ```