浅析Bitmap占据内存大小


        Bitmap的使用是开发时绕不过的坑,使用时要处处留意内存问题,稍有不慎就会报OOM(out of memory)。所以这次我们就研究研究程序中Bitmap到底占据多少内存。

前奏

        比如我们使用一张图片,将其放入到工程目录中,想当然的会以为为这张图片建立的bitmap使用内存大小为:宽×高×像素大小。为了验证这个猜想,我在度娘上随便找了幅图:


        规格是768×1152,大小为153.3KB,格式为支持ARGB四阶的32位色的JPG图片。
        我们猜想,如果按照内存大小计算公式,所占内存应为:768×1152×4=3538944,字节。因为JPG格式是有损压缩格式,所以存储大小比内存大小小多了。
        然后将这张图片放到res/drawable-xhdpi下,通过如下代码计算内存大小:
1
2
3
4
5
6
7
8
9
10
11
float density = this.getResources().getDisplayMetrics().density;
int dpi = this.getResources().getDisplayMetrics().densityDpi;
Log.e(TAG, "density = " + density + "------" + "dpi = " + dpi);

Bitmap b = BitmapFactory.decodeResource(getResources(), R.drawable.picture);
int w = b.getWidth();
int h = b.getHeight();
int size = b.getByteCount();
int config = b.getConfig().ordinal();

Log.e(TAG, "w = " + w + ";" + "h = " + h + ";" + "size = " + size + ";" + "config = " + config);

       测试机器规格为:Google Nexus 5 - 5.1.0 - API 22 - 1080×1920(480dpi)
       打印log如下:

density = 3.0——dpi = 480
w = 1152;h = 1728;size = 7962624;config = 3


       Why?How did you do it?这个不按套路出牌啊,宽高明显被拉伸了啊。。。。。。然后我又试了下将这张图片放到了res/drawable-xxhdpi下,打印log如下:

density = 3.0——dpi = 480
w = 768;h = 1152;size = 3538944;config = 3

       这次倒是和理论计算的大小一样了,我们大概猜到了什么。。。。。接着我又把这张图片放到了assets目录下,然后修改了一下获取图片的代码,打印log如下:

density = 3.0——dpi = 480
w = 768;h = 1152;size = 3538944;config = 3

       这次也是和理论值一样的,因为放到assets目录下的图片是不会被压缩的。

       如果多试几次,把图片放入不同目录下再运行几遍,我们也能够总结出规律的。但这些都是现象,我们组的老大也曾经说过:开发人员不要轻易根据现象得出结论…….所以我们也要分析一下本质原因。

求证

       做适配的同学要经常和density、densityDpi搞好关系,简单来说,可以理解为 density 的数值是 1dp=density px;densityDpi 是屏幕每英寸对应多少个点(不是像素点),在 DisplayMetrics 当中,这两个的关系是线性的:

density 0.75 1 1.5 2 3 3.5 4
densityDpi 120 160 240 320 480 560 640
DpiFolder ldpi mdpi hdpi xhdpi xxhdpi xxxhdpi xxxxhdpi

       这些内容每个人应该都知道,先放到这里,方便后面查表。

非压缩计算

       如果图片不被压缩,按照常规计算内存大小方法为:

1
2
3
4
5
6
7
8
9
10
//Bitmap的getByteCount方法
public final int getByteCount() {
// int result permits bitmaps up to 46,340 x 46,340
return getRowBytes() * getHeight();
}
//Bitmap的getRowBytes方法
public final int getRowBytes() {
return nativeRowBytes(mNativeBitmap);
}
private static native int nativeRowBytes(long nativeBitmap);

       getHeight 就是图片的高度(单位:px),getRowBytes 从字面意思看应该是行字节大小。我们往下看,找找JNI实现,查看 frameworks/base/core/jni/android/graphics/Bitmap.cpp文件:

1
2
3
4
static jint Bitmap_rowBytes(JNIEnv* env, jobject, jlong bitmapHandle) {
SkBitmap* bitmap = reinterpret_cast<SkBitmap*>(bitmapHandle);
return static_cast<jint>(bitmap->rowBytes());
}

       (reinterpret_cast和static_cast是C++经常用到的用来处理无关类型之间转换的强制类型转换符,建议有时间可以研究研究,或者把C++回顾一下,毕竟挺重要的。这里先给个科普文章
       上一篇关于的弹幕文章提到过,java层的Bitmap对应native层是由skia图形引擎创建的SkBitmap,关于skia这玩意儿东西比较多,不是专业的一时半会儿也玩不转。所以我们还是简单看看,继续往下找SkBitmap:(/external/skia/include/core/SkBitmap.h)

1
2
/** Return the number of bytes between subsequent rows of the bitmap. */
size_t rowBytes() const { return fRowBytes; }

       得到上述fRowBytes的大小会在SkBitmap.cpp文件里计算:(/external/skia/src/core/SkBitmap.cpp)

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
//计算fRowBytes大小
size_t SkBitmap::ComputeRowBytes(Config c, int width) {
return SkColorTypeMinRowBytes(SkBitmapConfigToColorType(c), width);//SkColorTypeMinRowBytes是/SkImageInfo.h的方法;SkBitmapConfigToColorType是SkImagePriv.cpp的方法
}
//SkImageInfo.h的SkColorTypeMinRowBytes方法
static inline size_t SkColorTypeMinRowBytes(SkColorType ct, int width) {
return width * SkColorTypeBytesPerPixel(ct);
}
//SkImageInfo.h的SkColorTypeBytesPerPixel方法
static int SkColorTypeBytesPerPixel(SkColorType ct) {
static const uint8_t gSize[] = {
0, // Unknown
1, // Alpha_8
2, // RGB_565
2, // ARGB_4444
4, // RGBA_8888
4, // BGRA_8888
1, // kIndex_8
};
...省略障眼法的宏...
return gSize[ct];
}
//SkBitmapConfigToColorType是SkImagePriv.cpp的方法
SkColorType SkBitmapConfigToColorType(SkBitmap::Config config) {
static const SkColorType gCT[] = {
kUnknown_SkColorType, // kNo_Config
kAlpha_8_SkColorType, // kA8_Config
kIndex_8_SkColorType, // kIndex8_Config
kRGB_565_SkColorType, // kRGB_565_Config
kARGB_4444_SkColorType, // kARGB_4444_Config
kN32_SkColorType, // kARGB_8888_Config
};
SkASSERT((unsigned)config < SK_ARRAY_COUNT(gCT));
return gCT[config];
}

       跟踪到这里,还记得我们上面大log的地方么。int config = b.getConfig().ordinal()返回的是3,那么在Bitmap.Config里面索引第4个枚举变量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public enum Config {
ALPHA_8 (1),
RGB_565 (3),
ARGB_4444 (4),
ARGB_8888 (5);//索引第四个是这个

final int nativeInt;

//从这个列表可以看出它与skia支持的图片格式一一对应,但是Android只支持上面4种
private static Config sConfigs[] = {
null, ALPHA_8, null, RGB_565, ARGB_4444, ARGB_8888
};
Config(int ni) {
this.nativeInt = ni;
}

static Config nativeToConfig(int ni) {
return sConfigs[ni];
}
}

       依照上面C++文件,我们发现 ARGB_8888(也就是我们最常用的 Bitmap 的格式)的一个像素占用 4byte,那么 rowBytes 实际上就是 4*width bytes。则理论上 ARGB_8888 的 Bitmap 占用内存的计算公式为:

bitmapInRam = bitmapWidth × bitmapHeight × 4 bytes

压缩计算

       如果我们不将图片放到assets目录下,内存大小计算方式就和上面完全不同了。我们读取的是 drawable 目录下面的图片,用的是 decodeResource 方法,该方法本质上就两步:

  • 读取原始资源,这个调用了 Resource.openRawResource 方法,这个方法调用完成之后会对 TypedValue 进行赋值,其中包含了原始资源的 density 等信息;
  • 调用 decodeResourceStream 对原始资源进行解码和适配。这个过程实际上就是原始资源的 density 到屏幕 density 的一个映射。
           原始资源的 density 其实取决于资源存放的目录(比如 xxhdpi 对应的是480),而屏幕 density 的赋值,请看下面这段代码:
    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
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58

    public static Bitmap decodeResource(Resources res, int id) {
    return decodeResource(res, id, null);
    }
    public static Bitmap decodeResource(Resources res, int id, Options opts) {
    Bitmap bm = null;
    InputStream is = null;

    try {
    final TypedValue value = new TypedValue();
    is = res.openRawResource(id, value);//对 TypedValue 进行赋值,其中包含了原始资源的 density 等信息

    bm = decodeResourceStream(res, value, is, null, opts);
    } catch (Exception e) {
    ......
    } finally{
    ......
    }

    ......

    return bm;
    }
    public static Bitmap decodeResourceStream(Resources res, TypedValue value,
    InputStream is, Rect pad, Options opts)
    {


    if (opts == null) {//opt为null
    opts = new Options();
    }

    if (opts.inDensity == 0 && value != null) {
    final int density = value.density;
    if (density == TypedValue.DENSITY_DEFAULT) {
    opts.inDensity = DisplayMetrics.DENSITY_DEFAULT;
    } else if (density != TypedValue.DENSITY_NONE) {
    opts.inDensity = density;//这里density的值如果对应资源目录为xhdpi的话,就是320
    }
    }

    if (opts.inTargetDensity == 0 && res != null) {
    //请注意,inTargetDensity就是当前的显示密度,比如Google Nexus 5就是480
    opts.inTargetDensity = res.getDisplayMetrics().densityDpi;
    }

    return decodeStream(is, pad, opts);
    }
    public static Bitmap decodeStream(InputStream is, Rect outPadding, Options opts) {
    ......
    bm = decodeStreamInternal(is, outPadding, opts);
    ......
    return bm;
    }
    private static Bitmap decodeStreamInternal(InputStream is, Rect outPadding, Options opts) {
    ......
    return nativeDecodeStream(is, tempStorage, outPadding, opts);
    }
    private static native Bitmap nativeDecodeStream(InputStream is, byte[] storage,
    Rect padding, Options opts)
    ;

       我们看到 opts 这个值被初始化,而它的构造居然如此简单:

1
2
3
4
5
public Options() {
inDither = false;
inScaled = true;
inPremultiplied = true;
}

       所以我们就很容易的看到,Option.inScreenDensity 这个值没有被初始化,而实际上后面我们也会看到这个值根本不会用到;我们最应该关心的是什么呢?是 inDensity 和 inTargetDensity,这两个值与下面 cpp 文件里面的 density 和 targetDensity 相对应——重复一下,inDensity 就是原始资源的 density,inTargetDensity 就是屏幕的 density。
       紧接着,用到了 nativeDecodeStream 方法:

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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
static jobject nativeDecodeStream(JNIEnv* env, jobject clazz, jobject is, jbyteArray storage,
jobject padding, jobject options)
{


jobject bitmap = NULL;
......
bitmap = doDecode(env, bufferedStream, padding, options);

return bitmap;
}
static jobject doDecode(JNIEnv* env, SkStreamRewindable* stream, jobject padding, jobject options) {

......
if (env->GetBooleanField(options, gOptions_scaledFieldID)) {
const int density = env->GetIntField(options, gOptions_densityFieldID);//对应xhdpi的时候,是320
const int targetDensity = env->GetIntField(options, gOptions_targetDensityFieldID);//Google Nexus 5为480
const int screenDensity = env->GetIntField(options, gOptions_screenDensityFieldID);
if (density != 0 && targetDensity != 0 && density != screenDensity) {
scale = (float) targetDensity / density;
}
}
}

const bool willScale = scale != 1.0f;
......
SkBitmap decodingBitmap;
if (!decoder->decode(stream, &decodingBitmap, prefColorType,decodeMode)) {
return nullObjectReturn("decoder->decode returned false");
}
//这里这个deodingBitmap就是解码出来的bitmap,大小是图片原始的大小
int scaledWidth = decodingBitmap.width();
int scaledHeight = decodingBitmap.height();
if (willScale && decodeMode != SkImageDecoder::kDecodeBounds_Mode) {
scaledWidth = int(scaledWidth * scale + 0.5f);
scaledHeight = int(scaledHeight * scale + 0.5f);
}
if (willScale) {
const float sx = scaledWidth / float(decodingBitmap.width());
const float sy = scaledHeight / float(decodingBitmap.height());

// TODO: avoid copying when scaled size equals decodingBitmap size
SkColorType colorType = colorTypeForScaledOutput(decodingBitmap.colorType());
// FIXME: If the alphaType is kUnpremul and the image has alpha, the
// colors may not be correct, since Skia does not yet support drawing
// to/from unpremultiplied bitmaps.
outputBitmap->setInfo(SkImageInfo::Make(scaledWidth, scaledHeight,
colorType, decodingBitmap.alphaType()));
if (!outputBitmap->allocPixels(outputAllocator, NULL)) {
return nullObjectReturn("allocation failed for scaled bitmap");
}

// If outputBitmap's pixels are newly allocated by Java, there is no need
// to erase to 0, since the pixels were initialized to 0.
if (outputAllocator != &javaAllocator) {
outputBitmap->eraseColor(0);
}

SkPaint paint;
paint.setFilterLevel(SkPaint::kLow_FilterLevel);

SkCanvas canvas(*outputBitmap);
canvas.scale(sx, sy);
canvas.drawBitmap(decodingBitmap, 0.0f, 0.0f, &paint);
}
......
}

       注意到其中有个 density 和 targetDensity,前者是 decodingBitmap 的 density,这个值跟这张图片的放置的目录有关(比如 xhdpi 是320,xxhdpi 是480),这部分代码我跟了一下,太长了,就不列出来了;targetDensity 实际上是我们加载图片的目标 density,这个值的来源我们已经在前面给出了,就是 DisplayMetrics 的 densityDpi,如果是Google Nexus 5那么这个数值就是480。sx 和sy 实际上是约等于 scale 的,因为 scaledWidth 和 scaledHeight 是由 width 和 height 乘以 scale 得到的。我们看到 Canvas 放大了 scale 倍,然后又把读到内存的这张 bitmap 画上去,相当于把这张 bitmap 放大了 scale 倍。

       然后我们再次验证上面打log的地方,win + r ,输入calc呼出计算器。这里千万不要忘了了精度

