前言
無論是剛剛加入Android的新人還是工作n年的老碼農(nóng),如何加載一張圖片到ImageView,都能輕松搞定。隨著Glide的發(fā)布,我已經(jīng)很久沒有寫過相關(guān)的代碼了,最近復(fù)習(xí)了一下Glide的源碼,偶然查看了Google官方的Bitmap管理文檔,才發(fā)現(xiàn)里面大有文章。
本篇主要以Google官方文檔Bitmap的推薦用法作為基礎(chǔ),手?jǐn)]一個Demo,最近在研究協(xié)程的用法,所以在Demo中拋棄線程池,使用協(xié)程異步加載。
正文
首先,我從網(wǎng)上找到了一張比較大的圖片,尺寸為:3024*3024:
把文件命名為cat放入drawable文件夾,然后使用ImageView.setImageResource顯示圖片:
imageView = findViewById(R.id.image)
// 直接設(shè)置Resource使用的是圖片的原始尺寸, 默認(rèn)使用ARGB_8888
if (imageView.drawable is BitmapDrawable){
Log.i("lzp", "drawable size: ${(imageView.drawable as BitmapDrawable).bitmap.allocationByteCount}")
Log.i("lzp", "drawable width: ${(imageView.drawable as BitmapDrawable).bitmap.width}")
Log.i("lzp", "drawable width: ${(imageView.drawable as BitmapDrawable).bitmap.height}")
}

調(diào)用ImageView.setImageResource設(shè)置圖片,系統(tǒng)不會為圖片做縮放處理,默認(rèn)以ARGB_8888加載圖片。具體加載過程可以查看源碼。
現(xiàn)在我們需要在手機頁面上使用尺寸為:100dp * 100dp的ImageView顯示這張圖片,圖片的原始尺寸已經(jīng)ImageView的大小超出很多倍了,此時我們會出現(xiàn)兩個問題:
- 圖片原始尺寸與顯示尺寸相差太大,內(nèi)存占用非常浪費;
- 加載效率以及繪制效率低下,如果是在RecyclerView或ListView中加載這么大的圖,滑動時一定會卡頓;
所以為了解決這兩個問題,我們進行第一次優(yōu)化:
object BestBitmapUtil {
/**
* 加載圖片
* */
fun loadBitmapToImageView(imageView: ImageView, @DrawableRes id: Int) {
val coroutineScope = getCoroutineScope(imageView.context) ?: return
coroutineScope.launch {
// 在IO線程中做圖片的加載縮放處理
withContext(Dispatchers.IO) {
// 獲取圖片的原始尺寸
val option = getOriginalSizeOption(imageView.context, id)
Log.i("BestBitmapUtil", "original width:${option.outWidth}")
Log.i("BestBitmapUtil", "original width:${option.outHeight}")
// 計算圖片的縮放比例
val layoutPrams = imageView.layoutParams
val inSampleSize = calculateInSampleSize(option, layoutPrams.width, layoutPrams.height)
Log.i("BestBitmapUtil", "inSampleSize:${inSampleSize}")
// 最終加載圖片
option.inSampleSize = inSampleSize
option.inJustDecodeBounds = false
// 禁止系統(tǒng)自動根據(jù)屏幕密度進行尺寸換算
// 否則會與option.outWidth的大小不一致,例如在xxhdpi的設(shè)備中option.outWidth=300,但是bitmap.width=900,設(shè)置為false后,bitmap.width = 300
option.inScaled = false
val bitmap = BitmapFactory.decodeResource(imageView.resources, id, option)
Log.i("BestBitmapUtil", "result width:${option.outWidth}")
Log.i("BestBitmapUtil", "result width:${option.outHeight}")
// 回歸主線程設(shè)置圖片
withContext(Dispatchers.Main){
imageView.setImageBitmap(bitmap)
}
}
}
}
private fun getOriginalSizeOption(
context: Context,
@DrawableRes id: Int
): BitmapFactory.Options {
return BitmapFactory.Options().apply {
this.inJustDecodeBounds = true
BitmapFactory.decodeResource(context.resources, id, this)
}
}
private fun calculateInSampleSize(
option: BitmapFactory.Options,
reqWidth: Int,
reqHeight: Int
) : Int{
val (width: Int, height: Int) = option.run { outWidth to outHeight }
var inSampleSize = 1
if (height > reqHeight || width > reqWidth){
val halfWidth = height / 2
val halfHeight = width / 2
while (halfHeight / inSampleSize > reqHeight || halfWidth / inSampleSize > reqWidth){
inSampleSize *= 2
}
}
return inSampleSize
}
/**
* 獲取協(xié)程的上下文
* */
private fun getCoroutineScope(context: Context?): CoroutineScope? {
var contextTemp = context
if (null != contextTemp) {
while (contextTemp is ContextWrapper) {
if (contextTemp is CoroutineScope) {
return contextTemp
}
contextTemp = contextTemp.baseContext
}
}
return null
}
}
// MainActivity 實現(xiàn)了協(xié)程,頁面銷毀,加載任務(wù)會被取消,防止內(nèi)存泄漏
class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() {
override fun onDestroy() {
super.onDestroy()
// 取消協(xié)程任務(wù)
cancel()
}
}
上面的代碼,我們通過:預(yù)加載 -> 縮放 -> 加載 -> 顯示,完成了圖片的加載。其中需要注意的是,我們設(shè)置了option.inScaled = false,因為我們的寬高的單位是dp,已經(jīng)被系統(tǒng)適配過了,所以不需要Bitmap再根據(jù)設(shè)備屏幕密度縮放,導(dǎo)致內(nèi)存的浪費。

