Android中Bitmap内存优化

Android开发中,Bitmap是经常会遇到的对象,特别是在列表图片展示、大图显示等界面。而Bitmap实实在在是内存使用的“大客户”。如何更好的使用Bitmap,减少其对App内存的使用,是Android优化方面不可回避的问题。因此,本文从常规的Bitmap使用,到Bitmap内存计算进行了介绍,最后分析了Bitmap的源码和其内存模型在不同版本上的变化。

Bitmap的使用

一般来说,一个对象的使用,我们会尝试利用其构造函数去生成这个对象。在Bitmap中,其构造函数:

// called from JNI
    Bitmap(long nativeBitmap, byte[] buffer, int width, int height, int density,
            boolean isMutable, boolean requestPremultiplied,
            byte[] ninePatchChunk, NinePatch.InsetStruct ninePatchInsets) 

通过构造函数的注释,得知这是一个给native层调用的方法,因此可以知道Bitmap的创建将会涉及到底层库的支持。为了方便从不同来源来创建Bitmap,Android中提供了BitmapFactory工具类。BitmapFactory类中有一系列的decodeXXX方法,用于解析资源文件、本地文件、流等方式,基本流程都很类似,读取目标文件,转换成输入流,调用native方法解析流,虽然Java层代码没有体现,但是我们可以猜想到,最后native方法解析完成后,必然会通过JNI调用Bitmap的构造函数,完成Java层的Bitmap对象创建。

// BitmapFactory部分代码:
public static Bitmap decodeResource(Resources res, int id)
public static Bitmap decodeStream(InputStream is)
private static native Bitmap nativeDecodeStream

native层的代码稍后我们在看,先从Java层来看看常规的使用。典型的一个例子是,当我们需要从本地Resource中加载一个图片,并展示出来,我们可以通过BitmapFacotry来完成:

Bitmap bitmapDecode = BitmapFactory.decodeResource(getResources(), resId);
imageView.setImageBitmap(bitmapDecode);

当然,这里简单的使用imageView.setImageResource(int resId)也能实现一样的效果,实际上setImageResource方法只是封装了bitmap的读入、解析的过程,并且这个过程是在UI线程完成的,对于性能是有所影响的。另外,也对接下来讨论的内容,Bitmap占用的内存有影响。

Bitmap到底占用多大的内存

Bitmap作为位图,需要读入一张图片每一个像素点的数据,其主要占用内存的地方也正是这些像素数据。对于像素数据总大小,我们可以猜想为:像素总数量 × 每个像素的字节大小,而像素总数量在矩形屏幕表现下,应该是:横向像素数量 × 纵向像素数量,结合得到:

Bitmap内存占用 ≈ 像素数据总大小 = 横向像素数量 × 纵向像素数量 × 每个像素的字节大小

单个像素的字节大小

单个像素的字节大小由Bitmap的一个可配置的参数Config来决定。
Bitmap中,存在一个枚举类Config,定义了Android中支持的Bitmap配置:

Config 占用字节大小(byte) 说明
ALPHA_8 (1) 1 单透明通道
RGB_565 (3) 2 简易RGB色调
ARGB_4444 (4) 2 已废弃
ARGB_8888 (5) 4 24位真彩色
RGBA_F16 (6) 8 Android 8.0 新增(更丰富的色彩表现HDR)
HARDWARE (7) Special Android 8.0 新增 (Bitmap直接存储在graphic memory)注1

注1:关于Android 8.0中新增的这个配置,stackoverflow已经有相关问题,可以关注下。

