提出问题

先提出一个问题,将两张分辨率相同(48px * 48px),但文件大小不同的 png 图片,放在 drawable-xhdpi 文件夹下,在不同分辨率的手机上,所加载出来的 Bitmap 的占用内存大小分别是多少?

PS. 使用 Bitmap.getByteCount() 所获取的值作为占用内存的大小

通过以下代码获取 Bitmap 所占用的内存大小

1
2
3
4
5
6
7
8
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val bigBitmap = BitmapFactory.decodeResource(resources,R.drawable.big_png)
val smallBitmap = BitmapFactory.decodeResource(resources,R.drawable.small_png)
Log.d(TAG,"small png bitmap size is ${smallBitmap.byteCount}")
Log.d(TAG,"bid png bitmap size is ${bigBitmap.byteCount}")
}

PS. 表格中的 dpi 的通过 resources.displayMetrics.densityDpi 方法获取

图片1 (分辨率 48px * 48px,文件大小 1.24kb)

图片2 (分辨率 48px * 48px,文件大小 1.27kb)

手机 分辨率 dpi 图片1 的 byteCount 图片2 的 byteCount
Nexus S 480 * 800 hdpi/240dpi 5184 5184
Nexus 4 768 * 1280 xhdpi/320dpi 9216 9216
Pixel 1 1080 * 1920 420dpi 15876 15876
小米6 1080 * 1920 480dpi 20736 20736
Nexus 5 1080 * 1920 xxhdpi/480dpi 20736 20736
Pixel 3a 1080 * 2220 440dpi 17424 17424
Pixel XL 1440 * 2560 560dpi 28244 28244

通过以上的数据对比,可见图片1和图片2的即使文件大小不同,但运行时所占的内存都是相同的,因此我们猜测:图片运行时的内存大小和文件大小无关,只与图片的分辨率有关

接下来,我们再将两张图片放到 drawable-xxhdpi 文件夹下,再通过同样的方式进行计算,得出以下表格

手机 分辨率 dpi 图片1 的 byteCount 图片2 的 byteCount
Nexus S 480 * 800 hdpi/240dpi 2304 2304
Nexus 4 768 * 1280 xhdpi/320dpi 4096 4096
Pixel 1 1080 * 1920 420dpi 7056 7056
小米6 1080 * 1920 480dpi 9216 9216
Nexus 5 1080 * 1920 xxhdpi/480dpi 9216 9216
Pixel 3a 1080 * 2220 440dpi 7744 7744
Pixel XL 1440 * 2560 560dpi 12544 12544

这回我们同样发现图片1和图片2所占的内存都是相同的,更加肯定了我们在第一个表格后的猜测

但是,通过表格②中的「Pixel 1、小米6 和 Nexus 5」的纵向对比,三个手机的分辨率均为1080 * 1920,但三个手机的 dpi 分别为 420dpi,480dpi 和 480dpi,占用的内存为 7056、9216 和 9216,因此我们猜测,图片所占内存的大小变化也跟手机的 dpi 有关,跟手机的分辨率无关

源码分析

既然有以上的猜测,我们不如从源码中探索缘由

PS. 以下代码基于 Android 30 版本

在分析 decodeResource 方法之前,我们先来看一些基础概念

基本概念

android.util.DisplayMetrics#density: Float

显示的逻辑密度,这是 dip 单位的比例系数.
一个 dip 大概是 160dpi 屏幕上的的一个像素(例如分辨率240x320.尺寸为 1.5”x2”的屏幕),按这个标准提供显示的基准。
因此在 160dpi 的屏幕上,这个值为1,在120dpi 的屏幕上,这个值为 0.75,在480dpi 的屏幕上,这个值为3.依次类推

1
计算方式:density = densityDpi/160

android.util.DisplayMetrics#densityDpi: Int

缩写为 dpi
dots-per-inch

屏幕密度,表示每英寸屏幕上的像素点个数

1
计算方式为 dpi = 斜边长/英寸

在 Android 设备中,将 densityDpi 将设备分成多个显示级别,如下表