再優(yōu)化
經(jīng)過第一次優(yōu)化,加載一張圖的問題我們已經(jīng)解決了,但是如果是在列表里呢?我們使用RecyclerView,顯示一個圖片列表。
每次Item顯示的時候我們都會加載一張新的圖片到內(nèi)存中,而事實上我們只需要一張圖片到內(nèi)存就足夠了,所以我們應(yīng)該添加一層內(nèi)存緩存。
/**
* @author li.zhipeng
*
* 圖片緩存池
* */
object BitmapCachePool {
private val memoryCache = lruCache<String, Bitmap>(
maxSize = 4 * 1024 * 1024, // 緩存4M的圖片
sizeOf = { _, value ->
value.byteCount
},
onEntryRemoved = { evicted, key, oldValue, newValue ->
}
)
fun put(key: String, bitmap: Bitmap) {
memoryCache.put(key, bitmap)
}
fun get(key: String): Bitmap? {
return memoryCache[key]
}
fun generateKey(id: Int): String{
return id.toString()
}
}
通過LruCache實現(xiàn)一個可控的內(nèi)存管理工具,必須要注意的是一定要使用Support中的LruCache,而不是android自帶的LruCache,兩者實現(xiàn)不一樣,親身踩過這個大坑?,F(xiàn)在緩存這一層有了,還有另外一個問題:
如果我們正在加載某一張圖片,此時又有一個新的請求過來,還是加載這張圖片,此時第一個請求還未完成,這樣就會出現(xiàn)兩張相同的圖片。
解決此問題,只需添加任務(wù)隊列,判斷是否已有相同的任務(wù)存在即可。
/**
* @author li.zhipeng
*
* 圖片加載任務(wù)管理類,防止創(chuàng)建重復(fù)任務(wù)
* */
object BitmapTaskManager {
private val taskSet = HashMap<String, Deferred<Bitmap>>()
fun contains(key: String) = taskSet.contains(key)
fun add(key: String, task: Deferred<Bitmap>) {
taskSet[key] = task
}
fun get(key: String) = taskSet[key]
fun remove(key: String) {
taskSet.remove(key)
}
}
工具已經(jīng)開發(fā)完畢,我們還需要修改圖片加載的流程,完整代碼如下:
/**
* 加載圖片
* */
fun loadBitmapToImageView(imageView: ImageView, @DrawableRes id: Int) {
val coroutineScope = getCoroutineScope(imageView.context) ?: return
coroutineScope.launch {
val taskKey = BitmapCachePool.generateKey(id)
imageView.tag = taskKey
// 優(yōu)先從緩存中找
var result = BitmapCachePool.get(taskKey)
if (result == null) {
// 在IO線程中做圖片的加載縮放處理
withContext(Dispatchers.IO) {
result = createLoadTask(imageView, id, taskKey)
}
} else {
Log.i("BestBitmapUtil", "load from cache")
}
Log.i("BestBitmapUtil", "setImageBitmap: $imageView")
if (imageView.tag == taskKey) {
imageView.setImageBitmap(result)
}
}
}
@Synchronized
private suspend fun createLoadTask(
imageView: ImageView,
@DrawableRes id: Int,
taskKey: String
): Bitmap = coroutineScope {
// 已經(jīng)有相同的圖片正在加載,等待任務(wù)結(jié)果返回
if (BitmapTaskManager.contains(taskKey)) {
Log.i("BestBitmapUtil", "wait task result")
return@coroutineScope BitmapTaskManager.get(taskKey)!!.await()
} else {
Log.i("BestBitmapUtil", "create new task")
// 創(chuàng)建新的異步任務(wù)
val task = async {
loadResource(imageView, id)
.apply {
// 加入緩存
BitmapCachePool.put(taskKey, this)
}
}
// 加入任務(wù)隊列中
BitmapTaskManager.add(taskKey, task)
return@coroutineScope task.await().apply {
//任務(wù)結(jié)束,移除管理棧
BitmapTaskManager.remove(taskKey)
}
}
}
我們把圖片加載增加2s,通過Logcat查看日志,確實我們的圖片只加載了一次:
再再優(yōu)化
目前我們只有一張圖片,現(xiàn)在讓我們思考一下真實的使用場景:
假設(shè)我們的LruCache可以緩存80張,每次刷新從網(wǎng)絡(luò)獲取20張圖片且不重復(fù),那么在刷新第五次的時候,根據(jù)LruCache緩存的規(guī)則,第一次刷新的20張圖片就會從LruCache中移出,處于等待被系統(tǒng)GC的狀態(tài)。如果我們繼續(xù)刷新n次,等待被回收的張數(shù)就會累積到 20 * n 張。
此時就會出現(xiàn)大量的Bitmap內(nèi)存碎片,我們不知道系統(tǒng)什么時候會觸發(fā)GC回收掉這些無用的Bitmap,對于內(nèi)存是否會溢出,是否會頻繁GC導(dǎo)致卡頓等未知問題,我們也無能為力。
如果我們直接使用那些無用的Bitmap內(nèi)存去加載圖片,這樣系統(tǒng)就不需要再為新的圖片動態(tài)分配新的內(nèi)存,這樣內(nèi)存不就可以達到動態(tài)平衡了嗎?所以在Android 3.0以后引入了 BitmapFactory.Options.inBitmap,如果設(shè)置此項,需要解碼的圖片就會嘗試使用該Bitmap的內(nèi)存,這樣取消了內(nèi)存的動態(tài)分配,提高了性能,節(jié)省了內(nèi)存。
所以我們需要優(yōu)化之前的內(nèi)存緩存,把處于無用的狀態(tài)的Bitmap放入SoftReference。SoftReference引用的對象會在內(nèi)存溢出之前被回收,所以我們可以不用考慮回收的問題。我們可以把LruCache中移出的對象,放入軟引用池子中。
private val memoryCache = lruCache<String, Bitmap>(
maxSize = 4 * 1024 * 1024, // 緩存4M的圖片
sizeOf = { _, value ->
value.byteCount
},
onEntryRemoved = { _, key, oldValue, _ ->
// 放入軟引用復(fù)用池
if (oldValue.isMutable) {
bitmapRecyclerPool?.put(key, SoftReference(oldValue))
}
}
)
/**
* 軟引用池
* */
private var bitmapRecyclerPool: MutableMap<String, SoftReference<Bitmap>>? = null
/**
* 位圖復(fù)用只支持Android 3.0 及以上
* */
private fun hasHoneycomb() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB
init {
if (hasHoneycomb()) {
bitmapRecyclerPool =
Collections.synchronizedMap(HashMap<String, SoftReference<Bitmap>>())
}
}
現(xiàn)在已經(jīng)有了位圖復(fù)用的池子,我們再思考如何使用它,目前我想到了兩種使用場景:
- 當(dāng)加載一張新圖片時,我們優(yōu)先從LruCache緩存中查看是否命中,如果未命中,我們還可以嘗試從SoftReference中嘗試命中,如果命中成功,重新移動LruCache中;
- 如果兩層緩存都未命中,我們可以從SoftReference嘗試尋找可以復(fù)用的位圖,優(yōu)化內(nèi)存;
我們先修改BitmapCachePool的get方法,再添加一層緩存:
// BitmapCachePool.kt
fun get(key: String): Bitmap? {
var result = memoryCache[key]
if (result == null) {
bitmapRecyclerPool?.remove(key)?.let {
result = it.get()?.apply {
// 從softReference中移出,加入LruCache
memoryCache.put(key, this)
}
}
}
return result
}
然后我們在BitmapCachePool新增位圖復(fù)用方法:
object BitmapCachePool {
...
fun getReusableBitmap(options: BitmapFactory.Options) {
bitmapRecyclerPool?.let {
options.inMutable = true
val iterator = it.values.iterator()
while (iterator.hasNext()) {
val bitmap = iterator.next().get()
// 已經(jīng)被回收或不可復(fù)用
if (bitmap == null || !bitmap.isMutable) {
iterator.remove()
}
// 找到合適的位圖
else if (canUseInBitmap(bitmap, options)) {
Log.i("BitmapCachePool", "find reusable bitmap")
options.inBitmap = bitmap
iterator.remove()
break
}
}
}
}
private fun canUseInBitmap(bitmap: Bitmap, options: BitmapFactory.Options): Boolean {
// 4.4以上需要bitmap的native內(nèi)存大于等于需要的內(nèi)存
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
val width = options.outWidth / options.inSampleSize
val height = options.outHeight / options.inSampleSize
val byteCount = width * height * getBytesPerPixel(bitmap.config)
byteCount <= bitmap.allocationByteCount
}
// Android 3.0 到 Android 4.4 版本之間需要必須寬高要完全匹配
else {
bitmap.width == options.outWidth && bitmap.height == options.outHeight && options.inSampleSize == 1
}
}
private fun getBytesPerPixel(config: Bitmap.Config): Int {
return when (config) {
Bitmap.Config.ARGB_8888 -> 4
Bitmap.Config.ARGB_4444, Bitmap.Config.RGB_565 -> 2
Bitmap.Config.ALPHA_8 -> 1
else -> 1
}
}
}
// BestBitmapUtil.kt
private suspend fun loadResource(imageView: ImageView, @DrawableRes id: Int) = coroutineScope {
... 預(yù)加載圖片寬高
// 最終加載圖片
options.inSampleSize = inSampleSize
options.inJustDecodeBounds = false
// 設(shè)置可以復(fù)用的Bitmap
BitmapCachePool.getReusableBitmap(options)
options.inScaled = false
val bitmap = BitmapFactory.decodeResource(imageView.resources, id, options)
return@coroutineScope bitmap
}
代碼中注釋寫明:在Android 3.0 到 Android 4.4之間,只能復(fù)用未縮放的大小相等的位圖,到了Android 4.4版本及以上,只需要判斷復(fù)用位圖的native內(nèi)存大于等于要加載的位圖的內(nèi)存即可。這次我又添加了很多新的圖片,下面是Profiler的內(nèi)存截圖:


