天天看點

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) 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檔案夾下:

并将其加載到一個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)
           

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

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

  • inDensity:Bitmap位圖自身的密度、分辨率
  • inTargetDensity: Bitmap最終繪制的目标位置的分辨率
  • inScreenDensity: 裝置螢幕分辨率

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

density 0.75 1.5 3 3.5
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的參數,結果見下圖:

以上可以驗證幾個結論:

  • 同一張圖檔,放在不同資源目錄下,其分辨率會有變化,
  • bitmap分辨率越高,其解析後的寬高越小,甚至會小于圖檔原有的尺寸(即縮放),進而記憶體占用也相應減少
  • 圖檔不特别放置任何資源目錄時,其預設使用mdpi分辨率:160
  • 資源目錄分辨率和裝置分辨率一緻時,圖檔尺寸不會縮放

是以,關于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
像素(pixel data)資料存放 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存放)

總結

// 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

來源:簡書