Bitmap 内存优化

Bitmap到底占用多大的内存

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

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

单个像素的字节大小

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

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

在Android系统中,默认Bitmap加载图片,是ARGB_8888,使用32位真彩色模式。每像素占用 4 个字节,即 argb 每个占用一个byte字节, 因为argb 每个值为0~255 ,所以一个字节可完全表示。所以色彩最真
而RGB_565 则表示,R通道占5位,G通道6位,B通道5位,总占位 5+6+5=16位=2个字节,所以RGB_565 的模式只占2个字节,比ARGB_888少一半的内存。

RGB_565 缺点:
1、 没有Alpha,所以不能用来显示带透明通道的图片,如果你是png,并不是说你用565加载就会少alpha通道,实际上还是为8888加载模式。所以必需文件格式也为jpg。
2、因R通道为例,因为只有5位 2^5=32,展示出来的效果就没有8888那样的鲜艳。
但是如何用0~32 表现出0~255的颜色值,这里给出两种实现猜想,实际可能都不是。

  1. 只显示0~32的颜色值,超过32还是32。也就是颜色值最高32。
  2. 把0~255 平分32段,即如果颜色值为 0~8 ,则都显示为0(第一段),9~16,显示颜色值9,第二段。

加载bitmap图片方式
通过查看Java层源码发现,下面几种获取和setBimtap方式

BitmapFactory.decodeResource(getResources(), resId);
imageView.setImageResource(resId);
imageView.setImageDrawable(resId);
getResources().getDrawable(resId);

注:这里指的resId是位图,不包含xml图片。

都是通过了BitmapFactory工具类。BitmapFactory类中有一系列的decodeXXX方法,用于解析资源文件、本地文件、流等方式,基本流程都很类似,读取目标文件,转换成输入流,调用native方法解析流,虽然Java层代码没有体现,但是我们可以猜想到,最后native方法解析完成后,必然会通过JNI调用Bitmap的构造函数,完成Java层的Bitmap对象创建。

BitmapFactory.java 中解析成bitmap 的native方法

    private static native Bitmap nativeDecodeStream(InputStream is, byte[] storage,
            Rect padding, Options opts);
    private static native Bitmap nativeDecodeFileDescriptor(FileDescriptor fd,
            Rect padding, Options opts);
    private static native Bitmap nativeDecodeAsset(long nativeAsset, Rect padding, Options opts);
    private static native Bitmap nativeDecodeByteArray(byte[] data, int offset,
            int length, Options opts);

从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这个对象的定义。Options 类有几个关键的属性

字段说明
inScaled是否采用缩放
inJustDecodeBounds是否只加载基本信息,如宽高,不加载数据
inBitmapbitmap 对象
inSampleSize宽高缩放,2的x次方
inPreferredConfig加载模式,默认8888
inDensityBitmap位图所在 drawable目录的密度
inTargetDensity设备的密度
outWidth,outHeight加载出的宽高

targetDensity也就对就options里的inTargetDensity,density为inDensity。

drawable 目录dpi 对应如下:

density0.7511.5233.54
densityDpi120160240320480560640
dpiFolderldpimdpihdpixhdpixxhdpixxxhdpixxxxhdpi

注:有些设备 xxxhdpi 可能会对应 dpi 640等,发现和表不对不要吃惊。
targetDpi 设备的dpi可通过 getResources().getDisplayMetrics().densityDpi 获取。
一般设备dpi 也是上面320、480、560、640这些有规则的,但也有设备是420等,也不用吃惊,bitmap 内存计算,还是用420除就行。

以上可以验证几个结论:

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

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

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

可以更细化为:

bitmap宽 = 原图宽 * (targetDpi/目录dpi)
bitmap 高= 原图高 * (targetDpi/目录dpi)
Bitmap内存占用 = 原图片宽 × 原图片高× (设备分辨率/资源目录分辨率)^2 × 每个像素的字节大小

可以通过实验验证。

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

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

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

