Android 性能优化(五)之细说 Bitmap

4,178 阅读12分钟
原文链接: mp.weixin.qq.com

在上一篇《Android性能优化(四)之内存优化实战》中谈到那个内存中的大胖子Bitmap,Bitmap对内存的影响极大。

例如:使用Pixel手机拍摄4048x3036像素(1200W)的照片,如果按ARGB_8888来显示的话,需要48MB的内存空间(4048*3036*4 bytes),这么大的内存消耗极易引发OOM。本篇文章就来说一说这个大胖子。

1. Bitmap内存模型

Android Bitmap内存的管理随着系统的版本迭代也有演进:

1.在Android 2.2(API8)之前,当GC工作时,应用的线程会暂停工作,同步的GC会影响性能。而Android2.3之后,GC变成了并发的,意味着Bitmap没有引用的时候其占有的内存会很快被回收。

2.在Android 2.3.3(API10)之前,Bitmap的像素数据存放在Native内存,而Bitmap对象本身则存放在Dalvik Heap中。Native内存中的像素数据并不会以可预测的方式进行同步回收,有可能会导致内存升高甚至OOM。而在Android3.0之后,Bitmap的像素数据也被放在了Dalvik Heap中。

2. Bitmap的内存回收

2.1 Android2.3.3之前

在Android2.3.3之前推荐使用Bitmap.recycle()方法进行Bitmap的内存回收。

备注:只有当确定这个Bitmap不被引用的时候才能调用此方法,否则会有“Canvas: trying to use a recycled bitmap”这个错误。

官方提供了一个使用Recycle的实例:使用引用计数来判断Bitmap是否被展示或缓存,判断能否被回收。

2.2 Android3.0之后

Android3.0之后,并没有强调Bitmap.recycle();而是强调Bitmap的复用:

2.2.1 Save a bitmap for later use

使用LruCache对Bitmap进行缓存,当再次使用到这个Bitmap的时候直接获取,而不用重走编码流程。

2.2.2 Use an existing bitmap

Android3.0(API 11之后)引入了BitmapFactory.Options.inBitmap字段,设置此字段之后解码方法会尝试复用一张存在的Bitmap。这意味着Bitmap的内存被复用,避免了内存的回收及申请过程,显然性能表现更佳。不过,使用这个字段有几点限制:

  • 声明可被复用的Bitmap必须设置inMutable为true;
  • Android4.4(API 19)之前只有格式为jpg、png,同等宽高(要求苛刻),inSampleSize为1的Bitmap才可以复用;
  • Android4.4(API 19)之前被复用的Bitmap的inPreferredConfig会覆盖待分配内存的Bitmap设置的inPreferredConfig;
  • Android4.4(API 19)之后被复用的Bitmap的内存必须大于需要申请内存的Bitmap的内存;
  • Android4.4(API 19)之前待加载Bitmap的Options.inSampleSize必须明确指定为1。

3. Bitmap占有多少内存?

3.1 getByteCount()

getByteCount()方法是在API12加入的,代表存储Bitmap的色素需要的最少内存。API19开始getAllocationByteCount()方法代替了getByteCount()。

3.2 getAllocationByteCount()

API19之后,Bitmap加了一个Api:getAllocationByteCount();代表在内存中为Bitmap分配的内存大小。

    public final int getAllocationByteCount() {
        if (mBuffer == null) {
            //mBuffer代表存储Bitmap像素数据的字节数组。
            return getByteCount();
        }
        return mBuffer.length;
    }

3.3 getByteCount()与getAllocationByteCount()的区别

  • 一般情况下两者是相等的;
  • 通过复用Bitmap来解码图片,如果被复用的Bitmap的内存比待分配内存的Bitmap大,那么getByteCount()表示新解码图片占用内存的大小(并非实际内存大小,实际大小是复用的那个Bitmap的大小),getAllocationByteCount()表示被复用Bitmap真实占用的内存大小(即mBuffer的长度)。(见第5节的示例)。

4. 如何计算Bitmap占用的内存?

还记得之前我曾言之凿凿的说:不考虑压缩,只是加载一张Bitmap,那么它占用的内存 = width * height * 一个像素所占的内存。
现在想来实在惭愧:说法也对,但是不全对,没有说明场景,同时也忽略了一个影响项:Density。

