如何优雅地加载一张图片到ImageVIew?


/   今日科技快讯   /

近日,美国太空探索技术公司SpaceX成功进行了2021年首次星链卫星发射,将其第17批微型卫星送入轨道。此次发射后,入轨星链卫星总数已经超过1000颗,帮助SpaceX实现了新的里程碑。

/   作者简介   /

本篇文章来自珠穆朗玛小王子的投稿,分享了他优化图片加载的整个过程,相信会对大家有所帮助!同时也感谢作者贡献的精彩文章!

珠穆朗玛小王子的博客地址:

https://blog.csdn.net/u011315960

/   前言   /

无论是刚刚加入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

推荐阅读:

年底了,一起来撸个视频播放器吧!

像使用Activity一样使用Fragment

使用协程,让网络世界更加美好

欢迎关注我的公众号

学习技术或投稿

长按上图,识别图中二维码即可关注

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值