Bitmap深入分析(一)

0 阅读6分钟

getAllocationByteCount 探索

我们可以通过 Bitmap.getAllocationByteCount() 方法获取 Bitmap 占用的字节大小,比如以下代码:

fun testXhdpi(){
    BitmapFactory.decodeResource(resources,R.mipmap.xhdpi).also {
        Log.e("$TAG", "test bitmap size ${it.allocationByteCount.byteToM()}")
    }
}

上图中 rodman 是保存在 res/mipmap-xhdpi 目录下的一张 960*600,大小为 54.88Kb 的图片。打印结果如下:

11-25 11:25:14.280 5791-5791/com.youdao.bitmaptest E/MainActivity: test bitmap size 2.1972656

解释

默认情况下 BitmapFactory 使用 Bitmap.Config.ARGB_8888 的存储方式来加载图片内容,而在这种存储模式下,每一个像素需要占用 4 个字节。因此上面图片 rodman 的内存大小可以使用如下公式来计算:

宽 * 高 * 4 = 960 * 600 * 4 = 2304000

屏幕自适应

但是如果我们在保证代码不修改的前提下,将图片 rodman 移动到(注意是移动,不是拷贝)res/drawable 目录下,重新运行代码,则打印日志如下:

11-25 11:25:14.269 5791-5791/com.youdao.bitmaptest E/MainActivity: test bitmap size 8.7890625

可以看出我们只是移动了图片的位置,Bitmap 所占用的空间竟然上涨了 4倍。这是为什么呢?

实际上 BitmapFactory 在解析图片的过程中,会根据当前设备屏幕密度和图片所在的 drawable 目录来做一个对比,根据这个对比值进行缩放操作。具体公式为如下所示:

  1. 缩放比例 scale = 当前设备屏幕密度 / 图片所在 drawable 目录对应屏幕密度
  2. Bitmap 实际大小 = 宽 * scale * 高 * scale * Config 对应存储像素数

在 Android 中,各个 drawable 目录对应的屏幕密度分别为下:

目录mdpihdpixhdpixxhdpixxxhdpi
density11.5234
densityDpi160240320480640

我运行的设备是 Nexus 4,屏幕密度为 320。如果将 rodman 放到 drawable-mdpi 目录下,最终的计算公式如下:

实际占用内存大小 = 960 *(320/160) * 600 * (320/160) * 4 内存增大了4倍

assets 中的图片大小

我们知道,Android 中的图片不仅可以保存在 drawable 目录中,还可以保存在 assets 目录下,然后通过 AssetManager 获取图片的输入流。那这种方式加载生成的 Bitmap 是多大呢?同样是上面的 rodman.png,这次将它放到 assets 目录中,使用如下代码加载:

    fun testAssets(){
        var inputStream: InputStream? = null
        try {
            inputStream = assets.open("assets.jpg")
            BitmapFactory.decodeStream(inputStream).also {
                Log.e("$TAG", "testAssets bitmap size ${it.allocationByteCount.byteToM()}")
            }
        }catch (e: Exception){
            e.printStackTrace()
        }finally {
            inputStream?.close()
        }
    }

最终打印结果如下:

11-25 12:06:35.777 11171-11171/com.youdao.bitmaptest E/MainActivity: testAssets bitmap size 2.1972656

可以看出,加载 assets 目录中的图片,系统并不会对其进行缩放操作。

Bitmap 加载优化

修改图片加载的 Config

修改占用空间少的存储方式可以快速有效降低图片占用内存。比如通过 BitmapFactory.Options 的 inPreferredConfig 选项,将存储方式设置为 Bitmap.Config.RGB_565。这种存储方式一个像素占用 2 个字节,所以最终占用内存直接减半.如下

  fun testBitmapCompressConfig(){
        val options = BitmapFactory.Options().apply {
            inPreferredConfig = Bitmap.Config.RGB_565
        }
        BitmapFactory.decodeResource(resources,R.mipmap.xhdpi,options).also {
            Log.e("$TAG", "testBitmapCompressConfig bitmap size ${it.allocationByteCount.byteToM()}")
        }
    }

打印日志如下:

11-25 12:14:05.017 11317-11317/com.youdao.bitmaptest E/MainActivity: testXhdpi bitmap size 1.0986328

另外 Options 中还有一个 inSampleSize 参数,可以实现 Bitmap 采样压缩,这个参数的含义是宽高维度上每隔 inSampleSize 个像素进行一次采集。比如以下代码:

fun testBitmapCompressConfig(){
    val options = BitmapFactory.Options().apply {
        inPreferredConfig = Bitmap.Config.RGB_565
        // 宽和高每2个像素进行一次采集
        inSampleSize = 2
    }
    BitmapFactory.decodeResource(resources,R.mipmap.xhdpi,options).also {
        Log.e("$TAG", "testBitmapCompressConfig bitmap size ${it.allocationByteCount.byteToM()}")
    }
}

因为宽高都会进行采样,所以最终图片会被缩略 4 倍,最终打印效果如下

11-25 12:16:50.242 11432-11432/com.youdao.bitmaptest E/MainActivity: testBitmapCompressConfig bitmap size 0.2746582

Bitmap 复用

通过下方的例子看出,如果我们频繁的切换图片,会造成内存抖动


