当前位置:网站首页>Bitmap这个“内存刺客”你也要小心
Bitmap这个“内存刺客”你也要小心
2022-08-11 11:06:00 【锐湃】
1
写在前面
「雪糕刺客」是最近被网友们玩坏了的梗,指的是那些以平平无奇的外表混迹于众多平价雪糕之中的贵价雪糕。由于没有明确标明价格,通常要等到结账的时候才会发现,犹如一个潜藏于普通人群中的刺客般,伺机对那些大意的顾客们的钱包刺上一剑,因此得名。
而在Android中,也有这么一个「内存刺客」,其作为我们日常开发中经常接触的对象之一,却常常因为使用方式的不当,时不时地就会给我们有限的内存来上一个背刺,甚至毫不留情地就给我们抛出一个OOM,它,就是「Bitmap」。
为了讲好Bitmap这个话题,本系列文章将分为上下两篇,上篇「从图像基础知识出发,结合源码讲解Bitmap内存的计算方式」;下篇则「基于Android系统提供的API,讲解在实际开发中如何管理好Bitmap的内存,包括缩放、缓存、内存复用等」,敬请期待。
本文为上篇,开始之前,先奉上的思维导图一张,方便后续复习:
2
从一个问题出发
假设有这么一张「PNG格式」的图片,其大小为「15.3KB」,尺寸为「96x96」,色深为「32 bit」,放到「xhdpi目录」下,并加载到一台「dpi为480」的Android设备上显示,那么请问,该图片实际会占用多大的内存?
如果你回答不了这个问题,那你就有必要深入往下读了。
3
压缩格式大小≠占用内存大小
首先我们要明确的是,无论是JPEG还是PNG,它们本质上都是一种「压缩格式」,压缩的目的是为了「降低存储和传输的成本」。
区别就在于:
「JPEG」是一种「有损压缩格式」,压缩比大,压缩后的体积比较小,但其高压缩率是通过「去除冗余的图像数据」进行的,因此解压后无法还原出完整的原始图像数据。
「PNG」则是一种「无损压缩格式」,不会损失图片质量,解压后能还原出完整的原始图像数据,但也因此压缩比小,压缩后的体积仍然很大。
开篇问题中所特意强调的「图片大小」,实际指的就是「压缩格式文件的大小」。而问题最后所问的「图片实际占用的内存」,指的则是「解压缩后显示在设备屏幕上的原始图像数据所占用的内存」。
在实际的Android开发中,我们经常直接接触到的「原始图像数据」,就是通过各种decode方法解码出的「Bitmap对象」。
Bitmap即「位图」,它还有另外一个名称叫做「点阵图」,相对来说,「点阵图」这个名称更能表述Bitmap的特征。
「点」指的是「像素点」,「阵」指的是「阵列」。点阵图,就是「以像素为最小单位构成的图」,缩放会失真。「每个像素实则都是一个非常小的正方形,并被分配不同的颜色,然后通过不同的排列来构成像素阵列,最终呈现出完整的图像」。
那么每个像素是如何存储自己的颜色信息的呢?这涉及到图片的色深。
4
色深是什么?
「色深」,又叫「色彩深度」(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。
「位图内存 = 像素数量(分辨率) * 每个像素的大小」,想要进一步计算加载位图所需要的内存,我们还需要得知像素的总数量,而描述像素数量的说法就是分辨率。
5
分辨率是什么?
如果说,色深决定了「位图颜色的丰富程度」,那么分辨率决定的则是「位图图像细节的精细程度」。「图像的分辨率越高,所包含的像素就越多,图像也就越清晰」,同样的,它也会相应「增加图片的体积」。
通常,我们用「每一个方向上的像素数量」来表示分辨率,也即「水平像素数×垂直像素数」,比如320×240,640×480,1280×1024等。
一张分辨率为640x480的图片,其像素数量就达到了307200,也就是我们常说的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()方法返回的值与我们的计算结果有差异,是我们的计算公式有问题吗?
6
探究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设备上,这种情况下计算位图内存,还有一个维度要考虑进来,那就是「像素密度」。
7
像素密度是什么?
「像素密度」指的是「屏幕单位面积内的像素数」,称为dpi(dots per inch,每英寸点数)。当两个设备的尺寸相同而像素密度不同时,图像的效果呈现如下:
是不是感觉跟分辨率的概念有点像?区别就在于,前者是「屏幕单位面积内的像素数」,后者是「屏幕上的总像素数」。
由于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图标
那么,具体的查找规则是怎样的呢?
8
Android查找最佳匹配资源的规则
一般来说,Android会「更倾向于缩小较大的原始图像,而非放大较小的原始图像」。在此前提下:
• 假设最接近设备屏幕密度的目录选项为xhdpi,如果图片资源存在,则匹配成功;
• 如果不存在,系统就会从更高密度的资源目录下查找,依次为xxhdpi、xxxhdpi;
• 如果还不存在,系统就会从「像素密度无关的资源目录nodpi」下查找;
• 如果还不存在,系统就会向更低密度的资源目录下查找,依次为hdpi、mdpi、ldpi。
那么,当匹配到其他密度目录下的图片资源后,对于原始图像的放大或缩小,Android是怎么实现的呢?又会对加载位图所需要的内存有什么影响呢?
想解决这些疑惑,我们还是得从源码中找寻答案。
9
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系统如何对其他密度目录下的原始图像进行缩放的具体实现,我们来梳理一下它的执行逻辑:
1. 首先,设置scale值也即初始的缩放比为1。
2. 取出关键的density值以及targetDensity值,以「目标像素密度/位图像素密度」重新计算缩放比。
3. 如果缩放比不再为1,则说明原始图像需要进行缩放。
4. 「取出待解码的位图的宽度,按int(scaledWidth * scale + 0.5f)计算缩放后的宽度」,高度同理。
5. 重新填充缩放后的宽高回Options。
基于以上内容,我们重新调整下我们的计算公式:
位图内存 = (位图宽度 * 缩放比) * 每个像素的大小 * (位图高度 * 缩放比) = (96 * 1.5) * 4 * (96 * 1.5) = 82944 bytes = 81KB
可以看到,这样计算得出来的结果则与Bitmap#getByteCount()返回的值一致。
10
总结
汇总上述的所有内容后,我们可以得出结论,即:
Android系统为Bitmap存储像素所分配的内存大小,取决于以下几个因素:
• 「色深」,也即每个像素的大小,对应的是Bitmap.Config的配置。
• 「分辨率」,也即像素的总数量,对应的是Bitmap的高度和宽度
• 「像素密度」,对应的是图片资源所在的密度目录,以及设备的屏幕像素密度
由此我们还衍生出其他的结论,即:
• 「图片资源放到正确的密度目录很重要」,否则可能会对较大尺寸的图片进行不合理的缩放,从而加大不必要的内存占用。
• 如果是为了减少包体积而不想提供所有密度目录下不同尺寸的图片,应「优先提供更高密度目录下的图片资源」,可以避免图片失真。
• ...
参考
App resources overview
What is bit depth?
重识图片
边栏推荐
- 6.1 总线的概念和结构形态
- C语言手写魂斗罗(一)
- a sequence of consecutive positive numbers with sum s
- 解决 Pocess finished with exit code 1 Class not found 和 Command line is too long. Shorten the command
- 从零开始配置 vim(12)——主题配置
- 2.MySQL ---- 修改数据库的字符集(日常小技巧)
- 【学习笔记】一般图最大匹配
- [Ext JS]11.14 SimXhr.js?_dc=1659315492151:65 Uncaught TypeError problem analysis and solution
- 如何设计一组会出现死锁(Deadlock)的ABAP程序
- Database indexes and their underlying data structures
猜你喜欢
随机推荐
How long does it take to train a neural network, neural network training takes too long
当科学家决定搞点“花里胡哨”的东西
SDUT数据库 SQL语句练习(MySQL)
SDS Observatory
1元限时秒杀 | 接口抓包分析与Mock实战训练营
论文笔记:《Time Series Generative Adversrial Networks》(TimeGAN,时间序列GAN)
如何设计一组会出现死锁(Deadlock)的ABAP程序
Getting Started with Chrome Plug-in Development - Nanny Level Raiders
黑马瑞吉外卖之分类信息的分页查询
【学习笔记】一般图最大匹配
Are there any foreign application cases for domestic databases?
exist和in的区别
日志使用注意事项和建议
Some time function records commonly used in mysql
分析 Flink 任务如何超过 YARN 容器内存限制
Analyzes how Flink task than YARN container memory limit
【2022】【Thesis Notes】Ultra-thin THz deflection based on laser direct writing graphene oxide paper——
go语言学习:并发编程(Sync/GMP/爬虫案例)
五、一起学习Lua 变量
【Mysql系列】03_系统设计