4.1 BitmapFactory.decodeResource()

    BitmapFactory.java
    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) {
                //inDensity默认为图片所在文件夹对应的密度
                opts.inDensity = DisplayMetrics.DENSITY_DEFAULT;
            } else if (density != TypedValue.DENSITY_NONE) {
                opts.inDensity = density;
            }
        }
        if (opts.inTargetDensity == 0 && res != null) {
            //inTargetDensity为当前系统密度。
            opts.inTargetDensity = res.getDisplayMetrics().densityDpi;
        }
        return decodeStream(is, pad, opts);
    }

    BitmapFactory.cpp 此处只列出主要代码。
    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;
            }
        }
        //原始解码出来的Bitmap;
        SkBitmap decodingBitmap;
        if (decoder->decode(stream, &decodingBitmap, prefColorType, decodeMode)
                != SkImageDecoder::kSuccess) {
            return nullObjectReturn("decoder->decode returned false");
        }
        //原始解码出来的Bitmap的宽高;
        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);
        }    
        //源码解释为因为历史原因;sx、sy基本等于scale。
        const float sx = scaledWidth / float(decodingBitmap.width());
        const float sy = scaledHeight / float(decodingBitmap.height());
        canvas.scale(sx, sy);
        canvas.drawARGB(0x00, 0x00, 0x00, 0x00);
        canvas.drawBitmap(decodingBitmap, 0.0f, 0.0f, &paint);
        // now create the java bitmap
        return GraphicsJNI::createBitmap(env, javaAllocator.getStorageObjAndReset(),
            bitmapCreateFlags, ninePatchChunk, ninePatchInsets, -1);
    }

此处可以看出:加载一张本地资源图片,那么它占用的内存 = width * height * nTargetDensity/inDensity * nTargetDensity/inDensity * 一个像素所占的内存。

实验:将长为1024、宽为594的一张图片放在xhdpi的文件夹下,使用魅族MX3手机加载。

        // 不做处理,默认缩放。
        BitmapFactory.Options options = new BitmapFactory.Options();
        Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.mipmap.resbitmap, options);
        Log.i(TAG, "bitmap:ByteCount = " + bitmap.getByteCount() + ":::bitmap:AllocationByteCount = " + bitmap.getAllocationByteCount());
        Log.i(TAG, "width:" + bitmap.getWidth() + ":::height:" + bitmap.getHeight());
        Log.i(TAG, "inDensity:" + options.inDensity + ":::inTargetDensity:" + options.inTargetDensity);

        Log.i(TAG,"===========================================================================");

        // 手动设置inDensity与inTargetDensity,影响缩放比例。
        BitmapFactory.Options options_setParams = new BitmapFactory.Options();
        options_setParams.inDensity = 320;
        options_setParams.inTargetDensity = 320;
        Bitmap bitmap_setParams = BitmapFactory.decodeResource(getResources(), R.mipmap.resbitmap, options_setParams);
        Log.i(TAG, "bitmap_setParams:ByteCount = " + bitmap_setParams.getByteCount() + ":::bitmap_setParams:AllocationByteCount = " + bitmap_setParams.getAllocationByteCount());
        Log.i(TAG, "width:" + bitmap_setParams.getWidth() + ":::height:" + bitmap_setParams.getHeight());
        Log.i(TAG, "inDensity:" + options_setParams.inDensity + ":::inTargetDensity:" + options_setParams.inTargetDensity);

        输出:
        I/lz: bitmap:ByteCount = 4601344:::bitmap:AllocationByteCount = 4601344
        I/lz: width:1408:::height:817 // 可以看到此处:Bitmap的宽高被缩放了440/320=1.375倍
        I/lz: inDensity:320:::inTargetDensity:440 // 默认资源文件所处文件夹密度与手机系统密度
        I/lz: ===========================================================================
        I/lz: bitmap:ByteCount = 2433024:::bitmap:AllocationByteCount = 2433024
        I/lz: width:1024:::height:594 // 手动设置了缩放系数为1,Bitmap的宽高都不变
        I/lz: inDensity:320:::inTargetDensity:320

