每一个 Android App 中都会使用到 Bitmap,它也是程序中内存消耗的大户,当 Bitmap 使用内存超过可用空间,则会报 OOM。
Bitmap 占用内存分析
Bitmap 用来描述一张图片的长、宽、颜色等信息。通常情况下,我们可以使用 BitmapFactory 来将某一路径下的图片解析为 Bitmap 对象。
当一张图片加载到内存后,具体需要占用多大内存呢?
getAllocationByteCount 探索
我们可以通过 Bitmap.getAllocationByteCount() 方法获取 Bitmap 占用的字节大小,比如以下代码:
上图中 rodman 是保存在 res/drawable-xxhdpi 目录下的一张 600*600,大小为 65Kb 的图片。打印结果如下:
I/Bitmap: bitmap size is 1440000
默认情况下 BitmapFactory 使用 Bitmap.Config.ARGB_8888 的存储方式来加载图片内容,而在这种存储模式下,每一个像素需要占用 4 个字节。因此上面图片 rodman 的内存大小可以使用如下公式来计算:
宽 * 高 * 4 = 600 * 600 * 4 = 1440000
屏幕自适应
但是如果我们在保证代码不修改的前提下,将图片 rodman 移动到(注意是移动,不是拷贝)res/drawable-xhdpi 目录下,重新运行代码,则打印日志如下:
I/Bitmap: bitmap size is 3240000
可以看出我们只是移动了图片的位置,Bitmap 所占用的空间竟然上涨了 125%。这是为什么呢?
实际上 BitmapFactory 在解析图片的过程中,会根据当前设备屏幕密度和图片所在的 drawable 目录来做一个对比,根据这个对比值进行缩放操作。具体公式为如下所示:
1. 缩放比例 scale = 当前设备屏幕密度 / 图片所在 drawable 目录对应屏幕密度
2.Bitmap 实际大小 = 宽 * scale * 高 * scale * Config 对应存储像素数
在 Android 中,各个 drawable 目录对应的屏幕密度分别为下:
目录 | drawable-mdpi | drawable-hdpi | drawable-xhdpi | drawable-xxhdpi | drawable-xxxhdpi |
---|---|---|---|---|---|
density | 1 | 1.5 | 2 | 3 | 4 |
densityDpi | 160 | 240 | 320 | 480 | 640 |
我运行的设备是 Nexus 5,屏幕密度为 480。如果将 rodman 放到 drawable-hdpi 目录下,最终的计算公式如下:
rodman 实际占用内存大小 = 600 * (480 / 320) * 600 * (480 / 320) * 4 = 3240000
assets 中的图片大小
Android 中的图片不仅可以保存在 drawable 目录中,还可以保存在 assets 目录下,然后通过 AssetManager 获取图片的输入流。那这种方式加载生成的 Bitmap 是多大呢?同样是上面的 rodman.png,这次将它放到 assets 目录中,使用如下代码加载:
try {
val inputStream = assets.open("rodman.png")
val bitmap = BitmapFactory.decodeStream(inputStream)
Log.i("Bitmap", "bitmap size is " + bitmap.allocationByteCount)
image.setImageBitmap(bitmap)
} catch (e: Exception) {
}
最终打印结果如下:
I/Bitmap: bitmap size is 1440000
可以看出,加载 assets 目录中的图片,系统并不会对其进行缩放操作。
Bitmap 加载优化
上面的例子也能看出,一张 65Kb 大小的图片被加载到内存后,竟然占用了 3240000 个字节,也就是 3.24M 左右。因此适当时候,我们需要对需要加载的图片进行缩略优化。
修改图片加载的 Config
修改占用空间少的存储方式可以快速有效降低图片占用内存。比如通过 BitmapFactory.Options 的 inPreferredConfig 选项,将存储方式设置为 Bitmap.Config.RGB_565。这种存储方式一个像素占用 2 个字节,所以最终占用内存直接减半。如下:
打印日志如下:
I/Bitmap: bitmap size is 1620000
另外 Options 中还有一个 inSampleSize 参数,可以实现 Bitmap 采样压缩,这个参数的含义是宽高维度上每隔 inSampleSize 个像素进行一次采集。比如以下代码:
因为宽高都会进行采样,所以最终图片会被缩略 4 倍,最终打印效果如下:
I/Bitmap: bitmap size is 405000
Bitmap 复用
场景描述
如果在 Android 某个页面创建很多个 Bitmap,比如有两张图片 A 和 B,通过点击某一按钮需要在 ImageView 上切换显示这两张图片,实现效果如下所示:
可以使用以下代码实现上述效果:
class BitmapPoolActivity : AppCompatActivity() {
var reuseBitmap: Bitmap? = null
var resIndex = 0
val resId = arrayOf(R.drawable.rodman, R.drawable.rodman2)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_bitmap_pool)
val options = BitmapFactory.Options()
options.inMutable = true
reuseBitmap = BitmapFactory.decodeResource(resources, resId[0], options)
switchImage.setOnClickListener {
poolImage.setImageBitmap(getBitmap())
}
}
private fun getBitmap(): Bitmap {
val options = BitmapFactory.Options()
return BitmapFactory.decodeResource(resources, resId[resIndex++ % 2], options)
}
}
但是在每次调用 switchImage 切换图片时,都需要通过 BitmapFactory 创建一个新的 Bitmap 对象。当方法执行完毕后,这个 Bitmap 又会被 GC 回收,这就造成不断地创建和销毁比较大的内存对象,从而导致频繁 GC(或者叫内存抖动)。像 Android App 这种面相最终用户交互的产品,如果因为频繁的 GC 造成 UI 界面卡顿,还是会影响到用户体验的。可以在 Android Studio Profiler 中查看内存情况,多次切换图片后,显示的效果如下:
使用 Options.inBitmap 优化
实际上经过第一次显示之后,内存中已经存在了一个 Bitmap 对象。每次切换图片只是显示的内容不一样,我们可以重复利用已经占用内存的 Bitmap 空间,具体做法就是使用 Options.inBitmap 参数。将 getBitmap 方法修改如下:
class BitmapPoolActivity : AppCompatActivity() {
var reuseBitmap: Bitmap? = null
var resIndex = 0
val resId = arrayOf(R.drawable.rodman, R.drawable.rodman2)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_bitmap_pool)
val options = BitmapFactory.Options()
options.inMutable = true
reuseBitmap = BitmapFactory.decodeResource(resources, resId[0], options)
switchImage.setOnClickListener {
poolImage.setImageBitmap(getBitmap())
}
}
private fun getBitmap(): Bitmap {
val options = BitmapFactory.Options()
options.inJustDecodeBounds = true
BitmapFactory.decodeResource(resources, resId[resIndex % 2], options)
if (canUseForInBitmap(reuseBitmap!!, options)) {
Log.e("BitmapPoolActivity", "reuseBitmap is reusable")
options.inMutable = true
options.inBitmap = reuseBitmap
}
options.inJustDecodeBounds = false
return BitmapFactory.decodeResource(resources, resId[resIndex++ % 2], options)
}
private fun canUseForInBitmap(candidate: Bitmap, targetOptions: BitmapFactory.Options): Boolean {
val width = targetOptions.outWidth / Math.max(targetOptions.inSampleSize, 1)
val height = targetOptions.outHeight / Math.max(targetOptions.inSampleSize, 1)
val byteCount: Int = width * height * getBytesPerPixel(candidate.config)
return byteCount <= candidate.allocationByteCount
}
private fun getBytesPerPixel(config: Bitmap.Config): Int {
return when (config) {
Bitmap.Config.ALPHA_8 -> 1
Bitmap.Config.RGB_565,
Bitmap.Config.ARGB_4444 -> 2
else -> 4
}
}
}
解释说明:
- 代码中 var reuseBitmap: Bitmap? = null 处创建一个可以用来复用的 Bitmap 对象。
- 代码中 options.inBitmap = reuseBitmap 处,将 options.inBitmap 赋值为之前创建的 reuseBitmap 对象,从而避免重新分配内存。
重新运行代码,并查看 Profiler 中的内存情况,可以发现不管我们切换图片多少次,内存占用始终处于一个水平线状态。
注意:在上述 getBitmap 方法中,复用 inBitmap 之前,需要调用 canUseForInBitmap 方法来判断 reuseBitmap 是否可以被复用。这是因为 Bitmap 的复用有一定的限制:
- 在 Android 4.4 版本之前,只能重用相同大小的 Bitmap 内存区域;
- 4.4 之后你可以重用任何 Bitmap 的内存区域,只要这块内存比将要分配内存的 bitmap 大就可以。
canUserForInBitmap 方法具体如下:
private fun canUseForInBitmap(candidate: Bitmap, targetOptions: BitmapFactory.Options): Boolean {
val width = targetOptions.outWidth / Math.max(targetOptions.inSampleSize, 1)
val height = targetOptions.outHeight / Math.max(targetOptions.inSampleSize, 1)
val byteCount: Int = width * height * getBytesPerPixel(candidate.config)
//新 Bitmap 内存 < 可复用占用内存
return byteCount <= candidate.allocationByteCount
}
private fun getBytesPerPixel(config: Bitmap.Config): Int {
return when (config) {
Bitmap.Config.ALPHA_8 -> 1
Bitmap.Config.RGB_565,
Bitmap.Config.ARGB_4444 -> 2
else -> 4
}
}
在每次加载之前,除了 inBitmap 参数之外,我还将 Options.inMutable 置为 true,这里如果不置为 true 的话,BitmapFactory 将不会重复利用 Bitmap 内存,并输出相应 warning 日志:
W/BitmapFactory: Unable to reuse an immutable bitmap as an image decoder target.
BitmapRegionDecoder 图片分片显示
有时候我们想要加载显示的图片很大或者很长,比如手机滚动截图功能生成的图片。
针对这种情况,在不压缩图片的前提下,不建议一次性将整张图加载到内存,而是采用分片加载的方式来显示图片部分内容,然后根据手势操作,放大缩小或者移动图片显示区域。
图片分片加载显示主要是使用 Android SDK 中的 BitmapRegionDecoder 来实现。
BitmapRegionDecoder 基本使用
首先需要使用 BitmapRegionDecoder 将图片加载到内存中,图片可以以绝对路径、文件描述符、输入流的方式传递给 BitmapRegionDecoder,如下所示:
/**
* 显示图片左上角 200 * 200 区域
*/
private fun showRegionImage() {
try {
val inputStream = assets.open("rodman3.png")
val decoter = BitmapRegionDecoder.newInstance(inputStream, false)
val options = BitmapFactory.Options()
val bitmap = decoter.decodeRegion(Rect(0, 0, 200, 200), options)
regionImage.setImageBitmap(bitmap)
} catch (e: Exception) {
}
}
运行后显示效果如下:
在此基础上,我们可以通过自定义View,添加 touch 事件来动态地设置 Bitmap 需要显示的区域 Rect。具体实现网上已经有很多成熟的轮子可以直接使用,比如 LargeImageView
。也有一篇比较详细文章对此介绍:Android 高清加载巨图方案。。
Bitmap 缓存
当需要在界面上同时展示一大堆图片的时候,比如 ListView、RecyclerView 等,由于用户不断地上下滑动,某个 Bitmap 可能会被短时间内加载并销毁多次。这种情况下通过使用适当的缓存,可以有效地减缓 GC 频率保证图片加载效率,提高界面的响应速度和流畅性。
最常用的缓存方式就是 LruCache,基本使用方式如下:
解释说明:
图中 1 处指定 LruCache 的最大空间为 20M,当超过 20M 时,LruCache 会根据内部缓存策略将多余 Bitmap 移除。
图中 2 处指定了插入 Bitmap 时的大小,当我们向 LruCache 中插入数据时,LruCache 并不知道每一个对象会占用大多内存,因此需要我们手动指定,并且根据缓存数据的类型不同也会有不同的计算方式。
总结:
详细介绍了 Bitmap 开发中的几个常见问题:
- 一张图片被加载成 Bitmap 后实际占用内存是多大。
- 通过 Options.inBitmap 可以实现 Bitmap 的复用,但是有一定的限制。
- 当界面需要展示多张图片,尤其是在列表视图中,可以考虑使用 Bitmap 缓存。
- 如果需要展示的图片过大,可以考虑使用分片加载的策略