ldpi mdpi hdpi xhdpi xxhdpi
dpi 0-120 120-160 160-320 320-480 480-640
比例 1dp 0.75px 1px 2px 3px 4px

由于 mdpi 中 1dp 刚好等于 1px 所以将 mdpi 作为基准屏幕密度

一般来说设备都会在出厂时设置一个默认的 dpi ,设置其范围内的最大值

dip/dp

全称为 Density Independent Pixel
密度独立像素

1
计算方式为 dip/dp = px / (dpi / 160)

TypeValue

1
2
3
4
5
TypedValue typedValue = new TypedValue();
Resources resources = getResources();
int id = resources.getIdentifier("ic_launcher","mipmap",getPackageName());
resources.openRawResource(id,typedValue);
int density = typedValue.density;

android.util.TypedValue#density: Int

如果是从 resource 中加载的图片等,这个值将会存储相对应的像素密度

例如:
从 ldpi 加载的 density == 120
从 mdpi 加载的 density == 160

decodeResource

decodeResource(Resources res, int id)

1
2
3
public static Bitmap decodeResource(Resources res, int id) {
return decodeResource(res, id, null);
}

decodeResource(Resources res, int id, Options opts)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public static Bitmap decodeResource(Resources res, int id, Options opts) {
validate(opts);
Bitmap bm = null;
InputStream is = null;

try {
final TypedValue value = new TypedValue();
is = res.openRawResource(id, value);

bm = decodeResourceStream(res, value, is, null, opts);
} catch (Exception e) {
/* do nothing.
If the exception happened on open, bm will be null.
If it happened on close, bm is still valid.
*/
} finally {
try {
if (is != null) is.close();
} catch (IOException e) {
// Ignore
}
}

if (bm == null && opts != null && opts.inBitmap != null) {
throw new IllegalArgumentException("Problem decoding into existing bitmap");
}

return bm;
}

-> 最后走到 android.graphics.BitmapFactory#decodeResourceStream 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

public static Bitmap decodeResourceStream(@Nullable Resources res, @Nullable TypedValue value,
@Nullable InputStream is, @Nullable Rect pad, @Nullable Options opts) {
validate(opts);
//如果 opts 为空,则 new 一个 Options 对象,默认为空
if (opts == null) {
opts = new Options();
}

//设置 opts 的 inDensity 参数
if (opts.inDensity == 0 && value != null) {
//只有当 opts.inDensity 为0 且 TypedValue 不为空时才进行赋值

//获取 TypedValue 的 density 值()
final int density = value.density;
if (density == TypedValue.DENSITY_DEFAULT) {
//如果 density 为默认值(0),则设置 inDenisity 为 DisplayMetrics.DENSITY_DEFAULT(160)
opts.inDensity = DisplayMetrics.DENSITY_DEFAULT;
} else if (density != TypedValue.DENSITY_NONE) {
//如果 density 不为 DENSITY_NONE(0xffff)
//只有放在 nodpi 中的图片的 density 会被设置为 DENSITY_NONE
opts.inDensity = density;
}
}

if (opts.inTargetDensity == 0 && res != null) {
//如果 opts 的 isTargetDenisity 为0且 res 不为空,则将设备的 densityDpi 赋值给 inTargetDensity
opts.inTargetDensity = res.getDisplayMetrics().densityDpi;
}

return decodeStream(is, pad, opts);
}

最后走到 decodeStream() 中的 native 方法中对图片进行解码

在 native 代码中,会根据前面的方法中的 BitmapFactory.Options 中设置的参数 inDensity 和 inTargetDensity 参数,对图片进行缩放

简单来说:
inDensity 代表资源文件所在的文件夹 dpi
inTargetDenisity 代表 bitmap 会被绘制的地方的像素密度

BitmapFactory.cpp 中可以看到

1
2
3
4
5
6
7
8
if (env->GetBooleanField(options, gOptions_scaledFieldID)) {
const int density = env->GetIntField(options, gOptions_densityFieldID);
const int targetDensity = env->GetIntField(options, gOptions_targetDensityFieldID);
const int screenDensity = env->GetIntField(options, gOptions_screenDensityFieldID);
if (density != 0 && targetDensity != 0 && density != screenDensity) {
scale = (float) targetDensity / density;
}
}