findViewById<View>(R.id.bt_iamge3).setOnClickListener {
    val smallBitmap = getBitmap()
    ivPreview.setImageBitmap(smallBitmap)
}
    
private fun getBitmap(): Bitmap{
    return BitmapFactory.decodeResource(resources, resIds[resIndex++ % 2])
}

截屏2023-11-07 10.52.12.png

使用 Options.inBitmap 优化

实际上经过第一次显示之后,内存中已经存在了一个 Bitmap 对象。每次切换图片只是显示的内容不一样,我们可以重复利用已经占用内存的 Bitmap 空间,具体做法就是使用 Options.inBitmap 参数。将 getBitmap 方法修改如下:

// code 1
    val options = BitmapFactory.Options()
    // 必须设置该属性,否则会重用失败
    options.inMutable = true
    val reuseBitmap = BitmapFactory.decodeResource(resources, R.mipmap.xhdpi,options)


   findViewById<View>(R.id.bt_iamge3).setOnClickListener {
        val smallBitmap = getBitmap()
        Log.e(TAG, "reuseBitmap before size ${reuseBitmap?.allocationByteCount?.byteToM()}")
        Log.e(TAG, "smallBitmap size ${smallBitmap?.allocationByteCount?.byteToM()}")
        Log.e(TAG, "reuseBitmap after size ${reuseBitmap?.allocationByteCount?.byteToM()}")
        ivPreview.setImageBitmap(smallBitmap)
    }


    private fun getReuseBitmap(): Bitmap? {
        val options = BitmapFactory.Options()
        options.inJustDecodeBounds = true
        BitmapFactory.decodeResource(resources, resIds[resIndex % 2], options)
        if (canUseForInBitmap(reuseBitmap!!, options)) {
            Log.e(TAG, "reuseBitmap is reusable")
            options.inMutable = true
            // code 2
            options.inBitmap = reuseBitmap
        }
        options.inJustDecodeBounds = false
        return BitmapFactory.decodeResource(resources, resIds[resIndex++ % 2], options)
    }


    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 = 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
        }
    }

解释说明:

code 1 处创建一个可以用来复用的 Bitmap 对象。

code 2 处,将 options.inBitmap 赋值为之前创建的 reuseBitmap 对象,从而避免重新分配内存。

截屏2023-11-07 10.54.50.png

注意:

在上述 getBitmap 方法中,复用 inBitmap 之前,需要调用 canUseForInBitmap 方法来判断 reuseBitmap 是否可以被复用。这是因为 Bitmap 的复用有一定的限制:

在 Android 4.4 版本之前,只能重用相同大小的 Bitmap 内存区域; 4.4 之后你可以重用任何 Bitmap 的内存区域,只要这块内存比将要分配内存的 bitmap 大就可以。

在每次加载之前,除了 inBitmap 参数之外,将 Options.inMutable 置为 true,这里如果不置为 true 的话,BitmapFactory 将不会重复利用 Bitmap 内存,并输出相应 warning 日志:

Unable to reuse an immutable bitmap as an image decoder target.

BitmapRegionDecoder 图片分片显示

有时候我们想要加载显示的图片很大或者很长,比如手机滚动截图功能生成的图片。

针对这种情况,在不压缩图片的前提下,不建议一次性将整张图加载到内存,而是采用分片加载的方式来显示图片部分内容,然后根据手势操作,放大缩小或者移动图片显示区域。

BitmapRegionDecoder 基本使用

private fun showRegionImage() {
        try {
            val inputStream = assets.open("assets.jpg")
            //获得图片的宽、高
            val tmpOptions = BitmapFactory.Options()
            tmpOptions.inJustDecodeBounds = true
            BitmapFactory.decodeStream(inputStream, null, tmpOptions)
            val width = tmpOptions.outWidth
            val height = tmpOptions.outHeight
            //设置显示图片的中心区域
            val decoder = BitmapRegionDecoder.newInstance(inputStream, false)
            val options = BitmapFactory.Options()
            options.inPreferredConfig = Bitmap.Config.RGB_565
            val addLength = factor++ * 5
            val bitmap = decoder.decodeRegion(Rect(addLength, addLength, width/2 + addLength, height/2 + addLength), options)
            ivPreview.setImageBitmap(bitmap)
        } catch (e: IOException) {
        }
    }

分享总结 :

  • 在复用一个Bitmap 时候一定要调用canUseForInBitmap() 方法进行检查能否复用 ,否则会发生异常.
  • 加载一个newBitmap 去复用reuseBitmap时,如果复用成功,那么加载一个newBitmap == reuseBitmap ,源码如下:
gOptions_bitmapFieldID = GetFieldIDOrDie(env, options_class, "inBitmap",
            "Landroid/graphics/Bitmap;");

    ...            
javaBitmap = env->GetObjectField(options, gOptions_bitmapFieldID);
    ...
if (javaBitmap != nullptr) {
    bitmap::reinitBitmap(env, javaBitmap, outputBitmap.info(), isPremultiplied);
    outputBitmap.notifyPixelsChanged();
    '//可以看到如果复用成功后 ,返回的是我们一开始设置的复用Bitmap对象'
    // If a java bitmap was passed in for reuse, pass it back
    return javaBitmap;
}