Android Bitmap 使用详解及加载优化

每一个 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) {
        }
    }

运行后显示效果如下:


1598321470204.jpg

在此基础上,我们可以通过自定义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 缓存。
  • 如果需要展示的图片过大,可以考虑使用分片加载的策略

你可能感兴趣的:(Android Bitmap 使用详解及加载优化)