“雪糕刺客”你聽說過,Bitmap這個“記憶體刺客”你也要小心(上)~

語言: CN / TW / HK

theme: channing-cyan

寫在前面

雪糕刺客是最近被網友們玩壞了的梗,指的是那些以平平無奇的外表混跡於眾多平價雪糕之中的貴价雪糕。由於沒有明確標明價格,通常要等到結賬的時候才會發現,猶如一個潛藏於普通人群中的刺客般,伺機對那些大意的顧客們的錢包刺上一劍,因此得名。

而在Android中,也有這麼一個記憶體刺客,其作為我們日常開發中經常接觸的物件之一,卻常常因為使用方式的不當,時不時地就會給我們有限的記憶體來上一個背刺,甚至毫不留情地就給我們丟擲一個OOM,它,就是Bitmap

為了講好Bitmap這個話題,本系列文章將分為上下兩篇,上篇從影象基礎知識出發,結合原始碼講解Bitmap記憶體的計算方式;下篇則基於Android系統提供的API,講解在實際開發中如何管理好Bitmap的記憶體,包括縮放、快取、記憶體複用等,敬請期待。

本文為上篇,開始之前,先奉上的思維導圖一張,方便後續複習:

Bitmap記憶體計算.png

從一個問題出發

假設有這麼一張PNG格式的圖片,其大小為15.3KB,尺寸為96x96,色深為32 bit,放到xhdpi目錄下,並載入到一臺dpi為480的Android裝置上顯示,那麼請問,該圖片實際會佔用多大的記憶體?

實際會佔用多大的記憶體.png

如果你回答不了這個問題,那你就有必要深入往下讀了。

壓縮格式大小≠佔用記憶體大小

首先我們要明確的是,無論是JPEG還是PNG,它們本質上都是一種壓縮格式,壓縮的目的是為了降低儲存和傳輸的成本

區別就在於:

JPEG是一種有失真壓縮格式,壓縮比大,壓縮後的體積比較小,但其高壓縮率是通過去除冗餘的影象資料進行的,因此解壓後無法還原出完整的原始影象資料。

PNG則是一種無失真壓縮格式,不會損失圖片質量,解壓後能還原出完整的原始影象資料,但也因此壓縮比小,壓縮後的體積仍然很大。

開篇問題中所特意強調的圖片大小,實際指的就是壓縮格式檔案的大小。而問題最後所問的圖片實際佔用的記憶體,指的則是解壓縮後顯示在裝置螢幕上的原始影象資料所佔用的記憶體

在實際的Android開發中,我們經常直接接觸到的原始影象資料,就是通過各種decode方法解碼出的Bitmap物件

Bitmap即點陣圖,它還有另外一個名稱叫做點陣圖,相對來說,點陣圖這個名稱更能表述Bitmap的特徵。

指的是畫素點指的是陣列。點陣圖,就是以畫素為最小單位構成的圖,縮放會失真。每個畫素實則都是一個非常小的正方形,並被分配不同的顏色,然後通過不同的排列來構成畫素陣列,最終呈現出完整的影象

放大12倍顯示獨立畫素

那麼每個畫素是如何儲存自己的顏色資訊的呢?這涉及到圖片的色深。

色深是什麼?

色深,又叫色彩深度(Color Depth)。假設色深的數值為n,代表每個畫素會採用n個二進位制位來儲存顏色資訊,也即2的n次方,表示的是每個畫素能顯示2^n種顏色**。

常見的色深有:

  • 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個位的透明通道。

色深會影響圖片的整體質量,我們可以來看同一張圖片在不同色深下的表現:

24-bit color: 224 = 16,777,216 colors, 45 KB

8-bit color: 28 = 256 colors, 17 KB

4-bit color: 24 = 16 colors, 6 KB

2-bit color: 22 = 4 colors, 4 KB

1-bit color: 21 = 2 colors, 3 KB

可以看出,色深越大,能表示的顏色越豐富,圖片也就越鮮豔,顏色過渡就越平滑。但相對的,圖片的體積也會增加,因為每個畫素必須儲存更多的顏色資訊

Android中與色深配置相關的類是Bitmap.Config,其取值會直接影響點陣圖的質量(色彩深度)以及顯示透明/半透明顏色的能力。在Android 2.3(API 級別 9)及更高版本中的預設配置是ARGB_8888,也即32 bit的色深,1 byte = 8 bit,因此該配置下每個畫素的大小為4 byte。

點陣圖記憶體 = 畫素數量(解析度) * 每個畫素的大小,想要進一步計算載入點陣圖所需要的記憶體,我們還需要得知畫素的總數量,而描述畫素數量的說法就是解析度。

解析度是什麼?

如果說,色深決定了點陣圖顏色的豐富程度,那麼解析度決定的則是點陣圖影象細節的精細程度影象的解析度越高,所包含的畫素就越多,影象也就越清晰,同樣的,它也會相應增加圖片的體積

通常,我們用每一個方向上的畫素數量來表示解析度,也即水平畫素數×垂直畫素數,比如320×240,640×480,1280×1024等。

一張解析度為640x480的圖片,其畫素數量就達到了307200,也就是我們常說的30萬畫素。

