Android中Bitmap內存優化

Android開發中,Bitmap是經常會遇到的對象,特別是在列表圖片展示、大圖顯示等界面。而Bitmap實實在在是內存使用的“大客戶”。如何更好的使用Bitmap,減少其對App內存的使用,是Android優化方面不可迴避的問題。因此,本文從常規的Bitmap使用,到Bitmap內存計算進行了介紹,最後分析了Bitmap的源碼和其內存模型在不同版本上的變化。

Bitmap的使用

一般來說,一個對象的使用,我們會嘗試利用其構造函數去生成這個對象。在Bitmap中,其構造函數:

// called from JNI
    Bitmap(long nativeBitmap, byte[] buffer, int width, int height, int density,
            boolean isMutable, boolean requestPremultiplied,
            byte[] ninePatchChunk, NinePatch.InsetStruct ninePatchInsets) 

通過構造函數的註釋,得知這是一個給native層調用的方法,因此可以知道Bitmap的創建將會涉及到底層庫的支持。爲了方便從不同來源來創建Bitmap,Android中提供了BitmapFactory工具類。BitmapFactory類中有一系列的decodeXXX方法,用於解析資源文件、本地文件、流等方式,基本流程都很類似,讀取目標文件,轉換成輸入流,調用native方法解析流,雖然Java層代碼沒有體現,但是我們可以猜想到,最後native方法解析完成後,必然會通過JNI調用Bitmap的構造函數,完成Java層的Bitmap對象創建。

// BitmapFactory部分代碼:
public static Bitmap decodeResource(Resources res, int id)
public static Bitmap decodeStream(InputStream is)
private static native Bitmap nativeDecodeStream

native層的代碼稍後我們在看,先從Java層來看看常規的使用。典型的一個例子是,當我們需要從本地Resource中加載一個圖片,並展示出來,我們可以通過BitmapFacotry來完成:

Bitmap bitmapDecode = BitmapFactory.decodeResource(getResources(), resId);
imageView.setImageBitmap(bitmapDecode);

當然,這裏簡單的使用imageView.setImageResource(int resId)也能實現一樣的效果,實際上setImageResource方法只是封裝了bitmap的讀入、解析的過程,並且這個過程是在UI線程完成的,對於性能是有所影響的。另外,也對接下來討論的內容,Bitmap佔用的內存有影響。

Bitmap到底佔用多大的內存

Bitmap作爲位圖,需要讀入一張圖片每一個像素點的數據,其主要佔用內存的地方也正是這些像素數據。對於像素數據總大小,我們可以猜想爲:像素總數量 × 每個像素的字節大小,而像素總數量在矩形屏幕表現下,應該是:橫向像素數量 × 縱向像素數量,結合得到:

Bitmap內存佔用 ≈ 像素數據總大小 = 橫向像素數量 × 縱向像素數量 × 每個像素的字節大小

單個像素的字節大小

單個像素的字節大小由Bitmap的一個可配置的參數Config來決定。
Bitmap中,存在一個枚舉類Config,定義了Android中支持的Bitmap配置:

Config 佔用字節大小(byte) 說明
ALPHA_8 (1) 1 單透明通道
RGB_565 (3) 2 簡易RGB色調
ARGB_4444 (4) 4 已廢棄
ARGB_8888 (5) 4 24位真彩色
RGBA_F16 (6) 8 Android 8.0 新增(更豐富的色彩表現HDR)
HARDWARE (7) Special Android 8.0 新增 (Bitmap直接存儲在graphic memory)注1

**注1:**關於Android 8.0中新增的這個配置,stackoverflow已經有相關問題,可以關注下。