可以看出:

  1. 不使用Bitmap复用时,getByteCount()与getAllocationByteCount()的值是一致的;
  2. 默认情况下使用魅族MX3、在xhdpi的文件夹下,inDensity为320,inTargetDensity为440,内存大小为4601344;而4601344 = 1024 * 594 * (440 / 320)* (440 / 320)* 4。
  3. 手动设置inDensity与inTargetDensity,使其比例为1,内存大小为2433024;2433024 = 1024 * 594 * 1 * 1 * 4。

4.2 BitmapFactory.decodeFile()

与BitmapFactory.decodeResource()的调用链基本一致,但是少了默认设置density和inTargetDensity(与缩放比例相关)的步骤,也就没有了缩放比例这一说。

除了加载本地资源文件的解码方法会默认使用资源所处文件夹对应密度和手机系统密度进行缩放之外,别的解码方法默认都不会。此时Bitmap默认占用的内存 = width * height * 一个像素所占的内存。这也就是上面4.1开头讲的需要注意场景。

4.3 一个像素占用多大内存?

Bitmap.Config用来描述图片的像素是怎么被存储的?
ARGB_8888: 每个像素4字节. 共32位,默认设置。
Alpha_8: 只保存透明度,共8位,1字节。
ARGB_4444: 共16位,2字节。
RGB_565:共16位,2字节,只存储RGB值。

5. Bitmap如何复用?

在上述2.2.2我们谈到了Bitmap的复用,以及复用的限制,Google在《Managing Bitmap Memory》中给出了详细的复用Demo:

  1. 使用LruCache和DiskLruCache做内存和磁盘缓存;
  2. 使用Bitmap复用,同时针对版本进行兼容。
    此处我写一个简单的demo,机型魅族MX3,系统版本API21;图片宽1024、高594,进行Bitmap复用的实验;
BitmapFactory.Options options = new BitmapFactory.Options();
// 图片复用,这个属性必须设置;
options.inMutable = true;
// 手动设置缩放比例,使其取整数,方便计算、观察数据;
options.inDensity = 320;
options.inTargetDensity = 320;
Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.resbitmap, options);
// 对象内存地址;
Log.i(TAG, "bitmap = " + bitmap);
Log.i(TAG, "bitmap:ByteCount = " + bitmap.getByteCount() + ":::bitmap:AllocationByteCount = " + bitmap.getAllocationByteCount());

// 使用inBitmap属性,这个属性必须设置;
options.inBitmap = bitmap;
options.inDensity = 320;
// 设置缩放宽高为原始宽高一半;
options.inTargetDensity = 160;
options.inMutable = true;
Bitmap bitmapReuse = BitmapFactory.decodeResource(getResources(), R.drawable.resbitmap_reuse, options);
// 复用对象的内存地址;
Log.i(TAG, "bitmapReuse = " + bitmapReuse);
Log.i(TAG, "bitmap:ByteCount = " + bitmap.getByteCount() + ":::bitmap:AllocationByteCount = " + bitmap.getAllocationByteCount());
Log.i(TAG, "bitmapReuse:ByteCount = " + bitmapReuse.getByteCount() + ":::bitmapReuse:AllocationByteCount = " + bitmapReuse.getAllocationByteCount());

输出:
I/lz: bitmap = android.graphics.Bitmap@35ac9dd4
I/lz: width:1024:::height:594
I/lz: bitmap:ByteCount = 2433024:::bitmap:AllocationByteCount = 2433024
I/lz: bitmapReuse = android.graphics.Bitmap@35ac9dd4 // 两个对象的内存地址一致
I/lz: width:512:::height:297
I/lz: bitmap:ByteCount = 608256:::bitmap:AllocationByteCount = 2433024
I/lz: bitmapReuse:ByteCount = 608256:::bitmapReuse:AllocationByteCount = 2433024 // ByteCount比AllocationByteCount小

可以看出:

  1. 从内存地址的打印可以看出,两个对象其实是一个对象,Bitmap复用成功;
  2. bitmapReuse占用的内存(608256)正好是bitmap占用内存(2433024)的四分之一;
  3. getByteCount()获取到的是当前图片应当所占内存大小,getAllocationByteCount()获取到的是被复用Bitmap真实占用内存大小。虽然bitmapReuse的内存只有608256,但是因为是复用的bitmap的内存,因而其真实占用的内存大小是被复用的bitmap的内存大小(2433024)。这也是getAllocationByteCount()可能比getByteCount()大的原因。

6. Bitmap如何压缩?

6.1 Bitmap.compress()

