Android 效能優化(五)之細說 Bitmap

頭條祁同偉發表於2017-03-16

在上一篇《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的載入。

參考:

歡迎關注微信公眾號:定期分享Java、Android乾貨!

Android 效能優化(五)之細說 Bitmap
歡迎關注

相關文章