如何加载一张图片到ImageVIew(Google官方推荐 + 协程)

前言

无论是刚刚加入Android的新人还是工作n年的老码农,如何加载一张图片到ImageView,都能轻松搞定。随着Glide的发布,我已经很久没有写过相关的代码了,最近复习了一下Glide的源码,偶然查看了Google官方的Bitmap管理文档,才发现里面大有文章。

本篇主要以Google官方文档Bitmap的推荐用法作为基础,手撸一个Demo,最近在研究协程的用法,所以在Demo中抛弃线程池,使用协程异步加载。

正文

首先,我从网上找到了一张比较大的图片,尺寸为:3024*3024:


在这里插入图片描述

把文件命名为cat放入drawable文件夹,然后使用ImageView.setImageResource显示图片:

imageView = findViewById(R.id.image)
// 直接设置Resource使用的是图片的原始尺寸, 默认使用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}")
}
在这里插入图片描述

调用ImageView.setImageResource设置图片,系统不会为图片做缩放处理,默认以ARGB_8888加载图片。具体加载过程可以查看源码。

现在我们需要在手机页面上使用尺寸为:100dp * 100dp的ImageView显示这张图片,图片的原始尺寸已经ImageView的大小超出很多倍了,此时我们会出现两个问题:

  1. 图片原始尺寸与显示尺寸相差太大,内存占用非常浪费;
  2. 加载效率以及绘制效率低下,如果是在RecyclerView或ListView中加载这么大的图,滑动时一定会卡顿;

所以为了解决这两个问题,我们进行第一次优化:

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
                // 禁止系统自动根据屏幕密度进行尺寸换算
                // 否则会与option.outWidth的大小不一致,例如在xxhdpi的设备中option.outWidth=300,但是bitmap.width=900,设置为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}")
                // 回归主线程设置图片
                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
    }

    /**
     * 获取协程的上下文
     * */
    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 实现了协程,页面销毁,加载任务会被取消,防止内存泄漏
class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() {
    override fun onDestroy() {
        super.onDestroy()
        // 取消协程任务
        cancel()
    }
}

上面的代码,我们通过:预加载 -> 缩放 -> 加载 -> 显示,完成了图片的加载。其中需要注意的是,我们设置了option.inScaled = false,因为我们的宽高的单位是dp,已经被系统适配过了,所以不需要Bitmap再根据设备屏幕密度缩放,导致内存的浪费。


在这里插入图片描述

再优化

经过第一次优化,加载一张图的问题我们已经解决了,但是如果是在列表里呢?我们使用RecyclerView,显示一个图片列表。


在这里插入图片描述

每次Item显示的时候我们都会加载一张新的图片到内存中,而事实上我们只需要一张图片到内存就足够了,所以我们应该添加一层内存缓存。

/**
 * @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实现一个可控的内存管理工具,必须要注意的是一定要使用Support中的LruCache,而不是android自带的LruCache,两者实现不一样,亲身踩过这个大坑。现在缓存这一层有了,还有另外一个问题:

如果我们正在加载某一张图片,此时又有一个新的请求过来,还是加载这张图片,此时第一个请求还未完成,这样就会出现两张相同的图片。

解决此问题,只需添加任务队列,判断是否已有相同的任务存在即可。

/**
 * @author li.zhipeng
 * 
 *      图片加载任务管理类,防止创建重复任务
 * */
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)
    }
}

