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: 加入複用池 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
}
} ```