Bitmap: Android 内存刺客 | 开发者说·DTalk
The following article is from 星际码仔 Author 椎锋陷陈
本文是 Bitmap 这个话题的上篇,从图像基础知识出发,结合源码讲解 Bitmap 内存的计算方式。
从一个问题出发
压缩格式大小≠占用内存大小
首先我们要明确的是,无论是 JPEG 还是 PNG,它们本质上都是一种压缩格式,压缩的目的是为了降低存储和传输的成本。
JPEG 是一种有损压缩格式,压缩比大,压缩后的体积比较小,但其高压缩率是通过去除冗余的图像数据进行的,因此解压后无法还原出完整的原始图像数据。
PNG 则是一种无损压缩格式,不会损失图片质量,解压后能还原出完整的原始图像数据,但也因此压缩比小,压缩后的体积仍然很大。
开篇问题中所特意强调的图片大小,实际指的就是压缩格式文件的大小。而问题最后所问的图片实际占用的内存,指的则是解压缩后显示在设备屏幕上的原始图像数据所占用的内存。
在实际的 Android 开发中,我们经常直接接触到的原始图像数据,就是通过各种 decode 方法解码出的 Bitmap 对象。
Bitmap 即位图,它还有另外一个名称叫做点阵图,相对来说,点阵图这个名称更能表述 Bitmap 的特征。
点指的是像素点,阵指的是阵列。点阵图,就是以像素为最小单位构成的图,缩放会失真。每个像素实则都是一个非常小的正方形,并被分配不同的颜色,然后通过不同的排列来构成像素阵列,最终呈现出完整的图像。
△ 放大 12 倍显示独立像素
那么每个像素是如何存储自己的颜色信息的呢?这涉及到图片的色深。
色深是什么?
常见的色深有:
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 个位的透明通道。
可以看出,色深越大,能表示的颜色越丰富,图片也就越鲜艳,颜色过渡就越平滑。但相对的,图片的体积也会增加,因为每个像素必须存储更多的颜色信息。
Android 中与色深配置相关的类是 Bitmap.Config,其取值会直接影响位图的质量 (色彩深度) 以及显示透明/半透明颜色的能力。在 Android 2.3 (API 级别 9) 及更高版本中的默认配置是 ARGB_8888,也即 32 bit 的色深,1 byte = 8 bit,因此该配置下每个像素的大小为 4 byte。
位图内存 = 像素数量 (分辨率) * 每个像素的大小,想要进一步计算加载位图所需要的内存,我们还需要得知像素的总数量,而描述像素数量的说法就是分辨率。
分辨率是什么?
public int getByteCount ()public int getAllocationByteCount ()探究 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();}public final int getRowBytes() { if (mRecycled) { Log.w(TAG, "Called getRowBytes() on a recycle()'d bitmap! This is undefined behavior!"); } return nativeRowBytes(mFinalizer.mNativeBitmap);}size_t SkBitmap::ComputeRowBytes(Config c, int width) { return SkColorTypeMinRowBytes(SkBitmapConfigToColorType(c), width);}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 查找最佳匹配资源的规则
假设最接近设备屏幕密度的目录选项为 xhdpi,如果图片资源存在,则匹配成功;
如果不存在,系统就会从更高密度的资源目录下查找,依次为 xxhdpi、xxxhdpi; 如果还不存在,系统就会从像素密度无关的资源目录 nodpi 下查找; 如果还不存在,系统就会向更低密度的资源目录下查找,依次为 hdpi、mdpi、ldpi。
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);}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())); } ...}首先,设置 scale 值也即初始的缩放比为 1。
取出关键的 density 值以及 targetDensity 值,以目标像素密度/位图像素密度重新计算缩放比。 如果缩放比不再为 1,则说明原始图像需要进行缩放。 取出待解码的位图的宽度,按 int (scaledWidth * scale + 0.5f) 计算缩放后的宽度,高度同理。 重新填充缩放后的宽高回 Options。
总结
汇总上述的所有内容后,我们可以得出结论,即:
Android 系统为 Bitmap 存储像素所分配的内存大小,取决于以下几个因素:
色深,也即每个像素的大小,对应的是 Bitmap.Config 的配置;
分辨率,也即像素的总数量,对应的是 Bitmap 的高度和宽度;
像素密度,对应的是图片资源所在的密度目录,以及设备的屏幕像素密度。
图片资源放到正确的密度目录很重要,否则可能会对较大尺寸的图片进行不合理的缩放,从而加大不必要的内存占用;
如果是为了减少包体积而不想提供所有密度目录下不同尺寸的图片,应优先提供更高密度目录下的图片资源,可以避免图片失真。
长按右侧二维码
查看更多开发者精彩分享
"开发者说·DTalk" 面向