提醒:本文稍长,建议先关注和收藏。
一张图,毁十优
在《Android移动性能实战》中有句话:“一张图,毁十优”。意思是一张图片的常驻内存,会造成十次优化的结果都白费。
为什么这么说呢?我们来做个测试。
我首先准备了一张 800*450 分辨率的 jpg 图片,大小约为 49.6KB ,放在项目的 res/drawable
文件夹下:
并将其加载一个 400dp * 200dp 大小的 ImageView
中,使用的 API 是 BitmapFactory.decodeResource()
:
Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.dijia);
imageView.setImageBitmap(bitmap);
我们来看看图片显示前后的内存变化:
可以看到,这么一张小小的图片,显示在小小的 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_565
和 ARGB_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);
看看测试结果:
可以看到,ARGB_8888 占用内存 12.96MB,RGB_565 占用内存 6.48MB,确实差了2倍。
资源文件夹目录
不同的资源文件夹目录,对应的显示密度不同,显示密度和设备分辨率密度结合,最终决定了图片宽高方向的缩放比例。
看一看资源文件夹目录与显示密度的对应关系吧:
目录名称 | 显示密度(densityDpi) | 备注 |
---|---|---|
res/drawable | 160 | 与 res/mipmap-mdpi 一致 |
res/mipmap-ldpi | 120 | |
res/mipmap-mdpi | 160 | |
res/mipmap-hdpi | 240 | |
res/mipmap-xhdpi | 320 | |
res/mipmap-xxhdpi | 480 | |
res/mipmap-xxxhdpi | 640 |
第二次测试,控制手机型号(OPPO r9s
)和像素格式(ARGB_8888
)不变,单单改变图片放置的资源文件夹位置:
可以看到,当放在不同的资源文件夹中时,bitmap 的宽高发生了变化,占用的内存随之发生了变化。
设备分辨率
APP 运行的设备的分辨率不同,其对 bitmap 最终显示的宽高也会产生影响。
第三次测试,控制像素格式(ARGB_8888
)和放置的资源文件夹位置(res/mipmap-mdpi
)不变,单单改变测试用的手机型号:
可以看到,两款手机的屏幕密度是不同的,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);
}
上述方法入参的宽高的单位是 px
,dp
与 px
间存在一定比例关系,比例关系与设备的 density
有关。
densityDpi | 160 | 240 | 320 | 480 | 560 | 640 |
---|---|---|---|---|---|---|
density | 1 | 1.5 | 2 | 3 | 3.5 | 4 |
/**
* 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。
HARDWARE
HARDWARE 在 Android 8.0 及以上的设备上,是一种很好的解决 bitmap 消耗大量 Java 内存的方式,因为其将像素数据从 Java 内存 转移到了 Graphic Memory 中。
我在 小米 MIX2(Android 9.0)
做了测试,下面是图片显示前后的内存变化:
可以看到,在图片显示前后,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”了解详情,欢迎!