其中第一張是未添加位圖復(fù)用的內(nèi)存走勢圖,在不停的滑動中,內(nèi)存還是上升的。當(dāng)使用了位圖復(fù)用后,滑動幾次后,內(nèi)存已經(jīng)趨于平穩(wěn),并且內(nèi)存小于第一張圖。
補充
上面的Demo中使用了 @Synchronized實現(xiàn)了線程同步,今天查看Kotlin文檔,發(fā)現(xiàn)Kotlin提供了Mutex作為Java中鎖機制的替代品,官方介紹如下:
在阻塞的世界中,你通常會使用 synchronized 或者 ReentrantLock。 在協(xié)程中的替代品叫做 Mutex 。它具有 lock 和 unlock 方法, 可以隔離關(guān)鍵的部分。關(guān)鍵的區(qū)別在于 Mutex.lock() 是一個掛起函數(shù),它不會阻塞線程。
Mutex使用方法和ReentrantLock類似,所以之前的代碼可以修改如下:
private val mMutex = Mutex()
private suspend fun createLoadTask(
imageView: ImageView,
@DrawableRes id: Int,
taskKey: String
): Bitmap? = coroutineScope {
// 加鎖
mMutex.lock()
val task = try {
// 已經(jīng)有相同的圖片正在加載,等待任務(wù)結(jié)果返回
if (BitmapTaskManager.contains(taskKey)) {
BitmapTaskManager.get(taskKey)!!
} else {
// 創(chuàng)建新的異步任務(wù)
val task = async {
loadResource(imageView, id)
.apply {
// 加入緩存
BitmapCachePool.put(taskKey, this)
}
}
// 加入任務(wù)隊列中
BitmapTaskManager.add(taskKey, task)
task
}
}
catch (e: Exception){
null
}
finally {
mMutex.unlock()
}
return@coroutineScope task?.await().apply {
//任務(wù)結(jié)束,移除管理棧
BitmapTaskManager.remove(taskKey)
}
}
總結(jié)
到此為止我們的Demo就結(jié)束了,但是上面的Demo還存在很多優(yōu)化的方向,例如軟引用池的大小限制,回收策略等等,有時間可以再深入的討論。看完Google的開發(fā)者文檔,作為一個工作了6年的自以為還不錯的Android開發(fā)者,感到非常的慚愧,真的非常推薦大家FQ去看一看。