之前我們分析到,Bitmap的decode實際上是在native層完成的,因此在native層也存在對應的Config枚舉類。
一般使用時,我們並未關注這個配置,在BitmapFactory中,有:

  * Image are loaded with the {@link Bitmap.Config#ARGB_8888} config by default.
  */
  public Bitmap.Config inPreferredConfig = Bitmap.Config.ARGB_8888;

因此,Android系統中,默認Bitmap加載圖片,使用24位真彩色模式。

Bitmap佔用內存大小實例

首先準備了一張800×600分辨率的jpg圖片,大小約135k,放置於res/drawable文件夾下:

image

並將其加載到一個200dp×300dp大小的ImageView中,使用BitmapFactory。

Bitmap bitmapDecode = BitmapFactory.decodeResource(getResources(), resId);
imageView.setImageBitmap(bitmapDecode);

打印出相關信息:

圖中顯示了從資源文件中decode得到的bitmap的長、寬和佔用內存大小(byte)等信息。
首先,從數據上可以驗證:

17280000 = 2400 * 1800 * 4

這意味着,爲了將單張800 * 600 的圖片加載到內存當中,付出了近17.28M的代價,即使現在手機運存普遍上漲,這樣的開銷也是無法接受的,因此,對於Bitmap的使用,是需要非常小心的。好在,目前主流的圖像加載庫(Glide、Fresco等)基本上都不在需要開發者去關心Bitmap內存佔用問題。
先暫時回到Bitmap佔用內存的計算上來,對比之前定義的公式和源圖片的尺寸數據,我們會發現,這張800 * 600大小的圖片,decode到內存中的Bitmap的橫縱像素數量實際是:2400 * 1800,相當於縮放了3倍大小。爲了探究這縮放來自何處,我們開始跟蹤源碼:之前提到過,Bitmap的decode過程實際上是在native層完成的,爲此,需要從BitmapFactory.cpp#nativeDecodeXXX方法開始跟蹤,這裏省略其他decode代碼,直接貼出和縮放相關的代碼如下:

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;
    }
}
...
int scaledWidth = decoded->width();
int scaledHeight = decoded->height();

if (willScale && mode != SkImageDecoder::kDecodeBounds_Mode) {
    scaledWidth = int(scaledWidth * scale + 0.5f);
    scaledHeight = int(scaledHeight * scale + 0.5f);
}
...
if (willScale) {
    const float sx = scaledWidth / float(decoded->width());
    const float sy = scaledHeight / float(decoded->height());
    bitmap->setConfig(decoded->getConfig(), scaledWidth, scaledHeight);
    bitmap->allocPixels(&javaAllocator, NULL);
    bitmap->eraseColor(0);
    SkPaint paint;
    paint.setFilterBitmap(true);
    SkCanvas canvas(*bitmap);
    canvas.scale(sx, sy);
    canvas.drawBitmap(*decoded, 0.0f, 0.0f, &paint);
}

從上述代碼中,我們看到bitmap最終通過canvas繪製出來,而canvas在繪製之前,有一個scale的操作,scale的值由

scale = (float) targetDensity / density;

這一行代碼決定,即縮放的倍率和targetDensity和density相關,而這兩個參數都是從傳入的options中獲取到的。這時候,需要回到Java層,看看options這個對象的定義和賦值。

BitmapFactory#Options

Options是BitmapFactory中的一個靜態內部類,用於配置Bitmap在decode時的一些參數。

// native層doDecode方法,傳入了Options參數
static jobject doDecode(JNIEnv* env, SkStreamRewindable* stream, jobject padding, jobject options)

其內部有很多可配置的參數,下面的類圖,列舉出了部分常用的參數。

image

我們先關注之前提到的幾個密度相關的參數,通過閱讀源碼的註釋,大概可以知道這三個密度參數代表的涵義:

  • inDensity:Bitmap位圖自身的密度、分辨率
  • inTargetDensity: Bitmap最終繪製的目標位置的分辨率
  • inScreenDensity: 設備屏幕分辨率

其中inDensity和圖片存放的資源文件的目錄有關,同一張圖片放置在不同目錄下會有不同的值:

density 0.75 1 1.5 2 3 3.5 4
densityDpi 120 160 240 320 480 560 640
DpiFolder ldpi mdpi hdpi xhdpi xxhdpi xxxhdpi xxxxhdpi

inTargetDensity和inScreenDensity一般來說,很少手動去賦值,默認情況下,是和設備分辨率保持一致。爲此,我在手機(紅米4,Android 6.0系統,設備dpi 480)上測試加載不同資源文件下的bitmap的參數,結果見下圖:

image

以上可以驗證幾個結論:

  • 同一張圖片,放在不同資源目錄下,其分辨率會有變化,
  • bitmap分辨率越高,其解析後的寬高越小,甚至會小於圖片原有的尺寸(即縮放),從而內存佔用也相應減少
  • 圖片不特別放置任何資源目錄時,其默認使用mdpi分辨率:160
  • 資源目錄分辨率和設備分辨率一致時,圖片尺寸不會縮放