float scale = 480/320f = 1.5
int scaledWidth = int(768 * 1.5 + 0.5f) = 1152
int scaledHeight = int(1152 * 1.5 + 0.5f) = 1728

size = 1152 1728 4 = 7962624

       果然和上面log打印的一模一样!因此我们可以得出结论。Bitmap在内存中大小取决于:

  • 色彩格式,前面我们已经提到,如果是 ARG_B8888 那么就是一个像素4个字节,如果是 RGB_565 那就是2个字节
  • 原始文件存放的资源目录(是 hdpi 还是 xxhdpi 等等)
  • 目标屏幕的密度(所以同等条件下,红米在资源方面消耗的内存肯定是要小于三星S6的)

       内存大小计算公式大概为(压缩计算情况下)(已忽略精度):

内存大小 = (设备屏幕dpi / 资源所在目录dpi)^ 2 × 图片原始宽 × 图片原始高 × 像素大小

瞎猜

       上面分析Bitmap.Config时发现Android官方并不完全支持skia图形引擎的所有像素格式,供java层设置的Config只有这么4个:

1
2
3
4
5
6
7
8
9
10
public enum Config {
// these native values must match up with the enum in SkBitmap.h

ALPHA_8 (1),
RGB_565 (3),
ARGB_4444 (4),
ARGB_8888 (5);

inal int nativeInt;
}

       其实 Java 层的枚举变量的 nativeInt 对应的就是 Skia 库当中枚举的索引值;而skia却支持这么多:

1
2
3
4
5
6
7
8
9
//Skbitmap.h文件
enum Config {
kNo_Config, //!< bitmap has not been configured
kA8_Config, //!< 8-bits per pixel, with only alpha specified (0 is transparent, 0xFF is opaque)
kIndex8_Config, //!< 8-bits per pixel, using SkColorTable to specify the colors
kRGB_565_Config, //!< 16-bits per pixel, (see SkColorPriv.h for packing)
kARGB_4444_Config, //!< 16-bits per pixel, (see SkColorPriv.h for packing)
kARGB_8888_Config, //!< 32-bits per pixel, (see SkColorPriv.h for packing)
};

       上述枚举中第三个类型为索引图类型。索引位图,每个像素只占 1 Byte,不仅支持 RGB,还支持 alpha。微软画图工具应该都玩过吧(win + r,输入mspaint),里面的调色板就是索引色盘。
画图工具
       而Android其他的config类型一个像素点占的字节比这个大多了,所以我们有时候能不能也用索引色去悄悄替换原来格式呢?
       我的猜想是,反射构造一个Bitmap.Config枚举对象,然后反射设置nativeInt字段的值为2,猜想代码如下:

1
2
3
Options op = new Options();
op.inPreferredConfig = ...反射构建Bitmap.Config相关内容...
BitmapFactory.decodeResource(getResources(), R.drawable.picture, op);

       不过我没有实践过,也是瞎猜的,不知道能不能行的通。。。。。。

       但是我对上一篇文章种调skia生成弹幕bitmap处的代码做了修改,修改了DanmakuFlameMaster工程里的NativeBitmapFactory.java文件:

1
2
3
4
5
6
    private static Bitmap createNativeBitmap(int width, int height, Config config, boolean hasAlpha) {
// int nativeConfig = getNativeConfig(config);
int nativeConfig = 2;//直接改为索引色
return android.os.Build.VERSION.SDK_INT == 19 ? createBitmap19(width, height,
nativeConfig, hasAlpha) : createBitmap(width, height, nativeConfig, hasAlpha);
}

       将色彩格式改为索引色,然后重新编译运行。。。。。。然而弹幕压根没出来。。。。。等以后有机会问问ctiao吧,请教一下为何。
       这些瞎猜只能暂时放着,等以后有机会再验证吧。。。。。。

坚持技术分享,您的支持将鼓励我继续创作!