1. 程式人生 > >Android bitmap佔用記憶體解析

Android bitmap佔用記憶體解析

0、寫在前面

本文涉及到螢幕密度的討論,這裡先要搞清楚 DisplayMetrics 的兩個變數,摘錄官方文件的解釋:
  • density:The logical density of the display. This is a scaling factor for the Density Independent Pixel unit, where one DIP is one pixel on an approximately 160 dpi screen (for example a 240x320, 1.5”x2” screen), providing the baseline of the system’s display. Thus on a 160dpi screen this density value will be 1; on a 120 dpi screen it would be .75; etc. This value does not exactly follow the real screen size (as given by xdpi and ydpi, but rather is used to scale the size of the overall UI in steps based on gross changes in the display dpi. For example, a 240x320 screen will have a density of 1 even if its width is 1.8”, 1.3”, etc. However, if the screen resolution is increased to 320x480 but the screen size remained 1.5”x2” then the density would be increased (probably to 1.5).
  • densityDpi:The screen density expressed as dots-per-inch.
簡單來說,可以理解為 density 的數值是 1dp=density px;densityDpi 是螢幕每英寸對應多少個點(不是畫素點),在 DisplayMetrics 當中,這兩個的關係是線性的:
density 1 1.5 2 3 3.5 4
densityDpi 160 240 320 480 560 640
為了不引起混淆,本文所有提到的密度除非特別說明,都指的是 densityDpi,當然如果你願意,也可以用 density 來說明問題。 另外,本文的依據主要來自 android 5.0 的原始碼,其他版本可能略有出入。文章難免疏漏,歡迎指正~

1、佔了多大記憶體?

做移動客戶端開發的朋友們肯定都因為圖頭疼過,說起來曾經還有過 leader 因為組裡面一哥們在工程裡面加了一張 jpg 的圖發脾氣的事兒,哈哈。 為什麼頭疼呢?吃記憶體唄,時不時還給你來個 OOM 沖沖喜,讓你的每一天過得有滋有味(真是沒救了)。那每次工程裡面增加一張圖片的時候,我們都需要關心這貨究竟要佔多大的坑,佔多大呢?Android API 有個方便的方法, [Java] 純文字檢視 複製程式碼 ?
1 2 3 4 public final int getByteCount() { // int result permits bitmaps up to 46,340 x 46,340
returngetRowBytes() * getHeight(); }
通過這個方法,我們就可以獲取到一張 Bitmap 在執行時到底佔用多大記憶體了。
舉個例子 一張 522x686PNG 圖片,我把它放到 drawable-xxhdpi 目錄下,在三星s6上載入,佔用記憶體2547360B,就可以用這個方法獲取到。

2、給我一張圖我告訴你佔多大記憶體

每次都問 Bitmap 你到底多大啦。。感覺怪怪的,畢竟我們不能總是去問,而不去搞清楚它為嘛介麼大吧。能不能給它算個命,算算它究竟多大呢?當然是可以的,很簡單嘛,我們直接順藤摸瓜,找出真凶,哦不,找出答案。

2.1 getByteCount

getByteCount 的原始碼我們剛剛已經認識了,當我們問 Bitmap 大小的時候,這孩子也是先拿到出生年月日,然後算出來的,那麼問題來了,getHeight 就是圖片的高度(單位:px),getRowBytes 是什麼? [Java] 純文字檢視 複製程式碼 ?
1 2 3 4 5 6 public final int getrowBytes() { if(mRecycled) { Log.w(TAG,"Called getRowBytes() on a recycle()'d bitmap! This is undefined behavior!"); } returnnativeRowBytes(mFinalizer.mNativeBitmap); }
額,感覺太對了啊,要 JNI 了。由於在下 C++ 實在用得少,每次想起 JNI 都請想象腦門磕牆的場景,不過呢,毛爺爺說過,一切反動派都是紙老虎~與
nativeRowBytes 對應的函式如下: Bitmap.cpp [Java] 純文字檢視 複製程式碼 ?
1 2 3 4 static jint Bitmap_rowBytes(JNIEnv* env, jobject, jlong bitmapHandle) { SkBitmap* bitmap = reinterpret_cast<SkBitmap*>(bitmapHandle) returnstatic_cast<jint>(bitmap->rowBytes()); }
等等,我們好像發現了什麼,原來 Bitmap 本質上就是一個 SkBitmap。。而這個 SkBitmap 也是大有來頭,不信你瞧:Skia。啥也別說了,趕緊瞅瞅 SkBitmap。 SkBitmap.h [Java] 純文字檢視 複製程式碼 ?
1 2 /** Return the number of bytes between subsequent rows of the bitmap. */ size_t rowBytes() const { returnfRowBytes; }
SkBitmap.cpp [Java] 純文字檢視 複製程式碼 ?
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 size_t SkBitmap::ComputeRowBytes(Config c,int width) { returnSkColorTypeMinRowBytes(SkBitmapConfigToColorType(c), width); } SkImageInfo.h static int SkColorTypeBytesPerPixel(SkColorType ct) { staticconst 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)); returngSize[ct]; } static inline size_t SkColorTypeMinRowBytes(SkColorType ct, intwidth) { returnwidth * SkColorTypeBytesPerPixel(ct); }
好,跟蹤到這裡,我們發現 ARGB_8888(也就是我們最常用的 Bitmap 的格式)的一個畫素佔用 4byte,那麼 rowBytes 實際上就是 4*width bytes。 那麼結論出來了,一張 ARGB_8888 的 Bitmap 佔用記憶體的計算公式 bitmapInRam = bitmapWidth*bitmapHeight *4 bytes 說到這兒你以為故事就結束了麼?有本事你拿去試,算出來的和你獲取到的總是會差個倍數,為啥呢? 還記得我們最開始給出的那個例子麼? 一張522*686PNG 圖片,我把它放到 drawable-xxhdpi 目錄下,在三星s6上載入,佔用記憶體2547360B,就可以用這個方法獲取到。 然而公式計算出來的可是1432368B。。。

