Bitmap这个“内存刺客”你也要小心~

Connor 币安币 2022-09-27 292 0

1

写在前面

「雪糕刺客」是最近被网友们玩坏了的梗,指的是那些以平平无奇的外表混迹于众多平价雪糕之中的贵价雪糕Bitget。由于没有明确标明价格,通常要等到结账的时候才会发现,犹如一个潜藏于普通人群中的刺客般,伺机对那些大意的顾客们的钱包刺上一剑,因此得名。

而在Android中,也有这么一个「内存刺客」,其作为我们日常开发中经常接触的对象之一,却常常因为使用方式的不当,时不时地就会给我们有限的内存来上一个背刺,甚至毫不留情地就给我们抛出一个OOM,它,就是「Bitmap」Bitget

为了讲好Bitmap这个话题,本系列文章将分为上下两篇,上篇「从图像基础知识出发,结合源码讲解Bitmap内存的计算方式」;下篇则「基于Android系统提供的API,讲解在实际开发中如何管理好Bitmap的内存,包括缩放、缓存、内存复用等」,敬请期待Bitget

本文为上篇Bitget,开始之前,先奉上的思维导图一张,方便后续复习:

Bitmap这个“内存刺客”<strong></p>
<p>Bitget</strong>你也要小心~

Bitmap内存计算

2

从一个问题出发

假设有这么一张「PNG格式」的图片Bitget,其大小为「15.3KB」,尺寸为「96x96」,色深为「32 bit」,放到「xhdpi目录」下,并加载到一台「dpi为480」的Android设备上显示,那么请问,该图片实际会占用多大的内存?

Bitmap这个“内存刺客”<strong></p>
<p>Bitget</strong>你也要小心~

展开全文

实际会占用多大的内存

如果你回答不了这个问题,那你就有必要深入往下读了Bitget

3

压缩格式大小≠占用内存大小

首先我们要明确的是,无论是JPEG还是PNG,它们本质上都是一种「压缩格式」,压缩的目的是为了「降低存储和传输的成本」Bitget

区别就在于:

「JPEG」是一种「有损压缩格式」,压缩比大,压缩后的体积比较小,但其高压缩率是通过「去除冗余的图像数据」进行的,因此解压后无法还原出完整的原始图像数据Bitget

「PNG」则是一种「无损压缩格式」,不会损失图片质量,解压后能还原出完整的原始图像数据,但也因此压缩比小,压缩后的体积仍然很大Bitget

开篇问题中所特意强调的「图片大小」,实际指的就是「压缩格式文件的大小」Bitget。而问题最后所问的「图片实际占用的内存」,指的则是「解压缩后显示在设备屏幕上的原始图像数据所占用的内存」。

在实际的Android开发中,我们经常直接接触到的「原始图像数据」,就是通过各种decode方法解码出的「Bitmap对象」Bitget

Bitmap即「位图」,它还有另外一个名称叫做「点阵图」,相对来说,「点阵图」这个名称更能表述Bitmap的特征Bitget

「点」指的是「像素点」,「阵」指的是「阵列」Bitget。点阵图,就是「以像素为最小单位构成的图」,缩放会失真。「每个像素实则都是一个非常小的正方形,并被分配不同的颜色,然后通过不同的排列来构成像素阵列,最终呈现出完整的图像」。

Bitmap这个“内存刺客”<strong></p>
<p>Bitget</strong>你也要小心~

放大12倍显示独立像素

那么每个像素是如何存储自己的颜色信息的呢?这涉及到图片的色深Bitget

4

色深是什么Bitget

「色深」,又叫「色彩深度」(Color Depth)Bitget。假设色深的数值为n,代表每个像素会「采用n个二进制位来存储颜色信息」,也即「2的n次方」,表示的是「每个像素能显示2^n种颜色」。

常见的色深有:

• 「1 bit」:只能显示黑与白两个中的一个Bitget。因为在色深为1的情况下,每个像素只能存储2^1=2种颜色。

