概述
本文原作者: 椎锋陷陈,原文发布于: 星际码仔
雪糕刺客是被网友们玩坏了的梗,指的是那些以平平无奇的外表混迹于众多平价雪糕之中的贵价雪糕。由于没有明确标明价格,通常要等到结账的时候才会发现,犹如一个潜藏于普通人群中的刺客般,伺机对那些大意的顾客们的钱包刺上一剑,因此得名。
而在 Android 中,也有这么一个内存刺客,其作为我们日常开发中经常接触的对象之一,却常常因为使用方式的不当,时不时地就会给我们有限的内存来上一个背刺,甚至毫不留情地就给我们抛出一个 OOM,它,就是 Bitmap。
本文是 Bitmap 这个话题的上篇,从图像基础知识出发,结合源码讲解 Bitmap 内存的计算方式。
开始之前,先奉上的思维导图一张,方便后续复习:
△ Bitmap 内存计算
从一个问题出发
假设有这么一张 PNG 格式的图片,其大小为 15.3KB,尺寸为 96x96,色深为 32 bit,放到 xhdpi 目录下,并加载到一台 dpi 为 480 的 Android 设备上显示,那么请问,该图片实际会占用多大的内存?
△ 实际会占用多大的内存
如果您回答不了这个问题,那就有必要深入往下读了。
压缩格式大小≠占用内存大小
首先我们要明确的是,无论是 JPEG 还是 PNG,它们本质上都是一种压缩格式,压缩的目的是为了降低存储和传输的成本。
区别就在于:
JPEG 是一种有损压缩格式,压缩比大,压缩后的体积比较小,但其高压缩率是通过去除冗余的图像数据进行的,因此解压后无法还原出完整的原始图像数据。
PNG 则是一种无损压缩格式,不会损失图片质量,解压后能还原出完整的原始图像数据,但也因此压缩比小,压缩后的体积仍然很大。
开篇问题中所特意强调的图片大小,实际指的就是压缩格式文件的大小。而问题最后所问的图片实际占用的内存,指的则是解压缩后显示在设备屏幕上的原始图像数据所占用的内存。
在实际的 Android 开发中,我们经常直接接触到的原始图像数据,就是通过各种 decode 方法解码出的 Bitmap 对象。
Bitmap 即位图,它还有另外一个名称叫做点阵图,相对来说,点阵图这个名称更能表述 Bitmap 的特征。
点指的是像素点,阵指的是阵列。点阵图,就是以像素为最小单位构成的图,缩放会失真。每个像素实则都是一个非常小的正方形,并被分配不同的颜色,然后通过不同的排列来构成像素阵列,最终呈现出完整的图像。
△ 放大 12 倍显示独立像素
那么每个像素是如何存储自己的颜色信息的呢?这涉及到图片的色深。
色深是什么?
色深,又叫色彩深度 (Color Depth)。假设色深的数值为 n,代表每个像素会采用 n 个二进制位来存储颜色信息,也即 2 的 n 次方,表示的是每个像素能显示 2^n 种颜色。
常见的色深有:
1 bit: 只能显示黑与白两个中的一个。因为在色深为 1 的情况下,每个像素只能存储 2^1=2 种颜色。
8 bit: 可以存储 2^8=256 种的颜色,典型的如 GIF 图像的色深就为 8 bit。
24 bit: 可以存储 2^24=16,777,216 种的颜色。每个像素的颜色由红 (Red)、绿 (Green)、蓝 (Blue) 3 个颜色通道合成,每个颜色通道用 8bit 来表示,其取值范围是:
二进制: 00000000~11111111
十进制: 0~255
十六进制: 00~FF
这里很自然地就让人联想起 Android 中常用于表示颜色的两种形式,即:
Color.rgb(float red, float green, float blue),对应十进制;
Color.parceColor(String colorString),对应十六进制。
32 bit: 在 24 位的基础上,增加多 8 个位的透明通道。
色深会影响图片的整体质量,我们可以来看同一张图片在不同色深下的表现:
△ 24-bit color: 224 = 16,777,216 colors, 45 KB
△ 8-bit color: 28 = 256 colors, 17 KB
△ 4-bit color: 24 = 16 colors, 6 KB
△ 2-bit color: 22 = 4 colors, 4 KB
△ 1-bit color: 21 = 2 colors, 3 KB
可以看出,色深越大,能表示的颜色越丰富,图片也就越鲜艳,颜色过渡就越平滑。但相对的,图片的体积也会增加,因为每个像素必须存储更多的颜色信息。
Android 中与色深配置相关的类是 Bitmap.Config,其取值会直接影响位图的质量 (色彩深度) 以及显示透明/半透明颜色的能力。在 Android 2.3 (API 级别 9) 及更高版本中的默认配置是 ARGB_8888,也即 32 bit 的色深,1 byte = 8 bit,因此该配置下每个像素的大小为 4 byte。
位图内存 = 像素数量 (分辨率) * 每个像素的大小,想要进一步计算加载位图所需要的内存,我们还需要得知像素的总数量,而描述像素数量的说法就是分辨率。
分辨率是什么?
如果说,色深决定了位图颜色的丰富程度,那么分辨率决定的则是位图图像细节的精细程度。图像的分辨率越高,所包含的像素就越多,图像也就越清晰,同样的,它也会相应增加图片的体积。
通常,我们用每一个方向上的像素数量来表示分辨率,也即水平像素数×垂直像素数,比如 320×240,640×480,1280×1024 等。
一张分辨率为 640x480 的图片,其像素数量就达到了 307,200,也就是我们常说的 30 万像素。
现在,我们明白了公式中 2 个变量的含义,就可以代入开篇问题中的例子来计算位图内存:
96 * 96 * 4 byte = 36864 bytes = 36KB
Bitmap 提供了两个方法用于获取系统为该 Bitmap 存储像素所分配的内存大小,分别为
public int getByteCount ()
public int getAllocationByteCount ()
一般情况下,两个方法返回的值是相同的。但如果我们手动重新配置了 Bitmap 的属性 (宽、高、Bitmap.Config 等),或者将 BitmapFactory.Options.inBitmap 属性设为 true 以支持其他更小的 Bitmap 复用其内存时,那么 getAllocationByteCount () 返回的值就有可能会大于 getByteCount()。
我们暂时不考虑以上两种场景,所以直接选择调用 getByteCount 方法 () 来获取为 Bitmap 分配的字节数,得到的结果是: 82944 bytes = 81KB。
可以看到,getByteCount 方法返回的值与我们的计算结果有差异,是我们的计算公式有问题吗?
探究 getByteCount() 的计算公式
为了验证我们的计算公式是否准确,我们需要深入 getByteCount() 方法的源码进行探究。
public final int getByteCount() {
if (mRecycled) {
Log.w(TAG, "Called getByteCount() on a recycle()'d bitmap! "
+ "This is undefined behavior!");
return 0;
}
// int result permits bitmaps up to 46,340 x 46,340
return getRowBytes() * getHeight();
}
可以看到,getByteCount() 方法的返回值是每一行的字节数 * 高度,那么每一行的字节数又是怎么计算的呢?
public final int getRowBytes() {
if (mRecycled) {
Log.w(TAG, "Called getRowBytes() on a recycle()'d bitmap! This is undefined behavior!");
}
return nativeRowBytes(mFinalizer.mNativeBitmap);
}
正如您所见,getRowBytes() 方法的实现是在 Native 层。先别灰心,接下来坐好扶稳了,我们省去一些不重要的步骤,乘坐飞船一路跨越 Bitmap.cpp、SkBitmap.h,途径 SkBitmap.cpp 时稍微停下:
size_t SkBitmap::ComputeRowBytes(Config c, int width) {
return SkColorTypeMinRowBytes(SkBitmapConfigToColorType(c), width);
}
并最终到达 SkImageInfo.h:
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
};
SK_COMPILE_ASSERT(SK_ARRAY_COUNT(gSize) == (size_t)(kLastEnum_SkColorType + 1),
size_mismatch_with_SkColorType_enum);
SkASSERT((size_t)ct < SK_ARRAY_COUNT(gSize));
return gSize[ct];
}
static inline size_t SkColorTypeMinRowBytes(SkColorType ct, int width) {
return width * SkColorTypeBytesPerPixel(ct);
}
都说正确清晰的函数名有替代注释的作用,这就是优秀的典范。
让我们把目光停留在 width * SkColorTypeBytesPerPixel(ct) 这一行,不难看出,其计算方式是先根据颜色类型获取每个像素对应的字节数,再去乘以其宽度。
那么,结合 Bitmap.java 的 getByteCount() 方法的实现,我们最终得出,系统为 Bitmap 存储像素所分配的内存大小 = 宽度 * 每个像素的大小 * 高度,与我们上面的计算公式一致。
公式没错,那问题究竟出在哪里呢?
其实,如果我们的图片是从磁盘、网络等地方获取的,理论上确实是按照上面的公式那样计算没错。但您还记得吗?我们在开篇的问题中,还特意强调了图片是放在 xhdpi 目录下的。在 Android 设备上,这种情况下计算位图内存,还有一个维度要考虑进来,那就是像素密度。
像素密度是什么?
像素密度指的是屏幕单位面积内的像素数,称为 dpi (dots per inch,每英寸点数)。当两个设备的尺寸相同而像素密度不同时,图像的效果呈现如下:
△ 在尺寸相同但像素密度不同的两个设备上放大图像
是不是感觉跟分辨率的概念有点像?区别就在于,前者是屏幕单位面积内的像素数,后者是屏幕上的总像素数。
由于 Android 是开源的,任何硬件制造商都可以制造搭载 Android 系统的设备,因此从手表、手机到平板电脑再到电视,各种屏幕尺寸和屏幕像素密度的设备层出不穷。
△ Android 碎片化
为了优化不同屏幕配置下的用户体验,确保图像能在所有屏幕上显示最佳效果,Android 建议应针对常见的不同的屏幕尺寸和屏幕像素密度,提供对应的图片资源。于是就有了 Android 工程 res 目录下,加上各种配置限定符的 drawable/mipmap 文件夹。
为了简化不同的配置,Android 针对不同像素密度范围进行了归纳分组,如下:
△ 适用于不同像素密度的配置限定符
我们通常选取中密度 (mdpi) 作为基准密度 (1 倍图),并保持 ldpi~xxxhdpi 这六种主要密度之间 3:4:6:8:12:16 的缩放比,来放置相应尺寸的图片资源。
例如,在创建 Android 工程时 IDE 默认为我们添加的 ic_launcher 图标,就遵循了这个规则。该图标在中密度 (mdpi) 目录下的大小为 48x48,在其他各种密度的目录下的大小则分别为:
36x36 (0.75x) - 低密度 (ldpi)
48x48 (1.0x 基准) - 中密度 (mdpi)
72x72 (1.5x) - 高密度 (hdpi)
96x96 (2.0x) - 超高密度 (xhdpi)
144x144 (3.0x) - 超超高密度 (xxhdpi)
192x192 (4.0x) - 超超超高密度 (xxxhdpi)
当我们引用该图标时,系统就会根据所运行设备屏幕的 dpi,与不同密度目录名称中的限定符进行比较,来选取最符合当前设备的图片资源。如果在该密度目录下没有找到合适的图片资源,系统会有对应的规则查找另外一个可能的匹配资源,并对其进行相应的缩放,以适配屏幕,由此可能造成图片有明显的模糊失真。
△ 不同密度大小的 ic_launcher 图标
那么,具体的查找规则是怎样的呢?
Android 查找最佳匹配资源的规则
一般来说,Android 会更倾向于缩小较大的原始图像,而非放大较小的原始图像。在此前提下:
假设最接近设备屏幕密度的目录选项为 xhdpi,如果图片资源存在,则匹配成功;
如果不存在,系统就会从更高密度的资源目录下查找,依次为 xxhdpi、xxxhdpi;
如果还不存在,系统就会从像素密度无关的资源目录 nodpi 下查找;
如果还不存在,系统就会向更低密度的资源目录下查找,依次为 hdpi、mdpi、ldpi。
那么,当匹配到其他密度目录下的图片资源后,对于原始图像的放大或缩小,Android 是怎么实现的呢?又会对加载位图所需要的内存有什么影响呢?
想解决这些疑惑,我们还是得从源码中找寻答案。
decode* 方法的猫腻
众所周知,在 Android 中要读取 drawable/mipmap 目录下的图片资源,需要用到的是 BitmapFactory 类下的 decodeResource 方法:
public static Bitmap decodeResource(Resources res, int id, Options opts) {
...
final TypedValue value = new TypedValue();
is = res.openRawResource(id, value);
bm = decodeResourceStream(res, value, is, null, opts);
...
}
decodeResource 方法的主要工作,就只是调用 Resource#openRawResource 方法读取原始图片资源,同时传递一个 TypedValue 对象用于持有图片资源的相关信息,并返回一个输入流作为内部继续调用 decodeResourceStream 方法的参数。
public static Bitmap decodeResourceStream(Resources res, TypedValue value,InputStream is, Rect pad, Options opts) {
if (opts == 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;
}
}
if (opts.inTargetDensity == 0 && res != null) {
opts.inTargetDensity = res.getDisplayMetrics().densityDpi;
}
return decodeStream(is, pad, opts);
}
decodeResourceStream 方法的主要工作,则是负责 Options (解码选项) 类 2 个重要参数 inDensity 和 inTargetDensity 的初始化,其中:
inDensity 代表的是 Bitmap 的像素密度,取决于原始图片资源所存放的密度目录。
inTargetDensity 代表的是 Bitmap 将绘制到的目标的像素密度,通常就是指屏幕的像素密度。
这两个参数起什么作用呢,让我们继续往下看:
public static Bitmap decodeStream(InputStream is, Rect outPadding, Options opts) {
···
if (is instanceof AssetManager.AssetInputStream) {
final long asset = ((AssetManager.AssetInputStream) is).getNativeAsset();
bm = nativeDecodeAsset(asset, outPadding, opts);
} else {
bm = decodeStreamInternal(is, outPadding, opts);
}
···
}
private static Bitmap decodeStreamInternal(InputStream is, Rect outPadding, Options opts) {
byte [] tempStorage = null;
if (tempStorage == null) tempStorage = new byte[DECODE_BUFFER_SIZE];
return nativeDecodeStream(is, tempStorage, outPadding, opts);
}
又见到熟悉的 Native 层方法了,让我们重新开动星际飞船再次跨越到 BitmapFactory.cpp 下查看:
static jobject nativeDecodeStream(JNIEnv* env, jobject clazz, jobject is, jbyteArray storage, jobject padding, jobject options) {
···
bitmap = doDecode(env, bufferedStream, padding, options);
···
}
static jobject doDecode(JNIEnv* env, SkStreamRewindable* stream, jobject padding, jobject options) {
····
float scale = 1.0f;
···
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;
}
}
···
const bool willScale = scale != 1.0f;
···
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 (options != NULL) {
env->SetIntField(options, gOptions_widthFieldID, scaledWidth);
env->SetIntField(options, gOptions_heightFieldID, scaledHeight);
env->SetObjectField(options, gOptions_mimeFieldID,
getMimeTypeString(env, decoder->getFormat()));
}
...
}
以上节选的 doDecode 方法的部分源码,就是 Android 系统如何对其他密度目录下的原始图像进行缩放的具体实现,我们来梳理一下它的执行逻辑:
首先,设置 scale 值也即初始的缩放比为 1。
取出关键的 density 值以及 targetDensity 值,以目标像素密度/位图像素密度重新计算缩放比。
如果缩放比不再为 1,则说明原始图像需要进行缩放。
取出待解码的位图的宽度,按 int (scaledWidth * scale + 0.5f) 计算缩放后的宽度,高度同理。
重新填充缩放后的宽高回 Options。
基于以上内容,我们重新调整下我们的计算公式:
位图内存 = (位图宽度 * 缩放比) * 每个像素的大小 * (位图高度 * 缩放比) = (96 * 1.5) * 4 * (96 * 1.5) = 82944 bytes = 81KB
可以看到,这样计算得出来的结果则与 Bitmap#getByteCount() 返回的值一致。
总结
汇总上述的所有内容后,我们可以得出结论,即:
Android 系统为 Bitmap 存储像素所分配的内存大小,取决于以下几个因素:
色深,也即每个像素的大小,对应的是 Bitmap.Config 的配置;
分辨率,也即像素的总数量,对应的是 Bitmap 的高度和宽度;
像素密度,对应的是图片资源所在的密度目录,以及设备的屏幕像素密度。
由此我们还衍生出其他的结论,即:
图片资源放到正确的密度目录很重要,否则可能会对较大尺寸的图片进行不合理的缩放,从而加大不必要的内存占用;
如果是为了减少包体积而不想提供所有密度目录下不同尺寸的图片,应优先提供更高密度目录下的图片资源,可以避免图片失真。
长按右侧二维码
查看更多开发者精彩分享
"开发者说·DTalk" 面向中国开发者们征集 Google 移动应用 (apps & games) 相关的产品/技术内容。欢迎大家前来分享您对移动应用的行业洞察或见解、移动开发过程中的心得或新发现、以及应用出海的实战经验总结和相关产品的使用反馈等。我们由衷地希望可以给这些出众的中国开发者们提供更好展现自己、充分发挥自己特长的平台。我们将通过大家的技术内容着重选出优秀案例进行谷歌开发技术专家 (GDE) 的推荐。
点击屏末 | 阅读原文 | 即刻报名参与 "开发者说·DTalk"
最后
以上就是凶狠口红为你收集整理的Bitmap: Android 内存刺客 | 开发者说·DTalk的全部内容,希望文章能够帮你解决Bitmap: Android 内存刺客 | 开发者说·DTalk所遇到的程序开发问题。
如果觉得靠谱客网站的内容还不错,欢迎将靠谱客网站推荐给程序员好友。
发表评论 取消回复