2.2 Density

知道我為什麼在舉例的時候那麼費勁的說放到xxx目錄下,還要說用xxx手機麼?你以為 Bitmap 載入只跟寬高有關麼?Naive。 還是先看程式碼,我們讀取的是 drawable 目錄下面的圖片,用的是 decodeResource 方法,該方法本質上就兩步:
  • 讀取原始資源,這個呼叫了 Resource.openRawResource 方法,這個方法呼叫完成之後會對 TypedValue 進行賦值,其中包含了原始資源的 density 等資訊;
  • 呼叫 decodeResourceStream 對原始資源進行解碼和適配。這個過程實際上就是原始資源的 density 到螢幕 density 的一個對映。
原始資源的 density 其實取決於資源存放的目錄(比如 xxhdpi 對應的是480),而螢幕 density 的賦值,請看下面這段程式碼:
BitmapFactory.java [Java] 純文字檢視 複製程式碼 ?
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 public static Bitmap decodeResourceStream(Resources res, TypedValue value, InputStream is, Rect pad, Options opts) { //實際上,我們這裡的opts是null的,所以在這裡初始化。 if (opts == null) { opts =new Options(); } if (opts.inDensity == 0 && value != null) { finalint density = value.density; if(density == TypedValue.DENSITY_DEFAULT) { opts.inDensity = DisplayMetrics.DENSITY_DEFAULT; }else if(density != TypedValue.DENSITY_NONE) { opts.inDensity = density;//這裡density的值如果對應資源目錄為hdpi的話,就是240 } } if (opts.inTargetDensity == 0 && res != null) { //請注意,inTargetDensity就是當前的顯示密度,比如三星s6時就是640 opts.inTargetDensity = res.getDisplayMetrics().densityDpi; } return decodeStream(is, pad, opts); }
我們看到 opts 這個值被初始化,而它的構造居然如此簡單: [Java] 純文字檢視 複製程式碼 ?
1 2 3 4 5 public Options() { inDither =false; inScaled =true; inPremultiplied =true; }

所以我們就很容易的看到,Option.inScreenDensity 這個值沒有被初始化,而實際上後面我們也會看到這個值根本不會用到;我們最應該關心的是什麼呢?是 inDensity 和 inTargetDensity,這兩個值與下面 cpp 檔案裡面的 density 和 targetDensity 相對應——重複一下,inDensity 就是原始資源的 density,inTargetDensity 就是螢幕的 density。 緊接著,用到了 nativeDecodeStream 方法,不重要的程式碼直接略過,直接給出最關鍵的 doDecode 函式的程式碼:
BitmapFactory.cpp [Java] 純文字檢視 複製程式碼 ?
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 static jobject doDecode(JNIEnv* env, SkStreamRewindable* stream, jobject padding, jobject options) { ...... if(env->GetBooleanField(options, gOptions_scaledFieldID)) { constint density = env->GetIntField(options, gOptions_densityFieldID);//對應hdpi的時候,是240 constint targetDensity = env->GetIntField(options, gOptions_targetDensityFieldID);//三星s6的為640 constint screenDensity = env->GetIntField(options, gOptions_screenDensityFieldID); if(density != 0 && targetDensity != 0 && density != screenDensity) { scale = (float) targetDensity / density; } } } const bool willScale = scale != 1.0f; ...... SkBitmap decodingBitmap; if (!decoder->decode(stream, &decodingBitmap, prefColorType,decodeMode)) { returnnullObjectReturn("decoder->decode returned false"); } //這裡這個deodingBitmap就是解碼出來的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); } if (willScale) { constfloat sx = scaledWidth /float(decodingBitmap.width()); constfloat sy = scaledHeight /float(decodingBitmap.height()); // TODO: avoid copying when scaled size equals decodingBitmap size SkColorType colorType = colorTypeForScaledOutput(decodingBitmap.colorType()); // FIXME: If the alphaType is kUnpremul and the image has alpha, the // colors may not be correct, since Skia does not yet support drawing // to/from unpremultiplied bitmaps. outputBitmap->setInfo(SkImageInfo::Make(scaledWidth, scaledHeight, colorType, decodingBitmap.alphaType())); if(!outputBitmap->allocPixels(outputAllocator, NULL)) { returnnullObjectReturn("allocation failed for scaled bitmap"); } // If outputBitmap's pixels are newly allocated by Java, there is no need // to erase to 0, since the pixels were initialized to 0. if(outputAllocator != &javaAllocator) { outputBitmap->eraseColor(0); } SkPaint paint; paint.setFilterLevel(SkPaint::kLow_FilterLevel); SkCanvas canvas(*outputBitmap); canvas.scale(sx, sy); canvas.drawBitmap(decodingBitmap,0.0f, 0.0f, &paint); } ...... }
注意到其中有個 density 和 targetDensity,前者是 decodingBitmap 的 density,這個值跟這張圖片的放置的目錄有關(比如 hdpi 是240,xxhdpi 是480),這部分程式碼我跟了一下,太長了,就不列出來了;targetDensity 實際上是我們載入圖片的目標 density,這個值的來源我們已經在前面給出了,就是 DisplayMetrics 的 densityDpi,如果是三星s6那麼這個數值就是640。sx 和sy 實際上是約等於 scale 的,因為 scaledWidth 和 scaledHeight 是由 width 和 height 乘以 scale 得到的。我們看到 Canvas 放大了 scale 倍,然後又把讀到記憶體的這張 bitmap 畫上去,相當於把這張 bitmap 放大了 scale 倍。
再來看我們的例子: 一張522*686PNG 圖片,我把它放到 drawable-xxhdpi 目錄下,在三星s6上載入,佔用記憶體2547360B,其中 density 對應 xxhdpi 為480,targetDensity 對應三星s6的密度為640: 522/480 * 640 * 686/480 *640 * 4 = 2546432B

2.3 精度

越來越有趣了是不是,你肯定會發現我們這麼細緻的計算還是跟獲取到的數值 不!一!樣! 為什麼呢?由於結果已經非常接近,我們很自然地想到精度問題。來,再把上面這段程式碼中的一句拿出來看看: [Java] 純文字檢視 複製程式碼 ?
1 2 outputBitmap->setInfo(SkImageInfo::Make(scaledWidth, scaledHeight, colorType, decodingBitmap.alphaType()));
我們看到最終輸出的 outputBitmap 的大小是scaledWidth*scaledHeight,我們把這兩個變數計算的片段拿出來給大家一看就明白了: [Java] 純文字檢視 複製程式碼 ?
1 2 3 4 if (willScale && decodeMode != SkImageDecoder::kDecodeBounds_Mode) { scaledWidth =int(scaledWidth * scale +0.5f); scaledHeight =int(scaledHeight * scale +0.5f); }
在我們的例子中, scaledWidth = int( 522 * 640 / 480f + 0.5) = int(696.5) = 696 scaledHeight = int( 686 * 640 / 480f + 0.5) = int(915.16666…) = 915 下面就是見證奇蹟的時刻: 915 * 696 * 4 = 2547360 有木有很興奮!有木有很激動!! 寫到這裡,突然想起《STL原始碼剖析》一書的扉頁,侯捷先生只寫了一句話:
“原始碼之前,了無祕密”。

2.4 小結

其實,通過前面的程式碼跟蹤,我們就不難知道,Bitmap 在記憶體當中佔用的大小其實取決於:
  • 色彩格式,前面我們已經提到,如果是 ARGB8888 那麼就是一個畫素4個位元組,如果是 RGB565 那就是2個位元組
  • 原始檔案存放的資源目錄(是 hdpi 還是 xxhdpi 可不能傻傻分不清楚哈)
  • 目標螢幕的密度(所以同等條件下,紅米在資源方面消耗的記憶體肯定是要小於三星S6的)

3、想辦法減少 Bitmap 記憶體佔用

3.1 Jpg 和 Png

說到這裡,肯定會有人會說,我們用 jpg 吧,jpg 格式的圖片不應該比 png 小麼? 這確實是個好問題,因為同樣一張圖片,jpg 確實比 png 會多少小一些(甚至很多),原因很簡單,jpg 是一種有損壓縮的圖片儲存格式,而 png 則是無損壓縮的圖片儲存格式,顯而易見,jpg 會比 png 小,代價也是顯而易見的。
可是,這說的是檔案儲存範疇的事情,它們只存在於檔案系統,而非記憶體或者視訊記憶體。說得簡單一點兒,我有一個極品飛車的免安裝硬碟版的壓縮包放在我的磁盤裡面,這個遊戲是不能玩的,我需要先解壓,才能玩——jpg 也好,png 也好就是個壓縮包的概念,而我們討論的記憶體佔用則是從使用角度來討論的。 所以,jpg 格式的圖片與 png 格式的圖片在記憶體當中不應該有什麼不同。
『啪!!!』 『誰這麼缺德!!打人不打臉好麼!』
肯定有人有意見,jpg 圖片讀到記憶體就是會小,還會給我拿出例子。當然,他說的不一定是錯的。因為 jpg 的圖片沒有 alpha 通道!!所以讀到記憶體的時候如果用 RGB565的格式存到記憶體,這下大小隻有 ARGB8888的一半,能不小麼。。。 不過,拋開 Android 這個平臺不談,從出圖的角度來看的話,jpg 格式的圖片大小也不一定比 png 的小,這要取決於影象資訊的內容:
JPG 不適用於所含顏色很少、具有大塊顏色相近的區域或亮度差異十分明顯的較簡單的圖片。對於需要高保真的較複雜的影象,PNG 雖然能無失真壓縮,但圖片檔案較大。
如果僅僅是為了 Bitmap 讀到記憶體中的大小而考慮的話,jpg 也好 png 也好,沒有什麼實質的差別;二者的差別主要體現在:
  • alpha 你是否真的需要?如果需要 alpha 通道,那麼沒有別的選擇,用 png。
  • 你的圖色值豐富還是單調?就像剛才提到的,如果色值豐富,那麼用jpg,如果作為按鈕的背景,請用 png。
  • 對安裝包大小的要求是否非常嚴格?如果你的 app 資源很少,安裝包大小問題不是很凸顯,看情況選擇 jpg 或者 png(不過,我想現在對資原始檔沒有苛求的應用會很少吧。。)
  • 目標使用者的 cpu 是否強勁?jpg 的影象壓縮演算法比 png 耗時。這方面還是要酌情選擇,前幾年做了一段時間 Cocos2dx,由於資源非常多,專案組要求統一使用 png,可能就是出於這方面的考慮。
嗯,跑題了,我們其實想說的是怎麼減少記憶體佔用的。。這一小節只是想說,休想通過這個方法來減少記憶體佔用。。。XD

3.2 使用 inSampleSize

有些朋友一看到這個肯定就笑了。取樣嘛,我以前是學訊號處理的,一看到 Sample 就抽抽。。哈哈開個玩笑,這個取樣其實就跟統計學裡面的取樣是一樣的,在保證最終效果滿足要求的前提下減少樣本規模,方便後續的資料採集和處理。 這個方法主要用在圖片資源本身較大,或者適當地取樣並不會影響視覺效果的條件下,這時候我們輸出地目標可能相對較小,對圖片解析度、大小要求不是非常的嚴格。
舉個例子 我們現在有個需求,要求將一張圖片進行模糊,然後作為 ImageView 的 src 呈現給使用者,而我們的原始圖片大小為 1080*1920,如果我們直接拿來模糊的話,一方面模糊的過程費時費力,另一方面生成的圖片又佔用記憶體,實際上在模糊運算過程中可能會存在輸入和輸出並存的情況,此時記憶體將會有一個短暫的峰值。 這時候你一定會想到三個字母在你的腦海裡揮之不去,它們就是『OOM』。 既然圖片最終是要被模糊的,也看不太情況,還不如直接用一張取樣後的圖片,如果取樣率為 2,那麼讀出來的圖片只有原始圖片的 1/4 大小,真是何樂而不為呢?? [Java] 純文字檢視 複製程式碼 ?
1 2 3 BitmapFactory.Options options =new Options(); options.inSampleSize =2; Bitmap bitmap = BitmapFactory.decodeResource(getResources(), resId, options);

3.3 使用矩陣

用到 Bitmap 的地方,總會見到 Matrix。這時候你會想到什麼?
『基友』 『是在下輸了。。』
其實想想,Bitmap 的畫素點陣,還不就是個矩陣,真是你中有我,我中有你的交情啊。那麼什麼時候用矩陣呢?
大圖小用用取樣,小圖大用用矩陣。 還是用前面模糊圖片的例子,我們不是取樣了麼?記憶體是小了,可是圖的尺寸也小了啊,我要用 Canvas 繪製這張圖可怎麼辦?當然是用矩陣了: 方式一: [Java] 純文字檢視 複製程式碼 ?
1 2 3 4 5 Matrix matrix = new Matrix(); matrix.preScale(2,2, 0f, 0f); //如果使用直接替換矩陣的話,在Nexus6 5.1.1上必須關閉硬體加速 canvas.concat(matrix); canvas.drawBitmap(bitmap,0,0, paint);
需要注意的是,在使用搭載 5.1.1 原生系統的 Nexus6 進行測試時發現,如果使用 Canvas 的 setMatrix 方法,可能會導致與矩陣相關的元素的繪製存在問題,本例當中如果使用 setMatrix 方法,bitmap 將不會出現在螢幕上。因此請儘量使用 canvas 的 scale、rotate 這樣的方法,或者使用 concat 方法。
方式二: [Java] 純文字檢視 複製程式碼 ?
1 2 3 Matrix matrix = new Matrix(); matrix.preScale(2,2, 0,0); canvas.drawBitmap(bitmap, matrix, paint);
這樣,繪製出來的圖就是放大以後的效果了,不過佔用的記憶體卻仍然是我們取樣出來的大小。 如果我要把圖片放到 ImageView 當中呢?一樣可以,請看: [Java] 純文字檢視 複製程式碼 ?
1 2 3 4 5 Matrix matrix = new Matrix(); matrix.postScale(2,2, 0,0); imageView.setImageMatrix(matrix); imageView.setScaleType(ScaleType.MATRIX); imageView.setImageBitmap(bitmap);


3.4 合理選擇Bitmap的畫素格式

其實前面我們已經多次提到這個問題。ARGB8888格式的圖片,每畫素佔用 4 Byte,而 RGB565則是 2 Byte。我們先看下有多少種格式可選:
格式 描述
ALPHA_8 只有一個alpha通道
ARGB_4444 這個從API 13開始不建議使用,因為質量太差
ARGB_8888 ARGB四個通道,每個通道8bit
RGB_565 每個畫素佔2Byte,其中紅色佔5bit,綠色佔6bit,藍色佔5bit

這幾個當中, ALPHA8 沒必要用,因為我們隨便用個顏色就可以搞定的。 ARGB4444 雖然佔用記憶體只有 ARGB8888 的一半,不過已經被官方嫌棄,失寵了。。『又要佔省記憶體,又要看著爽,臣妾做不到啊T T』。 ARGB8888 是最常用的,大家應該最熟悉了。 RGB565 看到這個,我就看到了資源優化配置無處不在,這個綠色。。(不行了,突然好邪惡XD),其實如果不需要 alpha 通道,特別是資源本身為 jpg 格式的情況下,用這個格式比較理想。

3.5 高能:索引點陣圖(Indexed Bitmap)

索引點陣圖,每個畫素只佔 1 Byte,不僅支援 RGB,還支援 alpha,而且看上去效果還不錯!等等,請收起你的口水,Android 官方並不支援這個。是的,你沒看錯,官方並不支援。 [Java] 純文字檢視 複製程式碼 ?
01 02 03 04 05 06 07 08 09 10 public enum Config { // these native values must match up with the enum in SkBitmap.h ALPHA_8     (2), RGB_565     (4), ARGB_4444   (5), ARGB_8888   (6); finalint nativeInt; }
不過,Skia 引擎是支援的,不信你再看: [Java] 純文字檢視 複製程式碼 ?
01 02 03 04 05 06 07 08 09 10 11 12 13 enum Config { kNo_Config,  //!< bitmap has not been configured kA8_Config,  //!< 8-bits per pixel, with only alpha specified (0 is transparent, 0xFF is opaque) //看這裡看這裡!!↓↓↓↓↓ kIndex8_Config,//!< 8-bits per pixel, using SkColorTable to specify the colors  kRGB_565_Config,//!< 16-bits per pixel, (see SkColorPriv.h for packing) kARGB_4444_Config,//!< 16-bits per pixel, (see SkColorPriv.h for packing) kARGB_8888_Config,//!< 32-bits per pixel, (see SkColorPriv.h for packing) kRLE_Index8_Config, kConfigCount };
其實 Java 層的列舉變數的 nativeInt 對應的就是 Skia 庫當中列舉的索引值,所以,如果我們能夠拿到這個索引是不是就可以了?對不起,拿不到。 不行了,廢話這麼多,肯定要挨板磚了T T。 不過呢,在 png 的解碼庫裡面有這麼一段程式碼: [Java] 純文字檢視 複製程式碼 ?
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38