当 isDenisity 、inTargetDensity 不为0,且 isDenisity != inScreenDensity 时候,会将图片进行缩放

缩放比例为 (float)inTargetDensity/isDenisity

内存大小计算

至此,我们可以回到文章开头的两个表格,来看一下图片所占用的内存是如何计算出来的

在计算之前,我们还要再看一个参数 Bitmap.Config

在 BitmapFactory.Options 中,inPreferredConfig 段默认为 ARGB_8888
该参数代表图片解码时使用的颜色模式

Bitmap.Config 字节数 备注
ALPHA_8 1 每个像素占8bit,存储图片的透明值
RGB_565 2 每个像素占16bit,RGB 通道分别占用5,6,5bit,存储图片的 RGB 值
ARGB_4444(已废弃) 2 每个像素占16bit,即每个通道用4bit表示
ARGB_8888 4 每个像素占32bit,即每个通道用8bit表示
RGBA_F16 8

各个枚举中的数字之和代表其位数,例如 ARGB_8888 则占用 8+8+8+8 = 32bit = 4byte

现在我们可以计算出一张图片在解码后所占用的内存了

内存 = (图片像素宽 * scale ) * (图片像素高 * scale ) * 每个像素点内存占用

其中 scale = (float)inTargetDensity/inDenisity

我们来对上述的表格进行验证
对于表格1中
由于我们将资源文件放在了 xhdpi 文件夹中,所以 inDenisity = 320

  • Nexus S 设备
    scale = (float)240/320 = 0.75
    图片的内存 = (48 * 0.75 ) * (48 * 0.75 ) * 4 = 5184

  • Pixel 1
    scale = (float)420/320 = 1.3125
    图片的内存 = (48 * 1.3125 ) * (48 * 1.3125 ) * 4 = 15876

  • 小米6
    scale = (float)480/320 = 1.5
    图片的内存 = (48 * 1.5 ) * (48 * 1.5 ) * 4 = 20736

同理可以验证表格2 中的数据

内存优化

由以上的 Bitmap 内存占用可知,要优化 bitmap 的内存大小
可以从以下几个方面出发

  1. 将正确的资源图片放到正确的目录下🐶

    通过表格2和表格1的对比,如果本应放在 xxhdpi 下的图片如果放到了 xhdpi 下,会导致 bitmap 占用的内存变大

  2. 对资源图片进行取样,根据所展示的 view 的宽高修改图片解码时采样率,以降低图片的分辨率

eg.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
/**
* ImageView 设置资源图
* 会根据宽高对资源文件的采样率进行压缩,减少内存占用
* @param resId 资源文件 id
* @param width 展示图片的 View 的宽度
* @param height 展示图片的 View 的高度
*/
fun ImageView.setImage(resId: Int,width: Int,height: Int){

val bitmap = BitmapFactory.Options().run {
inJustDecodeBounds = true
BitmapFactory.decodeResource(resources,resId,this)
inSampleSize = calculateInSampleSize(this,width,height)

inJustDecodeBounds = false
BitmapFactory.decodeResource(resources,resId,this)
}
Log.d("ImageViewExt","bitmap size is ${bitmap.byteCount}")
this.setImageBitmap(bitmap)
}

/**
* 根据宽高计算采样率
*/
fun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Int, reqHeight: Int): Int {
// Raw height and width of image
val (height: Int, width: Int) = options.run { outHeight to outWidth }
var inSampleSize = 1

if (height > reqHeight || width > reqWidth) {

val halfHeight: Int = height / 2
val halfWidth: Int = width / 2

// Calculate the largest inSampleSize value that is a power of 2 and keeps both
// height and width larger than the requested height and width.
while (halfHeight / inSampleSize >= reqHeight && halfWidth / inSampleSize >= reqWidth) {
inSampleSize *= 2
}
}

return inSampleSize
}