OfferKiller | Bitmap怎么这么吃内存?盘它!

916 阅读9分钟

提醒:本文稍长,建议先关注和收藏。

一张图,毁十优

在《Android移动性能实战》中有句话:“一张图,毁十优”。意思是一张图片的常驻内存,会造成十次优化的结果都白费。

为什么这么说呢?我们来做个测试。

我首先准备了一张 800*450 分辨率的 jpg 图片,大小约为 49.6KB ,放在项目的 res/drawable 文件夹下:

image.png

并将其加载一个 400dp * 200dp 大小的 ImageView 中,使用的 API 是 BitmapFactory.decodeResource()

    Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.dijia);
    imageView.setImageBitmap(bitmap);

我们来看看图片显示前后的内存变化:

image.png

可以看到,这么一张小小的图片,显示在小小的 ImageView 中,显示出来后,一下子吃掉了 12.9 MB 的 Java 内存!

我们再通过系统提供的 api 获取下 bitmap 大小:

    bitmap.getByteCount();

通过 api 得到的 bitmap 大小为 12.96 MB,与 Java 内存增长量一致,这说明 Java 内存消耗量的陡然提升,确实是这张图片引起的。

试想,如果这样的图片发生了内存泄漏,简直是噩梦啊。

我们知道,一个简单的 Activity 界面发生内存泄漏,通常泄漏大小在十几 KB 到1MB 之间;而图片发生内存泄漏,泄漏大小则会达到几十 KB 甚至几十 MB 。

这就是所谓的 “一张图,毁十优”。

那,小小的图片是怎么吃掉这么多的内存的?吃掉的内存大小又与哪些因素有关呢?

吃多少内存

不难想象,图片消耗内存的大小,会与多种因素有关。我们采用“控制变量”的方法把测试程序跑起来看看情况。

Bitmap 像素格式

我们常见的 bitmap 像素格式有以下几种:

  • RGB_565:每个像素使用2字节,只有RGB通道被解码——R通道5位,G通道6位,B通道5位,合计16位;

  • ARGB_8888:每个像素使用4字节,ARGB 4通道每个通道8位,合计32位;

  • ARGB_4444:质量太差,被 Android 官方弃用,官方建议更换 ARGB_8888;

  • ALPHA_8:只有 A 通道(即透明通道);

  • HARDWARE:Android 8.0 新增,bitmap 存储在 Graphic Memory 中。本文后面还会涉及。

重点看下 RGB_565ARGB_8888,他们的区别有3点需要关注:

  • 1、RGB_565每个像素使用2字节,ARGB_8888每个像素使用4字节,所占用的内存空间差2倍;

  • 2、RGB_565没有A通道(alpha),不支持透明度,而ARGB_8888支持;

  • 3、RGB_565的 RGB 3 通道占用的位数是5~6位,ARGB_8888的 RGB 3通道占用的位数是8位,位数越多,所显示的颜色效果自然越好。

第一次测试,控制手机型号(OPPO r9s)和图片放置的资源文件夹位置(res/drawable)不变,我们单单改变 bitmap 的像素格式:

    BitmapFactory.Options options = new BitmapFactory.Options();
    // 默认 ARGB_8888,下面这行代码可将其修改为 RGB_565
    // options.inPreferredConfig = Bitmap.Config.RGB_565;
    Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.mipmap.dijia, options);
    imageView.setImageBitmap(bitmap);

看看测试结果:

image.png

可以看到,ARGB_8888 占用内存 12.96MB,RGB_565 占用内存 6.48MB,确实差了2倍。

资源文件夹目录

不同的资源文件夹目录,对应的显示密度不同,显示密度和设备分辨率密度结合,最终决定了图片宽高方向的缩放比例。

看一看资源文件夹目录与显示密度的对应关系吧:

目录名称显示密度(densityDpi)备注
res/drawable160与 res/mipmap-mdpi 一致
res/mipmap-ldpi120
res/mipmap-mdpi160
res/mipmap-hdpi240
res/mipmap-xhdpi320
res/mipmap-xxhdpi480
res/mipmap-xxxhdpi640

第二次测试,控制手机型号(OPPO r9s)和像素格式(ARGB_8888)不变,单单改变图片放置的资源文件夹位置:

image.png

可以看到,当放在不同的资源文件夹中时,bitmap 的宽高发生了变化,占用的内存随之发生了变化。

设备分辨率

APP 运行的设备的分辨率不同,其对 bitmap 最终显示的宽高也会产生影响。

第三次测试,控制像素格式(ARGB_8888)和放置的资源文件夹位置(res/mipmap-mdpi)不变,单单改变测试用的手机型号:

image.png

可以看到,两款手机的屏幕密度是不同的,bitmap 的宽高和占用的内存也发生了变化。

测试结果

从以上几次测试中,首先能够得到这么一个简单的公式:

bitmap占用内存 ≈ 像素数据总大小 = bitmap宽 × bitmap高 × 每个像素占用的字节大小

但是我们发现,bitmap 的宽高,与原资源图的宽高分辨率不一致,其由资源文件夹目录分辨率和设备分辨率共同决定,并有这么一个简单的公式:

bitmap宽 = 原图宽 × (设备分辨率 / 资源目录分辨率)

所以,我们能够得出 bitmap 占用内存的公式:

bitmap占用内存 ≈ 像素数据总大小 = 原图宽 × 原图高 × (设备分辨率 / 资源目录分辨率)^2 × 每个像素占用的字节大小

我们通过这个公式计算出来的 bitmap 占用内存大小,与系统 api bitmap.getByteCount() 得到的 bitmap 占用内存大小一致。

通过这几个测试,我们知道 bitmap 占用内存大小与资源目录分辨率成反比,与其他因素成正比

那怎样才能将 bitmap 占用的内存降低呢?

能不能少吃点内存

想要让 bitmap 少吃点内存,我们可以从以下几方面做工作。

Bitmap 像素格式

如前所述,不同的像素格式下每个像素占用字节大小不同,RGB_565 比 ARGB_8888 节省一半内存。

如果使用图片的地方对图片质量要求不高,可以采用 RGB_565 的像素格式。

比如说,当我们要使用的是缩略图、模糊图等。

资源文件夹目录

通过上面的实验和得出的公式可知,图片放在高分辨率的资源文件夹中,更节省内存。

但是,如果将小图片放在高分辨率的资源文件夹中,加载时将会被拉伸,出现失真现象。

所以,在 APK 包体积允许的情况下,同一张图片应该提供尽可能多的分辨率,以便放在相应分辨率的资源文件夹中,尤其要提供高分辨率资源文件夹所需的图片。

采样压缩

采样压缩,就是从 bitmap 的全部像素中取出部分像素进行显示。像素数少了, bitmap 占用的内存自然就低了。

那什么时候可以采样压缩呢?

简单来说就是,当原图尺寸,超出要显示的目标区域的尺寸时,就可以采样压缩了。

BitmapFactory.Options 类中有一个属性 inSampleSize 控制采样率:

/**
    * If set to a value > 1, requests the decoder to subsample the original
    * image, returning a smaller image to save memory. The sample size is
    * the number of pixels in either dimension that correspond to a single
    * pixel in the decoded bitmap. For example, inSampleSize == 4 returns
    * an image that is 1/4 the width/height of the original, and 1/16 the
    * number of pixels. Any value <= 1 is treated the same as 1. Note: the
    * decoder uses a final value based on powers of 2, any other value will
    * be rounded down to the nearest power of 2.
    */
    public int inSampleSize;

我们将其注解简单翻译一下:

  • 1、如果 inSampleSize 大于1,原图就会被采样压缩以节省内存。

    例如,inSampleSize == 4,则横向和纵向像素各采样 1/4,最终取到的像素数是原像素数的1/16。

  • 2、inSampleSize 的值必须是2的幂值,如果不是2的幂值就会被向下取值取最近的2的幂值。

所以,我们采样压缩的重点是计算出采样率 inSampleSize

    /**
     * 计算采样率
     *
     * @param options   bitmap options
     * @param maxWidth  目标区域的最大宽度(px)
     * @param maxHeight 目标区域的最大高度(px)
     * @return the sample size
     */
    public static int calculateInSampleSize(final BitmapFactory.Options options,
                                             final int maxWidth,
                                             final int maxHeight) {
        // bitmap 原纵向像素数
        int height = options.outHeight;
        // bitmap 原横向像素数
        int width = options.outWidth;
        int inSampleSize = 1;
        while (height > maxHeight || width > maxWidth) {
            height >>= 1;
            width >>= 1;
            inSampleSize <<= 1;
        }
        return inSampleSize;
    }