• 「8 bit」:可以存储2^8=256种的颜色,典型的如GIF图像的色深就为8 bitBitget

• 「24 bit」:可以存储2^24=16,777,216种的颜色Bitget。每个像素的颜色由红(Red)、绿(Green)、蓝(Blue)3个颜色通道合成,每个颜色通道用8bit来表示,其取值范围是:

十进制:0~255

十六进制:00~FF

这里很自然地就让人联想起Android中常用于表示颜色的两种形式Bitget,即:

▪ Color.rgb(float red, float green, float blue)Bitget,对应十进制;

▪ Color.parceColor(String colorString)Bitget,对应十六进制

• 「32 bit」:在24位的基础上,增加多8个位的透明通道Bitget

色深会影响「图片的整体质量」Bitget,我们可以来看同一张图片在不同色深下的表现:

Bitmap这个“内存刺客”<strong></p>
<p>Bitget</strong>你也要小心~

24-bit color: 224 = 16,777,216 colors, 45 KB

Bitmap这个“内存刺客”<strong></p>
<p>Bitget</strong>你也要小心~

8-bit color: 28 = 256 colors, 17 KB

Bitmap这个“内存刺客”<strong></p>
<p>Bitget</strong>你也要小心~

4-bit color: 24 = 16 colors, 6 KB

Bitmap这个“内存刺客”<strong></p>
<p>Bitget</strong>你也要小心~

2-bit color: 22 = 4 colors, 4 KB

Bitmap这个“内存刺客”<strong></p>
<p>Bitget</strong>你也要小心~

1-bit color: 21 = 2 colors, 3 KB

可以看出,「 色深越大,能表示的颜色越丰富,图片也就越鲜艳,颜色过渡就越平滑」Bitget。但相对的,「 图片的体积也会增加」,因为「每个像素必须存储更多的颜色信息」。

Android中与色深配置相关的类是「Bitmap.Config」,其取值会直接影响「位图的质量(色彩深度)以及显示透明/半透明颜色的能力」Bitget。在Android 2.3(API 级别 9)及更高版本中的默认配置是「ARGB_8888」,也即32 bit的色深,1 byte = 8 bit,因此该配置下每个像素的大小为4 byte。

「位图内存 = 像素数量(分辨率) * 每个像素的大小」,想要进一步计算加载位图所需要的内存,我们还需要得知像素的总数量,而描述像素数量的说法就是分辨率Bitget

5

分辨率是什么Bitget

如果说,色深决定了「位图颜色的丰富程度」,那么分辨率决定的则是「位图图像细节的精细程度」Bitget。「图像的分辨率越高,所包含的像素就越多,图像也就越清晰」,同样的,它也会相应「增加图片的体积」。

通常,我们用「每一个方向上的像素数量」来表示分辨率,也即「水平像素数×垂直像素数」,比如320×240,640×480,1280×1024等Bitget

现在Bitget,我们明白了公式中2个变量的含义,就可以代入开篇问题中的例子来计算位图内存:

96 * 96 * 4 byte = 36864 bytes = 36KB

Bitmap提供了两个方法用于获取「系统为该Bitmap存储像素所分配的内存大小」Bitget,分别为:

publicintgetByteCount

publicintgetAllocationByteCount

一般情况下,两个方法返回的值是相同的Bitget。但如果我们手动重新配置了Bitmap的属性(宽、高、Bitmap.Config等),或者将BitmapFactory.Options.inBitmap属性设为true以支持其他更小的Bitmap复用其内存时,那么getAllocationByteCount 返回的值就有可能会大于getByteCount。

我们暂时不考虑以上两种场景,所以直接选择调用getByteCount方法 来获取为Bitmap分配的字节数,得到的结果是:82944 bytes = 81KBBitget

可以看到Bitget,getByteCount方法返回的值与我们的计算结果有差异,是我们的计算公式有问题吗?