之前我们分析到,Bitmap的decode实际上是在native层完成的,因此在native层也存在对应的Config枚举类。
一般使用时,我们并未关注这个配置,在BitmapFactory中,有:

  * Image are loaded with the {@link Bitmap.Config#ARGB_8888} config by default.
  */
  public Bitmap.Config inPreferredConfig = Bitmap.Config.ARGB_8888;

因此,Android系统中,默认Bitmap加载图片,使用24位真彩色模式。

Bitmap占用内存大小实例

首先准备了一张800×600分辨率的jpg图片,大小约135k,放置于res/drawable文件夹下:


awesomecoder.jpg

并将其加载到一个200dp×300dp大小的ImageView中,使用BitmapFactory。

Bitmap bitmapDecode = BitmapFactory.decodeResource(getResources(), resId);
imageView.setImageBitmap(bitmapDecode);

打印出相关信息:


nodpis.png

图中显示了从资源文件中decode得到的bitmap的长、宽和占用内存大小(byte)等信息。
首先,从数据上可以验证:

17280000 = 2400 * 1800 * 4

这意味着,为了将单张800 * 600 的图片加载到内存当中,付出了近17.28M的代价,即使现在手机运存普遍上涨,这样的开销也是无法接受的,因此,对于Bitmap的使用,是需要非常小心的。好在,目前主流的图像加载库(Glide、Fresco等)基本上都不在需要开发者去关心Bitmap内存占用问题。
先暂时回到Bitmap占用内存的计算上来,对比之前定义的公式和源图片的尺寸数据,我们会发现,这张800 * 600大小的图片,decode到内存中的Bitmap的横纵像素数量实际是:2400 * 1800,相当于缩放了3倍大小。为了探究这缩放来自何处,我们开始跟踪源码:之前提到过,Bitmap的decode过程实际上是在native层完成的,为此,需要从BitmapFactory.cpp#nativeDecodeXXX方法开始跟踪,这里省略其他decode代码,直接贴出和缩放相关的代码如下:

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;
    }
}
...
int scaledWidth = decoded->width();
int scaledHeight = decoded->height();

if (willScale && mode != SkImageDecoder::kDecodeBounds_Mode) {
    scaledWidth = int(scaledWidth * scale + 0.5f);
    scaledHeight = int(scaledHeight * scale + 0.5f);
}
...
if (willScale) {
    const float sx = scaledWidth / float(decoded->width());
    const float sy = scaledHeight / float(decoded->height());
    bitmap->setConfig(decoded->getConfig(), scaledWidth, scaledHeight);
    bitmap->allocPixels(&javaAllocator, NULL);
    bitmap->eraseColor(0);
    SkPaint paint;
    paint.setFilterBitmap(true);
    SkCanvas canvas(*bitmap);
    canvas.scale(sx, sy);
    canvas.drawBitmap(*decoded, 0.0f, 0.0f, &paint);
}

从上述代码中,我们看到bitmap最终通过canvas绘制出来,而canvas在绘制之前,有一个scale的操作,scale的值由

scale = (float) targetDensity / density;

这一行代码决定,即缩放的倍率和targetDensity和density相关,而这两个参数都是从传入的options中获取到的。这时候,需要回到Java层,看看options这个对象的定义和赋值。

BitmapFactory#Options

Options是BitmapFactory中的一个静态内部类,用于配置Bitmap在decode时的一些参数。

// native层doDecode方法,传入了Options参数
static jobject doDecode(JNIEnv* env, SkStreamRewindable* stream, jobject padding, jobject options)

其内部有很多可配置的参数,下面的类图,列举出了部分常用的参数。


BitmapFactory.Options.png

我们先关注之前提到的几个密度相关的参数,通过阅读源码的注释,大概可以知道这三个密度参数代表的涵义:

  • inDensity:Bitmap位图自身的密度、分辨率
  • inTargetDensity: Bitmap最终绘制的目标位置的分辨率
  • inScreenDensity: 设备屏幕分辨率

其中inDensity和图片存放的资源文件的目录有关,同一张图片放置在不同目录下会有不同的值:

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

inTargetDensity和inScreenDensity一般来说,很少手动去赋值,默认情况下,是和设备分辨率保持一致。为此,我在手机(红米4,Android 6.0系统,设备dpi 480)上测试加载不同资源文件下的bitmap的参数,结果见下图:


对比不同资源目录.png

以上可以验证几个结论:

  • 同一张图片,放在不同资源目录下,其分辨率会有变化,
  • bitmap分辨率越高,其解析后的宽高越小,甚至会小于图片原有的尺寸(即缩放),从而内存占用也相应减少
  • 图片不特别放置任何资源目录时,其默认使用mdpi分辨率:160
  • 资源目录分辨率和设备分辨率一致时,图片尺寸不会缩放

