前言
无论是刚刚加入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的大小超出很多倍了,此时我们会出现两个问题:
- 图片原始尺寸与显示尺寸相差太大,内存占用非常浪费;
- 加载效率以及绘制效率低下,如果是在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(
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>()
fun contains(key: String) = taskSet.contains(key)
fun add(key: String, task: Deferred) {
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(
maxSize = 4 * 1024 * 1024, // 缓存4M的图片
sizeOf = { _, value ->
value.byteCount
},
onEntryRemoved = { _, key, oldValue, _ ->
// 放入软引用复用池
if (oldValue.isMutable) {
bitmapRecyclerPool?.put(key, SoftReference(oldValue))
}
}
)
/**
* 软引用池
* */
private var bitmapRecyclerPool: MutableMap>? = null
/**
* 位图复用只支持Android 3.0 及以上
* */
private fun hasHoneycomb() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB
init {
if (hasHoneycomb()) {
bitmapRecyclerPool =
Collections.synchronizedMap(HashMap>())
}
}
现在已经有了位图复用的池子,我们再思考如何使用它,目前我想到了两种使用场景:
- 当加载一张新图片时,我们优先从LruCache缓存中查看是否命中,如果未命中,我们还可以尝试从SoftReference中尝试命中,如果命中成功,重新移动LruCache中;
- 如果两层缓存都未命中,我们可以从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