质量压缩:
它是在保持像素的前提下改变图片的位深及透明度等,来达到压缩图片的目的,不会减少图片的像素。进过它压缩的图片文件大小会变小,但是解码成bitmap后占得内存是不变的。

6.2 BitmapFactory.Options.inSampleSize

内存压缩:

  • 解码图片时,设置BitmapFactory.Options类的inJustDecodeBounds属性为true,可以在Bitmap不被加载到内存的前提下,获取Bitmap的原始宽高。而设置BitmapFactory.Options的inSampleSize属性可以真实的压缩Bitmap占用的内存,加载更小内存的Bitmap。
  • 设置inSampleSize之后,Bitmap的宽、高都会缩小inSampleSize倍。例如:一张宽高为2048x1536的图片,设置inSampleSize为4之后,实际加载到内存中的图片宽高是512x384。占有的内存就是0.75M而不是12M,足足节省了15倍。

备注:inSampleSize值的大小不是随便设、或者越大越好,需要根据实际情况来设置。
以下是设置inSampleSize值的一个示例:

public static Bitmap decodeSampledBitmapFromResource(Resources res, int resId,
        int reqWidth, int reqHeight) {
    // 设置inJustDecodeBounds属性为true,只获取Bitmap原始宽高,不分配内存;
    final BitmapFactory.Options options = new BitmapFactory.Options();
    options.inJustDecodeBounds = true;
    BitmapFactory.decodeResource(res, resId, options);
    // 计算inSampleSize值;
    options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight);
    // 真实加载Bitmap;
    options.inJustDecodeBounds = false;
    return BitmapFactory.decodeResource(res, resId, options);
}

public static int calculateInSampleSize(
            BitmapFactory.Options options, int reqWidth, int reqHeight) {
    // Raw height and width of image
    final int height = options.outHeight;
    final int width = options.outWidth;
    int inSampleSize = 1;
    if (height > reqHeight || width > reqWidth) {
        final int halfHeight = height / 2;
        final int halfWidth = width / 2;
        // 宽和高比需要的宽高大的前提下最大的inSampleSize
        while ((halfHeight / inSampleSize) >= reqHeight
                && (halfWidth / inSampleSize) >= reqWidth) {
            inSampleSize *= 2;
        }
    }
    return inSampleSize;
}

这样使用:mImageView.setImageBitmap(
   decodeSampledBitmapFromResource(getResources(), R.id.myimage, 100, 100));

备注:

  • inSampleSize比1小的话会被当做1,任何inSampleSize的值会被取接近2的幂值。

7. 总结

1. Bitmap内存模型

  • Android 2.3.3(API10)之前,Bitmap的像素数据存放在Native内存,而Bitmap对象本身则存放在Dalvik Heap中。而在Android3.0之后,Bitmap的像素数据也被放在了Dalvik Heap中。

2. Bitmap的内存回收

  • 在Android2.3.3之前推荐使用Bitmap.recycle()方法进行Bitmap的内存回收;
  • 在Android3.0之后更注重对Bitmap的复用;

3. Bitmap占用内存的计算

  • getByteCount()方法是在API12加入的,代表存储Bitmap的色素需要的最少内存;
    • getAllocationByteCount()在API19加入,代表在内存中为Bitmap分配的内存大小;
  • 在复用Bitmap的情况下,getAllocationByteCount()可能会比getByteCount()大;
  • 计算公式:
    • 对资源文件:width * height * nTargetDensity/inDensity * nTargetDensity/inDensity * 一个像素所占的内存;
    • 别的:width * height * 一个像素所占的内存;

4. Bitmap的复用

  • BitmapFactory.Options.inBitmap,针对不同版本复用有不同的限制,见上2.2.2,较多此处不再赘述;

5. Bitmap的压缩

  • Bitmap.compress(),质量压缩,不会对内存产生印象;
  • BitmapFactory.Options.inSampleSize,内存压缩;
    • inSampleSize的比对获取;

6. Glide

  • 查看官方文档以及性能优化典范,Google强烈推荐使用Glide来做Bitmap的加载。

参考:

  • 《Caching Bitmaps》
  • 《Handling Bitmaps》
  • 《Re-using Bitmaps》
  • 《Managing Bitmap Memory》
  • 《Loading Large Bitmaps Efficiently》