因此,关于Bitmap占用内存大小的公式,从之前:

Bitmap内存占用 ≈ 像素数据总大小 = 横向像素数量 × 纵向像素数量 × 每个像素的字节大小

可以更细化为:

Bitmap内存占用 ≈ 像素数据总大小 = 图片宽 × 图片高× (设备分辨率/资源目录分辨率)^2 × 每个像素的字节大小

对于本节中最开始的例子,如下:

17,280,000 = 800 * 600 * (480 / 160 )^2 * 4

Bitmap内存优化

图片占用的内存一般会分为运行时占用的运存和存储时本地开销(反映在包大小上),这里我们只关注运行时占用内存的优化。
在上一节中,我们看到对于一张800 * 600 大小的图片,不加任何处理直接解析到内存中,将近占用了17.28M的内存大小。想象一下这样的开销发生在一个图片列表中,内存占用将达到非常夸张的地步。从之前Bitmap占用内存的计算公式来看,减少内存主要可以通过以下几种方式:

  1. 使用低色彩的解析模式,如RGB565,减少单个像素的字节大小
  2. 资源文件合理放置,高分辨率图片可以放到高分辨率目录下
  3. 图片缩小,减少尺寸

第一种方式,大约能减少一半的内存开销。Android默认是使用ARGB8888配置来处理色彩,占用4字节,改用RGB565,将只占用2字节,代价是显示的色彩将相对少,适用于对色彩丰富程度要求不高的场景。
第二种方式,和图片的具体分辨率有关,建议开发中,高分辨率的图像应该放置到合理的资源目录下,注意到Android默认放置的资源目录是对应于160dpi,目前手机屏幕分辨率越来越高,此处能节省下来的开销也是很可观的。理论上,图片放置的资源目录分辨率越高,其占用内存会越小,但是低分辨率图片会因此被拉伸,显示上出现失真。另一方面,高分辨率图片也意味着其占用的本地储存也变大。
第三种方式,理论上根据适用的环境,是可以减少十几倍的内存使用的,它基于这样一个事实:源图片尺寸一般都大于目标需要显示的尺寸,因此可以通过缩放的方式,来减少显示时的图片宽高,从而大大减少占用的内存。

前两种方式,相对比较简单。第三种方式会涉及到一些编码,目前也有很多典型的使用方式,如下:

BitmapFactory.Options options = new BitmapFactory.Options();
options.inPreferredConfig = Bitmap.Config.RGB_565;
options.inJustDecodeBounds = true;
BitmapFactory.decodeResource(getResources(), resId,options);
options.inJustDecodeBounds = false;
options.inSampleSize = BitmapUtil.computeSampleSize(options, -1, imageView.getWidth() * imageView.getHeight());
Bitmap newBitmap = BitmapFactory.decodeResource(getResources(), resId, options);

原理很简单,充分利用了Options类里的参数设置,也可以从native底层源码上看到对应的逻辑。第一次解析bitmap只获取尺寸信息,不生成像素数据,继而比较bitmap尺寸和目标尺寸得到缩放倍数,第二次根据缩放倍数去解析我们实际需要的尺寸大小。

// Apply a fine scaling step if necessary.
    if (needsFineScale(codec->getInfo().dimensions(), size, sampleSize)) {
        willScale = true;
        scaledWidth = codec->getInfo().width() / sampleSize;
        scaledHeight = codec->getInfo().height() / sampleSize;
    }
优化后.png

上图是使用上述手段优化后的结果,可以看到现在占用的内存大小大约为960KB,从优化后的宽高来看,第三种方式并没有效果。应为目标ImageView尺寸也不小,而inSampleSize的值必须是2的整数幂,因此计算得到的值还是1。

PS: Bitmap内存占用的优化还有一个方式是复用和缓存

不同Android版本时的Bitmap内存模型

我们知道Android系统中,一个进程的内存可以简单分为Java内存和native内存两部分,而Bitmap对象占用的内存,有Bitmap对象内存和像素数据内存两部分,在不同的Android系统版本中,其所存放的位置也有变化。Android Developers上列举了从API 8 到API 26之间的分配方式:

API级别 API 10 - API 11 ~ API 25 API 26 +
Bitmap对象存放 Java heap Java heap Java heap
像素(pixel data)数据存放 native heap Java heap native heap

可以看到,最新的Android O之后,谷歌又把像素存放的位置,从java 堆改回到了 native堆。API 11的那次改动,是源于native的内存释放不及时,会导致OOM,因此才将像素数据保存到Java堆,从而保证Bitmap对象释放时,能够同时把像素数据内存也释放掉。


Android 6.0内存变化.png

Android 8.0内存变化.png

上面两幅图展示了不同系统,加载图片后,内存的变化,8.0的截图比较模糊。途中浅蓝色对应的是Java heap使用,深蓝色对应的是native heap的使用。
跟踪一下8.0的native源码来看看具体的变化:

// BitmapFactory.cpp
    if (!decodingBitmap.setInfo(bitmapInfo) ||
            !decodingBitmap.tryAllocPixels(decodeAllocator, colorTable.get())) {
        // SkAndroidCodec should recommend a valid SkImageInfo, so setInfo()
        // should only only fail if the calculated value for rowBytes is too
        // large.
        // tryAllocPixels() can fail due to OOM on the Java heap, OOM on the
        // native heap, or the recycled javaBitmap being too small to reuse.
        return nullptr;
    }

// Graphics.cpp
bool HeapAllocator::allocPixelRef(SkBitmap* bitmap, SkColorTable* ctable) {
    mStorage = android::Bitmap::allocateHeapBitmap(bitmap, sk_ref_sp(ctable));
    return !!mStorage;
}

// https://android.googlesource.com/platform/frameworks/base/+/master/libs/hwui/hwui/Bitmap.cpp
static sk_sp<Bitmap> allocateHeapBitmap(size_t size, const SkImageInfo& info, size_t rowBytes) {
    void* addr = calloc(size, 1);
    if (!addr) {
        return nullptr;
    }
    return sk_sp<Bitmap>(new Bitmap(addr, size, info, rowBytes));
}

还是通过BitmapFactory.cpp#doDecode方法来跟踪,发现其中tryAllocPixels方法,应该是尝试去进行内存分配,其中decodeAllocator会被赋值为HeapAllocator,通过一系列的调用,最终通过calloc方法,在native分配内存。
至于为什么Google 在8.0上改变了Bitmap像素数据的存放方式,我猜想和8.0中的GC算法调整有关系。GC算法的优化,使得Bitmap占用的大内存区域,在GC后也能够比较快速的回收、压缩,重新使用。

(native存放) 退出Activity 退出App
onStop中主动调用gc()和recycler() 内存不释放 内存释放
无调用 内存不释放 内存不释放
(gpu存放) 退出Activity 退出App
onStop中主动调用gc()和recycler() 内存释放 内存释放
无调用 内存释放 内存释放

总结

// 8.0源码
    Bitmap(long nativeBitmap, int width, int height, int density,
            boolean isMutable, boolean requestPremultiplied,
            byte[] ninePatchChunk, NinePatch.InsetStruct ninePatchInsets)
// 7.0源码
Bitmap(long nativeBitmap, byte[] buffer, int width, int height, int density,
            boolean isMutable, boolean requestPremultiplied,
            byte[] ninePatchChunk, NinePatch.InsetStruct ninePatchInsets)

一开始看两者java代码不同,少了存放像素的buffer字段,查阅相关资料到native源码对比,最终总结了下Bitmap内存相关的知识。另外,在Android 8.0中,关于Bitmap的改动有两方面还需深入探究的:1、Config配置为Hardware时的优劣。Hardware配置实际上没有改变像素的位储存大小(还是默认的ARGB8888),但是改变了bitmap像素的存储位置(存放到GPU内存中),对实际应用的影响会如何?;2、Bitmap在8.0后又回归到native存放bitmap像素数据,而这部分数据的回收时机和触发方式又是如何?一般测试下,可以通过native分配Bitmap超过1G的内存数据而不发生崩溃。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,293评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,604评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,958评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,729评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,719评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,630评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,000评论 3 397
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,665评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,909评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,646评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,726评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,400评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,986评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,959评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,197评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,996评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,481评论 2 342