可以看到,最新的Android O之后,谷歌又把像素存放的位置,从java 堆改回到了 native堆。API 11的那次改动,是源于native的内存释放不及时,会导致OOM,因此才将像素数据保存到Java堆,从而保证Bitmap对象释放时,能够同时把像素数据内存也释放掉。
可以在加载图片后,通过不同版本在AndroidProfile 中看出java 堆和 native 内存增长的区别。

8.0+ Bitmap 分配内存:

// 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分配内存。

3.0~7.0 bitmap 分配内存:

// BitmapFactory.cpp
JavaPixelAllocator javaAllocator(env);
 SkBitmap::Allocator* outputAllocator = (javaBitmap != NULL) ?
           (SkBitmap::Allocator*)&recyclingAllocator : (SkBitmap::Allocator*)&javaAllocator;
 
if (!outputBitmap.tryAllocPixels(outputAllocator, NULL)) {
            return nullObjectReturn("allocation failed for scaled bitmap");
        }
//
///frameworks/base/core/jni/android/graphics/Graphics.cpp
671bool JavaPixelAllocator::allocPixelRef(SkBitmap* bitmap, SkColorTable* ctable) {
672    JNIEnv* env = vm2env(mJavaVM);
673
674    mStorage = GraphicsJNI::allocateJavaPixelRef(env, bitmap, ctable);
675    return mStorage != nullptr;
676}


486android::Bitmap* GraphicsJNI::allocateJavaPixelRef(JNIEnv* env, SkBitmap* bitmap,
487                                             SkColorTable* ctable) {
488   
501    const size_t rowBytes = bitmap->rowBytes();
503    jbyteArray arrayObj = (jbyteArray) env->CallObjectMethod(gVMRuntime,                                                             gVMRuntime_newNonMovableArray,                                                             gByte_class, size);
506    if (env->ExceptionCheck() != 0) {
507        return NULL;
508    }
509    
522    return wrapper;
523}

tryAllocPixels 类似,outputBitmap.tryAllocPixels 是会调用outputAllocator.allocPixelRef,而allocPixelRef就是JavaPixelAllocator。再GraphicsJNI::allocateJavaPixelRef,allocateJavaPixelRef 中通过 (jbyteArray) env->CallObjectMethod(gVMRuntime, gVMRuntime_newNonMovableArray, gByte_class, size); 创建byte 数组。

而java 层的 Bitmap.create()等方法,最终调用 nativeCreate 。

 private static native Bitmap nativeCreate(int[] colors, int offset,
                                              int stride, int width, int height,
                                              int nativeConfig, boolean mutable,
                                              @Nullable @Size(9) float[] xyzD50,
                                              @Nullable ColorSpace.Rgb.TransferParameters p);

对应Bimtap.cpp 中 Bitmap_creator ;

api 11~25 的 Bitmap_creator 方法:


static jobject Bitmap_creator(JNIEnv* env, jobject, jintArray jColors,
                              jint offset, jint stride, jint width, jint height,
                              jint configHandle, jboolean isMutable) {
    ……
    // ARGB_4444 is a deprecated format, convert automatically to 8888
    if (colorType == kARGB_4444_SkColorType) {
        colorType = kN32_SkColorType;
    }

    SkBitmap bitmap;
    bitmap.setInfo(SkImageInfo::Make(width, height, colorType, kPremul_SkAlphaType));

    Bitmap* nativeBitmap = GraphicsJNI::allocateJavaPixelRef(env, &bitmap, NULL);
    if (!nativeBitmap) {
        return NULL;
    }

    if (jColors != NULL) {
        GraphicsJNI::SetPixels(env, jColors, offset, stride,
                0, 0, width, height, bitmap);
    }

    return GraphicsJNI::createBitmap(env, nativeBitmap,
            getPremulBitmapCreateFlags(isMutable));
}

也是调用了GraphicsJNI::allocateJavaPixelRef()。

