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