6

探究getByteCount的计算公式

为了验证我们的计算公式是否准确,我们需要深入getByteCount方法的源码进行探究Bitget

publicfinalintgetByteCount{

if(mRecycled) {

Log.w(TAG, "Called getByteCount on a recycle'd bitmap! "

+ "This is undefined behavior!");

return0;

// int result permits bitmaps up to 46,340 x 46,340

returngetRowBytes * getHeight;

可以看到Bitget,getByteCount方法的返回值是「每一行的字节数 * 高度」,那么每一行的字节数又是怎么计算的呢?

publicfinalintgetRowBytes{

if(mRecycled) {

Log.w(TAG, "Called getRowBytes on a recycle'd bitmap! This is undefined behavior!");

returnnativeRowBytes(mFinalizer.mNativeBitmap);

正如你所见,getRowBytes方法的实现是在Native层Bitget。先别灰心,接下来坐好扶稳了,我们省去一些不重要的步骤,乘坐飞船一路跨越Bitmap.cpp、SkBitmap.h,途经SkBitmap.cpp时稍微停下:

size_tSkBitmap::ComputeRowBytes(Config c, intwidth) {

returnSkColorTypeMinRowBytes(SkBitmapConfigToColorType(c), width);

并最终到达SkImageInfo.h:

staticintSkColorTypeBytesPerPixel(SkColorType ct){

staticconstuint8_tgSize[] = {

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));

returngSize[ct];

staticinlinesize_t SkColorTypeMinRowBytes(SkColorType ct, intwidth) {

returnwidth * SkColorTypeBytesPerPixel(ct);

都说正确清晰的函数名有替代注释的作用,这就是优秀的典范Bitget

让我们把目光停留在width * SkColorTypeBytesPerPixel(ct)这一行,不难看出,其计算方式是先「根据颜色类型获取每个像素对应的字节数,再去乘以其宽度」Bitget

那么,结合Bitmap.java的getByteCount方法的实现,我们最终得出,「系统为Bitmap存储像素所分配的内存大小 = 宽度 * 每个像素的大小 * 高度」,与我们上面的计算公式一致Bitget

公式没错Bitget,那问题究竟出在哪里呢?

其实,如果我们的图片是从磁盘、网络等地方获取的,理论上确实是按照上面的公式那样计算没错Bitget。但你还记得吗?我们在开篇的问题中,还特意强调了图片是放在xhdpi目录下的。在Android设备上,这种情况下计算位图内存,还有一个维度要考虑进来,那就是「像素密度」。

7

像素密度是什么Bitget

「像素密度」指的是「屏幕单位面积内的像素数」,称为dpi(dots per inch,每英寸点数)Bitget。当两个设备的尺寸相同而像素密度不同时,图像的效果呈现如下:

Bitmap这个“内存刺客”<strong></p>
<p>Bitget</strong>你也要小心~

在尺寸相同但像素密度不同的两个设备上放大图像

是不是感觉跟分辨率的概念有点像?区别就在于,前者是「屏幕单位面积内的像素数」,后者是「屏幕上的总像素数」Bitget

由于Android是开源的,任何硬件制造商都可以制造搭载Android系统的设备,因此从手表、手机到平板电脑再到电视,各种屏幕尺寸和屏幕像素密度的设备层出不穷Bitget

Bitmap这个“内存刺客”<strong></p>
<p>Bitget</strong>你也要小心~

Android碎片化

为了优化不同屏幕配置下的用户体验,确保图像能在所有屏幕上显示最佳效果,Android建议应「针对常见的不同的屏幕尺寸和屏幕像素密度,提供对应的图片资源」Bitget。于是就有了Android工程res目录下,加上各种配置限定符的drawable/mipmap文件夹。

为了简化不同的配置Bitget,Android针对不同像素密度范围进行了归纳分组,如下:

Bitmap这个“内存刺客”<strong></p>
<p>Bitget</strong>你也要小心~

适用于不同像素密度的配置限定符

我们通常选取中密度 (mdpi) 作为基准密度(1倍图),并保持ldpi~xxxhdpi这六种主要密度之间 「3:4:6:8:12:16」 的缩放比,来放置相应尺寸的图片资源Bitget

例如,在创建Android工程时IDE默认为我们添加的ic_launcher图标,就遵循了这个规则Bitget。该图标在中密度 (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,与不同密度目录名称中的限定符进行比较,来选取最符合当前设备的图片资源」Bitget。如果在该密度目录下没有找到合适的图片资源,系统会有对应的规则查找另外一个可能的匹配资源,并「对其进行相应的缩放,以适配屏幕,由此可能造成图片有明显的模糊失真」。

Bitmap这个“内存刺客”<strong></p>
<p>Bitget</strong>你也要小心~

不同密度大小的ic_launcher图标

那么Bitget,具体的查找规则是怎样的呢?

8

Android查找最佳匹配资源的规则

一般来说,Android会「更倾向于缩小较大的原始图像,而非放大较小的原始图像」Bitget。在此前提下:

• 假设最接近设备屏幕密度的目录选项为xhdpiBitget,如果图片资源存在,则匹配成功;

• 如果不存在Bitget,系统就会从更高密度的资源目录下查找,依次为xxhdpi、xxxhdpi;

• 如果还不存在Bitget,系统就会从「像素密度无关的资源目录nodpi」下查找;

• 如果还不存在,系统就会向更低密度的资源目录下查找,依次为hdpi、mdpi、ldpiBitget

那么Bitget,当匹配到其他密度目录下的图片资源后,对于原始图像的放大或缩小,Android是怎么实现的呢?又会对加载位图所需要的内存有什么影响呢?

想解决这些疑惑,我们还是得从源码中找寻答案Bitget

9

decode*方法的猫腻

众所周知Bitget,在Android中要读取drawable/mipmap目录下的图片资源,需要用到的是BitmapFactory类下的decodeResource方法:

publicstaticBitmap decodeResource( Resources res, intid, Options opts ) {

final TypedValue value= newTypedValue;

is= res.openRawResource(id, value);

bm = decodeResourceStream(res, value, is, null, opts);

decodeResource方法的主要工作,就只是调用「Resource#openRawResource」方法「读取原始图片资源」,同时传递一个「TypedValue」对象用于「持有图片资源的相关信息」,并返回一个输入流作为内部继续调用decodeResourceStream方法的参数Bitget

publicstaticBitmap decodeResourceStream( Resources res, TypedValue value,InputStream is, Rect pad, Options opts ) {

if(opts == null) {

opts = newOptions;

if(opts.inDensity == 0&& value!= null) {

final intdensity = value.density;

if(density == TypedValue.DENSITY_DEFAULT) {

opts.inDensity = DisplayMetrics.DENSITY_DEFAULT;

} elseif(density != TypedValue.DENSITY_NONE) {

opts.inDensity = density;

if(opts.inTargetDensity == 0&& res != null) {

opts.inTargetDensity = res.getDisplayMetrics.densityDpi;

returndecodeStream( is, pad, opts);

decodeResourceStream方法的主要工作Bitget,则是负责Options(解码选项)类2个重要参数inDensity和inTargetDensity的初始化,其中:

• 「 inDensity」代表的是「Bitmap的像素密度」,取决于原始图片资源所存放的密度目录Bitget

• 「 inTargetDensity」代表的是「Bitmap将绘制到的目标的像素密度」,通常就是指屏幕的像素密度Bitget

这两个参数起什么作用呢Bitget,让我们继续往下看:

publicstaticBitmap decodeStream( InputStream is, Rect outPadding, Options opts ) {

if( isinstanceof AssetManager.AssetInputStream) {

final longasset = ((AssetManager.AssetInputStream) is).getNativeAsset;

bm = nativeDecodeAsset(asset, outPadding, opts);

} else{

bm = decodeStreamInternal( is, outPadding, opts);

privatestaticBitmap decodeStreamInternal( InputStream is, Rect outPadding, Options opts ) {

byte[] tempStorage = null;

if(tempStorage == null) tempStorage = newbyte[DECODE_BUFFER_SIZE];

returnnativeDecodeStream( is, tempStorage, outPadding, opts);

又见到熟悉的Native层方法了Bitget,让我们重新开动星际飞船再次跨越到BitmapFactory.cpp下查看:

staticjobject nativeDecodeStream( JNIEnv* env, jobject clazz, jobject is, jbyteArray storage, jobject padding, jobject options ) {

bitmap = doDecode(env, bufferedStream, padding, options);

staticjobject doDecode(JNIEnv* env, SkStreamRewindable* stream, jobject padding, jobject options){

floatscale = 1.0f;

if(env->GetBooleanField(options, gOptions_scaledFieldID)) {

constintdensity = env->GetIntField(options, gOptions_densityFieldID);

constinttargetDensity = env->GetIntField(options, gOptions_targetDensityFieldID);

constintscreenDensity = env->GetIntField(options, gOptions_screenDensityFieldID);

if(density != 0&& targetDensity != 0&& density != screenDensity) {

scale = ( float) targetDensity / density;

constboolwillScale = scale != 1.0f;

intscaledWidth = decodingBitmap.width;

intscaledHeight = 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方法的部分源码Bitget,就是Android系统如何对其他密度目录下的原始图像进行缩放的具体实现,我们来梳理一下它的执行逻辑:

1. 首先,设置scale值也即初始的缩放比为1Bitget

2. 取出关键的density值以及targetDensity值,以「目标像素密度/位图像素密度」重新计算缩放比Bitget

3. 如果缩放比不再为1,则说明原始图像需要进行缩放Bitget

4. 「取出待解码的位图的宽度,按int(scaledWidth * scale + 0.5f)计算缩放后的宽度」,高度同理Bitget

5. 重新填充缩放后的宽高回OptionsBitget

基于以上内容Bitget,我们重新调整下我们的计算公式:

位图内存 = (位图宽度 * 缩放比) * 每个像素的大小 * (位图高度 * 缩放比) = (96 * 1.5) * 4 * (96 * 1.5) = 82944 bytes = 81KB

可以看到,这样计算得出来的结果则与Bitmap#getByteCount返回的值一致Bitget

10

总结

汇总上述的所有内容后Bitget,我们可以得出结论,即:

Android系统为Bitmap存储像素所分配的内存大小Bitget,取决于以下几个因素:

• 「 色深」,也即每个像素的大小,对应的是Bitmap.Config的配置Bitget

• 「 分辨率」Bitget,也即像素的总数量,对应的是Bitmap的高度和宽度

• 「 像素密度」Bitget,对应的是图片资源所在的密度目录,以及设备的屏幕像素密度

由此我们还衍生出其他的结论Bitget,即:

• 「图片资源放到正确的密度目录很重要」,否则可能 会对较大尺寸的图片进行不合理的缩放,从而加大不必要的内存占用Bitget

• 如果是为了减少包体积而不想提供所有密度目录下不同尺寸的图片,应「优先提供更高密度目录下的图片资源」,可以避免图片失真Bitget

参考

App resources overview

What is bit depth?

重识图片

你的 Bitmap 究竟占多大内存Bitget

最后推荐一下我做的网站Bitget,玩Android: wanandroid.com,包含详尽的知识体系、好用的工具,还有本公众号文章合集,欢迎体验和收藏!

WebView遇到的各种问题解决方案分享

Android 技术周刊(第1期):38篇技术文章

Android 实现菜单拖拽排序, so easy

点击关注Bitget我的公众号

如果你想要跟大家分享你的文章Bitget,欢迎投稿~

┏(^0^)┛明天见Bitget

评论