現在,我們明白了公式中2個變數的含義,就可以代入開篇問題中的例子來計算點陣圖記憶體:

96 * 96 * 4 byte = 36864 bytes = 36KB

Bitmap提供了兩個方法用於獲取系統為該Bitmap儲存畫素所分配的記憶體大小,分別為:

public int getByteCount ()

public int getAllocationByteCount () 一般情況下,兩個方法返回的值是相同的。但如果我們手動重新配置了Bitmap的屬性(寬、高、Bitmap.Config等),或者將BitmapFactory.Options.inBitmap屬性設為true以支援其他更小的Bitmap複用其記憶體時,那麼getAllocationByteCount ()返回的值就有可能會大於getByteCount()。

我們暫時不考慮以上兩種場景,所以直接選擇呼叫getByteCount方法 ()來獲取為Bitmap分配的位元組數,得到的結果是:82944 bytes = 81KB。

可以看到,getByteCount方法返回的值與我們的計算結果有差異,是我們的計算公式有問題嗎?

探究getByteCount()的計算公式

為了驗證我們的計算公式是否準確,我們需要深入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(); } 可以看到,getByteCount()方法的返回值是每一行的位元組數 * 高度,那麼每一行的位元組數又是怎麼計算的呢? public final int getRowBytes() { if (mRecycled) { Log.w(TAG, "Called getRowBytes() on a recycle()'d bitmap! This is undefined behavior!"); } return nativeRowBytes(mFinalizer.mNativeBitmap); } 正如你所見,getRowBytes()方法的實現是在Native層。先別灰心,接下來坐好扶穩了,我們省去一些不重要的步驟,乘坐飛船一路跨越Bitmap.cpp、SkBitmap.h,途徑SkBitmap.cpp時稍微停下:

size_t SkBitmap::ComputeRowBytes(Config c, int width) { return SkColorTypeMinRowBytes(SkBitmapConfigToColorType(c), width); } 並最終到達SkImageInfo.h: ``` 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針對不同畫素密度範圍進行了歸納分組,如下:

適用於不同畫素密度的配置限定符.png

我們通常選取中密度 (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查詢最佳匹配資源的規則

一般來說,Android會更傾向於縮小較大的原始影象,而非放大較小的原始影象。在此前提下:

  • 假設最接近裝置螢幕密度的目錄選項為xhdpi,如果圖片資源存在,則匹配成功;
  • 如果不存在,系統就會從更高密度的資源目錄下查詢,依次為xxhdpi、xxxhdpi;
  • 如果還不存在,系統就會從畫素密度無關的資源目錄nodpi下查詢;
  • 如果還不存在,系統就會向更低密度的資源目錄下查詢,依次為hdpi、mdpi、ldpi。

那麼,當匹配到其他密度目錄下的圖片資源後,對於原始影象的放大或縮小,Android是怎麼實現的呢?又會對載入點陣圖所需要的記憶體有什麼影響呢?

想解決這些疑惑,我們還是得從原始碼中找尋答案。

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); } 又見到熟悉的Native層方法了,讓我們重新開動星際飛船再次跨越到BitmapFactory.cpp下檢視:

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()));
}
...

} ``` 以上節選的doDecode方法的部分原始碼,就是Android系統如何對其他密度目錄下的原始影象進行縮放的具體實現,我們來梳理一下它的執行邏輯:

  1. 首先,設定scale值也即初始的縮放比為1。
  2. 取出關鍵的density值以及targetDensity值,以目標畫素密度/點陣圖畫素密度重新計算縮放比。
  3. 如果縮放比不再為1,則說明原始影象需要進行縮放。
  4. 取出待解碼的點陣圖的寬度,按int(scaledWidth * scale + 0.5f)計算縮放後的寬度,高度同理。
  5. 重新填充縮放後的寬高回Options。

基於以上內容,我們重新調整下我們的計算公式:

點陣圖記憶體 = (點陣圖寬度 * 縮放比) * 每個畫素的大小 * (點陣圖高度 * 縮放比) = (96 * 1.5) * 4 * (96 * 1.5) = 82944 bytes = 81KB

可以看到,這樣計算得出來的結果則與Bitmap#getByteCount()返回的值一致。

總結

彙總上述的所有內容後,我們可以得出結論,即:

Android系統為Bitmap儲存畫素所分配的記憶體大小,取決於以下幾個因素: - 色深,也即每個畫素的大小,對應的是Bitmap.Config的配置。 - 解析度,也即畫素的總數量,對應的是Bitmap的高度和寬度 - 畫素密度,對應的是圖片資源所在的密度目錄,以及裝置的螢幕畫素密度

由此我們還衍生出其他的結論,即:

  • 圖片資源放到正確的密度目錄很重要,否則可能對會較大尺寸的圖片進行不合理的縮放,從而加大不必要的記憶體佔用。
  • 如果是為了減少包體積而不想提供所有密度目錄下不同尺寸的圖片,應優先提供更高密度目錄下的圖片資源,可以避免圖片失真。
  • ...

參考

App resources overview

What is bit depth?

重識圖片