设备的总内存
final ActivityManager am = (ActivityManager) getSystemService(Context.ACTIVITY_SERVICE);
final ActivityManager.MemoryInfo info = new ActivityManager.MemoryInfo();
 am.getMemoryInfo(info);
 long total=info.totalMem;//设备最大内存
 long availMem=info.availMem;//设备当前可用内存
APP 可用最大Java内存
int large = am.getLargeMemoryClass();//app 开启largeHeap 后最大可用内存
int mem = am.getMemoryClass();//普通app未开启largeHeap最大可用内存

其中largeHeap 中 AndroidManifest.xml application android:largeHeap="true" 中开启,开启后再安装 getLargeMemoryClass后可能不会立即生效,卸载重装试试
通过adb 获取app最大可用内存:

adb shell getprop | grep heap

返回如下:

[dalvik.vm.heapgrowthlimit]: [256m]
[dalvik.vm.heapmaxfree]: [8m]
[dalvik.vm.heapminfree]: [512k]
[dalvik.vm.heapsize]: [512m]
[dalvik.vm.heapstartsize]: [8m]
[dalvik.vm.heaptargetutilization]: [0.75]
[ro.af.client_heap_size_kbyte]: [7168]

其中 heapsize 是开largeHeap后最大可用,heapgrowthlimit 是未开启时最大可用。

如果没有在AndroidManifest中启用largeheap,那么Java 堆内存达到256M的时候就会崩溃,对于现在动辄4G的手机而言,存在严重的资源浪费,ios的一个APP几乎能用近所有的可用内存(除去系统开支),8.0之后,Android也向这个方向靠拢,最好的下手对象就是Bitmap,因为它是耗内存大户。图片内存被转移到native之后,一个APP的图片处理不仅能使用系统绝大多数内存,还能降低Java层内存使用,减少OOM风险。不过,内存无限增长的情况下,也会导致APP崩溃,但是这种崩溃已经不是OOM崩溃了,Java虚拟机也不会捕获,按道理说,应该属于linux的OOM了。这个时候崩溃并不为Java虚拟机控制,直接进程死掉,不会有Crash弹框。其实如果在Android6.0的手机上,在native分配内存,也会达到相同的效果,也就是说native的内存不影响java虚拟机的OOM。

获取app 当前运行内存

long max = Runtime.getRuntime().maxMemory();//总java堆
long total = Runtime.getRuntime().totalMemory();//已用java 堆
当前app 剩余可用内存= max-total

这个maxMemory 会等于 通过am.getMemoryClass 或 am.getLargeMemoryClass 中的一个。

Bitmap内存优化

图片占用的内存一般会分为运行时占用的运存和存储时本地开销(反映在包大小上),这里我们只关注运行时占用内存的优化。
从之前Bitmap占用内存的计算公式来看,减少内存主要可以通过以下几种方式:

分为两大方向:
1、通用优化:使用低色彩的解析模式,如RGB565,减少单个像素的字节大小,不过要注意格式要为jpg.

一、网络图片
在加载网络图片时,并不会存在上面在不同文件夹导致宽高变动的情况,所以网络图片和本地sdcard 图片的内存计算方式还是 width * height * 像素大小。主要优化就是根据view大小,把图片缩放到合适尺寸。一般使用第三方图片库去管理,到不用我们太关心。
二、apk资源图片

  1. 能用shape 画的就用shape drawable
  2. 简单图形与线条组成的icon 用 svg .
  3. 资源文件合理放置,高分辨率图片可以放到高分辨率目录下,千万不要把大图放在低目录下,按照公式算,会非常大。
  4. 图片缩小,减少尺寸。
  5. 常用图片缓存起来,如 占位图,经常进入页面里的图。

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

最后,推荐一个公司的开源库:Tacker
Tracker 是Android 上的一个用户行为跟踪框架,根据预先订阅的事件链,以观察者模式监听用户的行为,当用户的行为与订阅的一样时,通知给订阅者。 基于此框架,你可以轻松实现android 上不侵入业务的逻辑埋点。特别是长路径的埋点更有优势。欢迎star,issue,哈。。

参考:

Android中Bitmap内存优化

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值