Bitmap 比你想的更費內存 | 吊打 OOM
一、前言
在一個 App 中,無可避免的會有一些 Bitmap 的資源,會被打包在 apk 中,隨著 apk 發布出去。而當你在使用這些 Bitmap 的資源的時候,它到底需要占用多少內存空間?這是一個很實際的問題,把握不好就可能引發各種 OOM 的錯誤。
本文就來探討一下,本地的 Bitmap 到底占用多少內存空間?
二、占用多少內存?
2.1 如何獲取占用的內存空間?
既然需要說道一個 Bitmap 資源,加載到內存中所要占用的空間,那就需要有一個明確的獲取方法,來確定的知道它到底占用了多少空間。而 Android 確實也為我們提供了類似的 API,那就是 Bitmap.getByteCount() 。
例如,現在項目內有一個 400 * 200 像素的圖片,方在 drawable-xhdpi 目錄下,在Nexus 6 設備上,運行加載它。看它輸出的尺寸。
看一下輸出的結果:
- I/cxmyDev: byteCound : 720000
可以看到,getByteCount() 是根據 getRowBytes() * getHeight() 計算出來的。getHeight() 方法它是 Bitmap 的高度,而 getRowBytes() 又是什么?
2.2 getRowBytes() 的計算依據
getRowBytes() 方法,最終調用的是一個 nativeRowBytes() 的方法,它是一個native 的方法。
既然要查就查到底,看看 native 的代碼是如何實現的(文內 native 的源碼,都是基于Android 5.1.1,文末會有在線查看地址,并且已經附帶行號,方便查閱)。
先看看 Bitmap.cpp 的代碼中 rowBytes() 是如何實現的。
這里閱讀的是 Android 5.1.1 的源碼,實際上從 Android 6 開始,會使用 LocalScopedBitmap 去操作,它其實也只是對 SkBitmap 做了一個封裝而已。如下圖所示,rowBytes() 是使用的 LocalScoopedBitmap 來操作的,有興趣的可以繼續看看它是如何實現的。
可以看到,最終使用的是 SkBitmap 去實現的。
在 SkBitmap.cpp 里就可以確認 ,色彩度為 ARGB_8888 圖片,每像素會占用 4 bytes 的大小。
看這個樣子,結合前面提到的 Bitmap.getByteCount() 的計算公式就是:
- bitmapInRam = bitmapWidth * 4 bytes * bitmapHeight
但是如果依據這樣的公式計算一個結果,你會發現獲得的值會比真實的值差了很多。
前面 Demo 中的圖片,加載到內存中,占用的內存是:720000 。但是用我們這里得到的計算方式,計算的結果是。
- 400 * 200 * 4 = 320000
那么,問題出在哪里?
2.3 density 影響 Bitmap 內存
2.1 中的 Demo ,明確指出了需要圖片存放的 Drawable 目錄,以及使用的設備,其實它們都是有關系的,不是無關系的路人甲。
關于圖片而言,放在不同的 Drawable 目錄下,對應的不同 density 的設備。density 是設備的固有參數,伴隨著 density 的,還有 densityDpi,它也是與設備相關的,表示屏幕每英寸對應多少個點(非像素點)。
它們之間的關系,可以直接查閱官方文檔,這里就不贅述了。
https://developer.android.com/guide/practices/screens_support.html
這里說到的 density ,其實就是代表不同的 drawable-xxx 目錄。
上面是官方提供的一張比較經典的圖,可以看到,不同的目錄,代表不同的 density ,例如 xhdpi 代表的 density 就是 2。而這里的 density 對 densityDip 的基準是 160 ,也就是說,mdpi 對應的 densityDpi 是 160 ,xhdpi 對應的 densityDpi 是 320。
它們的關系如下表:
density 和 densityDpi 在 Android 中,都有標準的 API 可以拿到,利用 DisplayMetrics即可。
看到 Nexus 5 輸出的結果:
- I/cxmyDev: density : 3.0
- I/cxmyDev: densityDpi : 480
了解了設備的 density 和 densityDpi ,在繼續看看加載 Bitmap 的過程,使用的是 BitmapFactory.decodeResource() 方法。
從源碼上可以看出,它實際上是分兩步完成的。
- 使用 openRawResource() 方法獲取圖片的原始流。
- 使用 decodeResourceStream() 方法,對數據流進行解碼和適配。
對于一個文件流而言,在這里我們是不需要關心的。主要影響圖片內存的是 decodeResourceStream() 方法中,對數據流進行解碼和適配的時候,都做了哪些處理。
在這個方法中,會傳遞一個 Options 的對象,用于配置當前圖片的解碼和適配。
從代碼中可以了解到,影響圖片內存占比的因素有 inDensity 和 inTargetDensity 兩個。
Options 中這兩個值,都是可以設置的,如果不對其進行額外的操作,它們默認情況下,分別表示的含義:
- inDensity :圖片存放的 Drawable 文件夾代表的 densityDpi 。
- inTargetDensity : 當前設備固有的 densityDpi 。
而使用他們的代碼,都是在 native 中,繼續追看 BitmapFactory.cpp 的源碼(源碼太多,只貼關鍵點)
可以看到,它實際上是會通過兩個 density 計算出一個比例值 scale ,它會去對圖片原始的像素進行 scale 表示的比例的縮放。
也就是說同一張圖片,放在不同 drawable 文件夾下的圖片,在不同的設備上,實際上加載出來的尺寸也是不同的。
那計算圖片內存的公式,就應該調整為:
- scale = targetDensity / inDensity
- bitmapInRam = (bitmapWidth*scale) * (bitmapHeight*scale) * 4 bytes
再來使用新的公式,計算一下上面圖片的尺寸:
- 400 * (480/320) * 200 *(480/320) * 4 = 720000
可以看到,最終得出的和我們程序中計算的值一致 了,所以這就是我們最終得到的計算圖片在內存中,占比的公式了。
再改寫上面的 Demo ,把細節點都輸出出來。
看看我們關心的 Log 輸出:
- I/cxmyDev: byteCound : 720000
- I/cxmyDev: rowBytes : 2400
- I/cxmyDev: height : 300
- I/cxmyDev: width : 600
- I/cxmyDev: density : 3.0
- I/cxmyDev: densityDpi : 480
3.4 查缺補漏
前面舉的例子中,圖片尺寸和設備的 densityDpi 都是很規整的。但是不排除有一些比較不標準的設備,加載的圖片使用上面的計算公式,依然對不上。
這個問題,還是需要在源碼中找答案,對于不那么標準的 densityDpi 的設備而言,根據這個scale 計算出來的尺寸,可能是一個 float 值,也就是存在小數的情況,而圖片的尺寸,都是以 int 類型為單位。所以 Android 為了規避這樣的問題,做了個容差值(0.5),去轉換成 int 類型。
代碼依然在 BitmapFactory..cpp 中。
所以 getByteCount() 這個 Api 得到的尺寸,可能和我們前面使用公式計算的尺寸,略微有些偏差,這個值就是在小數點之間。
4、小結
好了,到這里就講清楚了一個本地的 Bitmap ,加載到內存中,到底會占用多少內存。
決定 Bitmap 占用內存大小的因素,和圖片文件在磁盤上占用的空間一點關系都沒有,總結來說,有以下幾點:
- 色彩格式:比如 ARGB_8888 、RGB_5555 這種,單位像素占的內存空間不同。
- 圖片本身的像素尺寸。
- 圖片文件存放的 Drawable 目錄。xhdpi 和 xxhdpi 可是不一樣的。
- 目標設備的 densityDpi 值。
最后附上Android 5.1.1 的相關源碼,供大家參考
Bitmap.cpp :
http://androidxref.com/5.1.1_r6/xref/frameworks/base/core/jni/android/graphics/Bitmap.cpp
SkBitmap.cpp:
http://androidxref.com/5.1.1_r6/xref/external/skia/src/core/SkBitmap.cpp
BitmapFactory.cpp:
http://androidxref.com/5.1.1_r6/xref/frameworks/base/core/jni/android/graphics/BitmapFactory.cpp
【本文為51CTO專欄作者“張旸”的原創稿件,轉載請通過微信公眾號聯系作者獲取授權】