计算出采样率,就可以对 bitmap 采样压缩得到压缩后的小 bitmap 了。

    /**
     * 对原 bitmap 采样压缩
     *
     * @param res       The resources object containing the image data
     * @param resId     The resource id of the image data
     * @param maxWidth  目标区域的最大宽度(px)
     * @param maxHeight 目标区域的最大高度(px)
     * @return 采样压缩后的 bitmap
     */
    public static Bitmap decodeSampledBitmapFromResource(Resources res, int resId, int maxWidth, int maxHeight) {

        BitmapFactory.Options options = new BitmapFactory.Options();
        // 要求解码器只取 bitmap 边界,不取其中的像素(目的仅为了取得原图尺寸)
        options.inJustDecodeBounds = true;
        // 解码取得原图尺寸
        BitmapFactory.decodeResource(res, resId, options);

        // 根据原图尺寸和目标区域尺寸,计算采样压缩率
        options.inSampleSize = calculateInSampleSize(options, maxWidth, maxHeight);

        // 根据计算出的 inSampleSize 来解码图片生成最终的 bitmap
        options.inJustDecodeBounds = false;
        return BitmapFactory.decodeResource(res, resId, options);
    }

上述方法入参的宽高的单位是 pxdppx 间存在一定比例关系,比例关系与设备的 density 有关。

densityDpi160240320480560640
density11.5233.54
/**
 * Value of dp to value of px.
 *
 * @param dpValue The value of dp.
 * @return value of px
 */
public static int dp2px(Context context, final float dpValue) {
    final float scale =
            context.getResources().getDisplayMetrics().density;
    return (int) (dpValue * scale + 0.5f);
}

为了测试的效果,我将 ImageView 的宽高设为 200dp * 100dp,计算出来的 inSampleSize 值为 2,则 bitmap 占用内存将被采样压缩为原图大小的 1/4。

image.png

HARDWARE

HARDWARE 在 Android 8.0 及以上的设备上,是一种很好的解决 bitmap 消耗大量 Java 内存的方式,因为其将像素数据从 Java 内存 转移到了 Graphic Memory 中。

我在 小米 MIX2(Android 9.0) 做了测试,下面是图片显示前后的内存变化:

image.png

可以看到,在图片显示前后,Java 内存保持在 7.5 MB 没有变化,Graphics Memory 和 Native 内存的消耗量陡升。

还有一点要说明的是,HARDWARE 严格意义上讲,并不是一种像素格式,其代表的是 bitmap 像素数据存储的位置,其内部使用的像素格式依然是 ARGB_8888。

png?jpg?

有的小伙伴可能会想,通过把 png 图片压缩成 jpg 图片的方式,也能节省内存吧?

NO!NO!NO!

图片格式是 png 或者 jpg,描述的是文件系统中存储的格式,节省的空间也是文件系统中的空间。

换言之,png 换成 jpg,可能会使 apk 包体积减小,却无法使图片显示时占用的 Java 内存减少。

只要他们的分辨率一样,显示时占用的 Java 内存就是一样的。

感兴趣的小伙伴可以自行测试,这里就不贴测试图了。

结语

bitmap 在 Android 中是“内存消耗大户”,今天我们先测试了不同情况下 bitmap 吃掉多少内存,又分析了怎么让它少吃内存,把它 “盘” 的服服帖帖的,以后开发中再涉及 bitmap 问题,我们就不怕不怕啦!

另外,大家可能注意到本文标题有个前缀 “OfferKiller”,OfferKiller 是我们组织起来的学习小组,我在学习小组一期接的任务是 “Glide大图加载、缓存及进度显示”,本篇文章算是打前站了。

如果有小伙伴对 OfferKiller 学习小组 感兴趣或者想要加入,请关注我的公众号,回复“OfferKiller”了解详情,欢迎!