工具已经开发完毕,我们还需要修改图片加载的流程,完整代码如下:

    /**
     * 加载图片
     * */
    fun loadBitmapToImageView(imageView: ImageView, @DrawableRes id: Int) {

        val coroutineScope = getCoroutineScope(imageView.context) ?: return

        coroutineScope.launch {

            val taskKey = BitmapCachePool.generateKey(id)
            imageView.tag = taskKey

            // 优先从缓存中找
            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 {
        // 已经有相同的图片正在加载,等待任务结果返回
        if (BitmapTaskManager.contains(taskKey)) {
            Log.i("BestBitmapUtil", "wait task result")
            return@coroutineScope BitmapTaskManager.get(taskKey)!!.await()
        } else {
            Log.i("BestBitmapUtil", "create new task")
            // 创建新的异步任务
            val task = async {
                loadResource(imageView, id)
                    .apply {
                        // 加入缓存
                        BitmapCachePool.put(taskKey, this)
                    }
            }
            // 加入任务队列中
            BitmapTaskManager.add(taskKey, task)
            return@coroutineScope task.await().apply {
                //任务结束,移除管理栈
                BitmapTaskManager.remove(taskKey)
            }
        }

    }

我们把图片加载增加2s,通过Logcat查看日志,确实我们的图片只加载了一次:


在这里插入图片描述

再再优化

目前我们只有一张图片,现在让我们思考一下真实的使用场景:

假设我们的LruCache可以缓存80张,每次刷新从网络获取20张图片且不重复,那么在刷新第五次的时候,根据LruCache缓存的规则,第一次刷新的20张图片就会从LruCache中移出,处于等待被系统GC的状态。如果我们继续刷新n次,等待被回收的张数就会累积到 20 * n 张。

此时就会出现大量的Bitmap内存碎片,我们不知道系统什么时候会触发GC回收掉这些无用的Bitmap,对于内存是否会溢出,是否会频繁GC导致卡顿等未知问题,我们也无能为力。

如果我们直接使用那些无用的Bitmap内存去加载图片,这样系统就不需要再为新的图片动态分配新的内存,这样内存不就可以达到动态平衡了吗?所以在Android 3.0以后引入了 BitmapFactory.Options.inBitmap,如果设置此项,需要解码的图片就会尝试使用该Bitmap的内存,这样取消了内存的动态分配,提高了性能,节省了内存。

所以我们需要优化之前的内存缓存,把处于无用的状态的Bitmap放入SoftReference。SoftReference引用的对象会在内存溢出之前被回收,所以我们可以不用考虑回收的问题。我们可以把LruCache中移出的对象,放入软引用池子中。

private val memoryCache = lruCache<String, Bitmap>(
        maxSize = 4 * 1024 * 1024,  // 缓存4M的图片
        sizeOf = { _, value ->
            value.byteCount
        },
        onEntryRemoved = { _, key, oldValue, _ ->
            // 放入软引用复用池
            if (oldValue.isMutable) {
                bitmapRecyclerPool?.put(key, SoftReference(oldValue))
            }
        }
    )

    /**
     * 软引用池
     * */
    private var bitmapRecyclerPool: MutableMap<String, SoftReference<Bitmap>>? = null

    /**
     * 位图复用只支持Android 3.0 及以上
     * */
    private fun hasHoneycomb() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB

    init {
        if (hasHoneycomb()) {
            bitmapRecyclerPool =
                Collections.synchronizedMap(HashMap<String, SoftReference<Bitmap>>())
        }
    }

现在已经有了位图复用的池子,我们再思考如何使用它,目前我想到了两种使用场景:

  1. 当加载一张新图片时,我们优先从LruCache缓存中查看是否命中,如果未命中,我们还可以尝试从SoftReference中尝试命中,如果命中成功,重新移动LruCache中;
  2. 如果两层缓存都未命中,我们可以从SoftReference尝试寻找可以复用的位图,优化内存;

我们先修改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新增位图复用方法:

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()
                // 已经被回收或不可复用
                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内存大于等于需要的内存
        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 {
        ... 预加载图片宽高

        // 最终加载图片
        options.inSampleSize = inSampleSize
        options.inJustDecodeBounds = false

        // 设置可以复用的Bitmap
        BitmapCachePool.getReusableBitmap(options)
        options.inScaled = false
        val bitmap = BitmapFactory.decodeResource(imageView.resources, id, options)
        return@coroutineScope bitmap
    }

代码中注释写明:在Android 3.0 到 Android 4.4之间,只能复用未缩放的大小相等的位图,到了Android 4.4版本及以上,只需要判断复用位图的native内存大于等于要加载的位图的内存即可。这次我又添加了很多新的图片,下面是Profiler的内存截图:


未添加位图复用的内存走势图

未添加位图复用的内存走势图

其中第一张是未添加位图复用的内存走势图,在不停的滑动中,内存还是上升的。当使用了位图复用后,滑动几次后,内存已经趋于平稳,并且内存小于第一张图。

补充

上面的Demo中使用了 @Synchronized实现了线程同步,今天查看Kotlin文档,发现Kotlin提供了Mutex作为Java中锁机制的替代品,官方介绍如下:

在阻塞的世界中,你通常会使用 synchronized 或者 ReentrantLock。 在协程中的替代品叫做 Mutex 。它具有 lock 和 unlock 方法, 可以隔离关键的部分。关键的区别在于 Mutex.lock() 是一个挂起函数,它不会阻塞线程。

Mutex使用方法和ReentrantLock类似,所以之前的代码可以修改如下:

    private val mMutex = Mutex()

    private suspend fun createLoadTask(
        imageView: ImageView,
        @DrawableRes id: Int,
        taskKey: String
    ): Bitmap? = coroutineScope {

        // 加锁
        mMutex.lock()

        val task = try {
            // 已经有相同的图片正在加载,等待任务结果返回
            if (BitmapTaskManager.contains(taskKey)) {
                BitmapTaskManager.get(taskKey)!!
            } else {
                // 创建新的异步任务
                val task = async {
                    loadResource(imageView, id)
                        .apply {
                            // 加入缓存
                            BitmapCachePool.put(taskKey, this)
                        }
                }
                // 加入任务队列中
                BitmapTaskManager.add(taskKey, task)
                task
            }
        }
        catch (e: Exception){
            null
        }
        finally {
            mMutex.unlock()
        }

        return@coroutineScope task?.await().apply {
            //任务结束,移除管理栈
            BitmapTaskManager.remove(taskKey)
        }

    }

总结

到此为止我们的Demo就结束了,但是上面的Demo还存在很多优化的方向,例如软引用池的大小限制,回收策略等等,有时间可以再深入的讨论。看完Google的开发者文档,作为一个工作了6年的自以为还不错的Android开发者,感到非常的惭愧,真的非常推荐大家FQ去看一看。

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 156,907评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,546评论 1 289
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 106,705评论 0 238
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,624评论 0 203
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 51,940评论 3 285
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,371评论 1 210
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,672评论 2 310
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,396评论 0 195
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,069评论 1 238
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,350评论 2 242
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,876评论 1 256
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,243评论 2 251
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,847评论 3 231
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,004评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,755评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,378评论 2 269
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,266评论 2 259

推荐阅读更多精彩内容