如何加載一張圖片到ImageVIew(Google官方推薦 + 協(xié)程)

前言

無論是剛剛加入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)兩個問題:

  1. 圖片原始尺寸與顯示尺寸相差太大,內(nèi)存占用非常浪費;
  2. 加載效率以及繪制效率低下,如果是在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ù)用的池子,我們再思考如何使用它,目前我想到了兩種使用場景:

  1. 當(dāng)加載一張新圖片時,我們優(yōu)先從LruCache緩存中查看是否命中,如果未命中,我們還可以嘗試從SoftReference中嘗試命中,如果命中成功,重新移動LruCache中;
  2. 如果兩層緩存都未命中,我們可以從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)存走勢圖

未添加位圖復(fù)用的內(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去看一看。

本文Demo下載地址:https://github.com/li504799868/BestBitmapDemo

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容