因此,關於Bitmap佔用內存大小的公式,從之前:

Bitmap內存佔用 ≈ 像素數據總大小 = 橫向像素數量 × 縱向像素數量 × 每個像素的字節大小

可以更細化爲:

Bitmap內存佔用 ≈ 像素數據總大小 = 圖片寬 × 圖片高× (設備分辨率/資源目錄分辨率)^2 × 每個像素的字節大小

對於本節中最開始的例子,如下:

17,280,000 = 800 * 600 * (480 / 160 )^2 * 4

Bitmap內存優化

圖片佔用的內存一般會分爲運行時佔用的運存和存儲時本地開銷(反映在包大小上),這裏我們只關注運行時佔用內存的優化。
在上一節中,我們看到對於一張800 * 600 大小的圖片,不加任何處理直接解析到內存中,將近佔用了17.28M的內存大小。想象一下這樣的開銷發生在一個圖片列表中,內存佔用將達到非常誇張的地步。從之前Bitmap佔用內存的計算公式來看,減少內存主要可以通過以下幾種方式:

  1. 使用低色彩的解析模式,如RGB565,減少單個像素的字節大小
  2. 資源文件合理放置,高分辨率圖片可以放到高分辨率目錄下
  3. 圖片縮小,減少尺寸

第一種方式,大約能減少一半的內存開銷。Android默認是使用ARGB8888配置來處理色彩,佔用4字節,改用RGB565,將只佔用2字節,代價是顯示的色彩將相對少,適用於對色彩豐富程度要求不高的場景。
第二種方式,和圖片的具體分辨率有關,建議開發中,高分辨率的圖像應該放置到合理的資源目錄下,注意到Android默認放置的資源目錄是對應於160dpi,目前手機屏幕分辨率越來越高,此處能節省下來的開銷也是很可觀的。理論上,圖片放置的資源目錄分辨率越高,其佔用內存會越小,但是低分辨率圖片會因此被拉伸,顯示上出現失真。另一方面,高分辨率圖片也意味着其佔用的本地儲存也變大。
第三種方式,理論上根據適用的環境,是可以減少十幾倍的內存使用的,它基於這樣一個事實:源圖片尺寸一般都大於目標需要顯示的尺寸,因此可以通過縮放的方式,來減少顯示時的圖片寬高,從而大大減少佔用的內存。

前兩種方式,相對比較簡單。第三種方式會涉及到一些編碼,目前也有很多典型的使用方式,如下:

BitmapFactory.Options options = new BitmapFactory.Options();
options.inPreferredConfig = Bitmap.Config.RGB_565;
options.inJustDecodeBounds = true;
BitmapFactory.decodeResource(getResources(), resId,options);
options.inJustDecodeBounds = false;
options.inSampleSize = BitmapUtil.computeSampleSize(options, -1, imageView.getWidth() * imageView.getHeight());
Bitmap newBitmap = BitmapFactory.decodeResource(getResources(), resId, options);

原理很簡單,充分利用了Options類裏的參數設置,也可以從native底層源碼上看到對應的邏輯。第一次解析bitmap只獲取尺寸信息,不生成像素數據,繼而比較bitmap尺寸和目標尺寸得到縮放倍數,第二次根據縮放倍數去解析我們實際需要的尺寸大小。

// Apply a fine scaling step if necessary.
    if (needsFineScale(codec->getInfo().dimensions(), size, sampleSize)) {
        willScale = true;
        scaledWidth = codec->getInfo().width() / sampleSize;
        scaledHeight = codec->getInfo().height() / sampleSize;
    }

上圖是使用上述手段優化後的結果,可以看到現在佔用的內存大小大約爲960KB,從優化後的寬高來看,第三種方式並沒有效果。應爲目標ImageView尺寸也不小,而inSampleSize的值必須是2的整數冪,因此計算得到的值還是1。

PS: Bitmap內存佔用的優化還有一個方式是複用和緩存

不同Android版本時的Bitmap內存模型

我們知道Android系統中,一個進程的內存可以簡單分爲Java內存和native內存兩部分,而Bitmap對象佔用的內存,有Bitmap對象內存和像素數據內存兩部分,在不同的Android系統版本中,其所存放的位置也有變化。Android Developers上列舉了從API 8 到API 26之間的分配方式:

API級別 API 10 - API 11 ~ API 25 API 26 +
Bitmap對象存放 Java heap Java heap Java heap
像素(pixel data)數據存放 native heap Java heap native heap

可以看到,最新的Android O之後,谷歌又把像素存放的位置,從java 堆改回到了 native堆。API 11的那次改動,是源於native的內存釋放不及時,會導致OOM,因此纔將像素數據保存到Java堆,從而保證Bitmap對象釋放時,能夠同時把像素數據內存也釋放掉。

上面兩幅圖展示了不同系統,加載圖片後,內存的變化,8.0的截圖比較模糊。途中淺藍色對應的是Java heap使用,深藍色對應的是native heap的使用。
跟蹤一下8.0的native源碼來看看具體的變化:

// BitmapFactory.cpp
    if (!decodingBitmap.setInfo(bitmapInfo) ||
            !decodingBitmap.tryAllocPixels(decodeAllocator, colorTable.get())) {
        // SkAndroidCodec should recommend a valid SkImageInfo, so setInfo()
        // should only only fail if the calculated value for rowBytes is too
        // large.
        // tryAllocPixels() can fail due to OOM on the Java heap, OOM on the
        // native heap, or the recycled javaBitmap being too small to reuse.
        return nullptr;
    }

// Graphics.cpp
bool HeapAllocator::allocPixelRef(SkBitmap* bitmap, SkColorTable* ctable) {
    mStorage = android::Bitmap::allocateHeapBitmap(bitmap, sk_ref_sp(ctable));
    return !!mStorage;
}

// https://android.googlesource.com/platform/frameworks/base/+/master/libs/hwui/hwui/Bitmap.cpp
static sk_sp<Bitmap> allocateHeapBitmap(size_t size, const SkImageInfo& info, size_t rowBytes) {
    void* addr = calloc(size, 1);
    if (!addr) {
        return nullptr;
    }
    return sk_sp<Bitmap>(new Bitmap(addr, size, info, rowBytes));
}

還是通過BitmapFactory.cpp#doDecode方法來跟蹤,發現其中tryAllocPixels方法,應該是嘗試去進行內存分配,其中decodeAllocator會被賦值爲HeapAllocator,通過一系列的調用,最終通過calloc方法,在native分配內存。
至於爲什麼Google 在8.0上改變了Bitmap像素數據的存放方式,我猜想和8.0中的GC算法調整有關係。GC算法的優化,使得Bitmap佔用的大內存區域,在GC後也能夠比較快速的回收、壓縮,重新使用。

(native存放) 退出Activity 退出App
onStop中主動調用gc()和recycler() 內存不釋放 內存釋放
無調用 內存不釋放 內存不釋放
(gpu存放) 退出Activity 退出App
onStop中主動調用gc()和recycler() 內存釋放 內存釋放
無調用 內存釋放 內存釋放

總結

// 8.0源碼
    Bitmap(long nativeBitmap, int width, int height, int density,
            boolean isMutable, boolean requestPremultiplied,
            byte[] ninePatchChunk, NinePatch.InsetStruct ninePatchInsets)
// 7.0源碼
Bitmap(long nativeBitmap, byte[] buffer, int width, int height, int density,
            boolean isMutable, boolean requestPremultiplied,
            byte[] ninePatchChunk, NinePatch.InsetStruct ninePatchInsets)

一開始看兩者java代碼不同,少了存放像素的buffer字段,查閱相關資料到native源碼對比,最終總結了下Bitmap內存相關的知識。另外,在Android 8.0中,關於Bitmap的改動有兩方面還需深入探究的:1、Config配置爲Hardware時的優劣。Hardware配置實際上沒有改變像素的位儲存大小(還是默認的ARGB8888),但是改變了bitmap像素的存儲位置(存放到GPU內存中),對實際應用的影響會如何?;2、Bitmap在8.0後又迴歸到native存放bitmap像素數據,而這部分數據的回收時機和觸發方式又是如何?一般測試下,可以通過native分配Bitmap超過1G的內存數據而不發生崩潰。

作者:Dragon_Boat
鏈接:https://www.jianshu.com/p/3f6f6e4f1c88
來源:簡書

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章