Android系統Bitmap記憶體分配原理與優化

vivo網際網路技術發表於2021-07-06

一、前言

筆者最近致力於vivo遊戲中心穩定性維護,在分析線上異常時,發現有相當一部分是由OutOfMemory引起。談及OOM,我們一般都會想到記憶體洩漏,其實,往往還有另外一個因素——圖片,如果對圖片使用不當的話,很容易吃掉大量記憶體,從而導致異常。

尤其是遊戲中心在2020末~2021初的幾個重要版本,上線了很多內容相關的feature,引入大量圖片、視訊列表,從而導致線上OOM佔比上升。

在這篇文章中,筆者將講解一張看似普通的Bitmap對記憶體的佔用,介紹Android Studio中幫助我們分析圖片佔用記憶體的工具,舉例說明流行的兩大圖片載入框架:Glide、Picasso在載入圖片時使用記憶體的不同方式,接著分析不同drawable目錄下圖片的顯示策略,最後基於手機記憶體、版本,提出一種優化記憶體分配的方案。

二、檢視圖片記憶體佔用

一張圖片在記憶體佔用的空間究竟有多少,普遍存在的一個誤解是,圖片本身在磁碟上/從網路下載下來是多大,就會佔用多少的記憶體。這種說法是不正確的,圖片佔用記憶體的大小不取決於它本身的大小,而取決於圖片庫所採用的展示方式所申請的記憶體。

拿鋼鐵俠這張圖片舉例,它的尺寸是350*350,可以看到在電腦磁碟上,它只佔36KB的空間。

我們建立一個簡單的Demo,頁面正中央是一個ImageView,用於顯示這張鋼鐵俠圖片。

通過Android Studio進行heap dump,從而看圖片所佔用的記憶體。首先我們將顯示圖片時的記憶體快照儲存下來。操作路徑為Profiler -> Memory -> Heap Dump,這會生成一個dump檔案,在其中可以看到當前堆的使用情況。

在下面這張圖可以看到,程式執行時,“鋼鐵俠”這張圖片佔用的記憶體(Retained Size)是2560000bytes,約等於2.4MB記憶體。與它在磁碟上36KB的大小,相差了整整70倍!

小技巧:如何檢視dump檔案中的圖片

在除錯時,如果我們手頭只有一個dump檔案,往往需要還原圖片內容,以幫助定位問題。有兩種方式可以從dump檔案裡提取原圖片。

方式一:通過Android Studio直接檢視

如果dump檔案來源自Android版本為7.1.1(Android N,API=25)及以下的裝置,可以使用這種方法。選中Bitmap物件,直接在視窗的Bitmap Preview中檢視圖片內容(如上圖),非常方便。

方式二:通過MAT+GIMP檢視

這種方法適用於全部Android版本的裝置,首先用MAT開啟dump檔案,有時會發生下圖的錯誤:

原因是Android Studio的Profiler生成的dump檔案不是標準格式,我們可以使用位於路徑SDK/platform-tools/hprof-conv.exe的工具將其轉換為標準格式,轉換命令為:

hprof-conv.exe <in-file> <out-file>

將轉換後的dump檔案通過MAT開啟,在其中找到Bitmap物件的byte[]屬性,將其複製為image01.data檔案。

Tip: 可以看到這裡image01.data檔案的尺寸是2.44MB,也正是在執行時圖片所佔用的記憶體。

然後用GIMP工具開啟該檔案,在格式那裡選擇RGBA(大部分Bitmap都使用這種格式),寬與高可以在MAT中看到,筆者這裡是800 * 800。設定好格式和寬高後,就可以看到圖片的真實面目了。

二、圖片記憶體佔用計算公式

在上一章節我們知道一個通過網路下載的36KB圖片,在被載入到記憶體中時,需要2.4MB的空間。接下來解釋這其中的換算關係,讓我們記住一個公式:

圖片佔用記憶體 = 圖片質量 * 寬 * 高

這裡面有“圖片質量”、“寬”、“高”三個因素,它涉及到圖片載入框架的實現,不同的框架,對於這三者的預設取值是不一樣的,我們以當前最流行的Picasso和Glide為例。

Picasso

在Picasso中,圖片預設顯示的寬高與原始圖片寬高一致。仍然以這張鋼鐵俠為例,圖片本身是350px * 350px,當我們把它載入到200px * 200px的ImageView當中時,佔用空間是0.49MB

因此,在目標ImageView小於圖片尺寸的情況下,好的做法是使用不超過ImageView尺寸的圖片源,一方面可以縮短圖片下載時間,另一方面有助於優化記憶體佔用。

Glide

Glide則採用截然不同的處理方式,它最終使用的寬高是目標ImageView的寬高。如果我們把同樣一張圖片載入到200px * 200px的ImageView中,佔用空間只有0.16MB。

使Picasso達到與Glide同樣的效果

Picasso的設計者也發現了這一缺點,提供一系列方法用來調整最終載入出來的圖片尺寸,其一就是fit(),通過這個方法可以達到與Glide同樣的效果。

Picasso().get().load(IMAGE_URL).fit().into(imageVIEW)

相反場景:小圖載入到大ImageView中

通常為了提供更清晰的介面,防止圖片拉伸後失真模糊,設計師提供的圖片都是高解析度的,我們所面臨的場景是將大圖載入到小ImageView中。但也不排除相反的可能:將小圖載入到大ImageView裡面。這時Glide預設採用的記憶體策略是存在不足的:它採用目標ImageView的尺寸作為最終的寬和高。

舉例說明,當把350 * 350的鋼鐵俠圖片載入到600 * 600的ImageView中時,佔用的記憶體高達1.41MB。

600 * 600 * 4bytes = 1.41MB

有沒有一種方法,可以兼顧原圖片與目標ImageView不同的大小關係呢?——有的,這就是centerInside()。

Glide.with(this).load(IMAGE_URL).centerInside().into(imageView)

藉助centerInside()方法,可以達到“在原圖片和目標ImageView中取最小寬高作為最終載入圖片的尺寸”這樣的效果。

三、圖片質量

什麼是“圖片質量”?簡單說就是用多少位元組來表示一個畫素點的顏色,它的學名叫做“位深度”,在圖片屬性當中可以看到。

圖片位深度通常有1位、8位、16位、24位、32位。

PNG格式有8位、24位、32位三種形式,其中8位PNG支援兩種不同 的透明形式(索引透明和alpha透明),24位PNG不支援透明,32位 PNG 在24位基礎上增加了8位透明通道,因此可展現256級透明程度。

Glide和Picasso預設採用的圖片質量都是ARGB_8888、也就是帶透明度的32位深度,一個畫素點需要佔用4bytes的記憶體,這也解釋了為什麼上文中的計算都是採用寬_高_4bytes的公式。

注:v4開始,Glide將ARGB_8888作為預設配置。在那之前它一直預設使用RGB_565。

對客戶端使用的大部分圖片來說,32位深度、16位深度的顯示質量是肉眼較難分辨的,但它們在佔用記憶體上相差了整整一倍。因此,筆者建議在大部分場景下,使用RGB_565作為載入圖片的模式。以下兩種場景除外:

1)含透明部分的圖片:如果採用RGB_565圖片格式來顯示圖片,是無法正常展現透明區域的。比如上方這個鋼鐵俠圖片,原本透明的部分會被顯示為黑色。

2)含漸變色並且對顯示質量要求高的圖片:32位比16位可以支援更多的顏色,在漸變的顯示上呈現更加自然的過渡(如下圖)。這時我們應當在顯示質量和應用效能之間作取捨。對於低端裝置,應用的穩定性比顯示質量更加重要,筆者強烈建議採用16位深度來顯示。

四、drawable目錄下圖片載入方式

專案的資源目錄下,一般都有drawable-mdpi、drawable-hdpi、drawable-xhdpi、drawable-xxhdpi、drawable-xxxhdpi目錄,它們是用來匹配不同顯示密度的裝置的,對應表格如下。

通過adb shell wm density可以獲取當前裝置的dpi,對Nexus 6P模擬器執行後,可以讀取到它的dpi是560,屬於xxxhdpi。

$ adb shell wm densityPhysical density: 560

那麼同一個圖片放在不同目錄下,對分配記憶體是否有影響呢?答案是有的,基於兩步簡單的推導:

  • 圖片所在資源目錄、裝置密度兩者決定圖片最終顯示在螢幕上的畫素尺寸;

  • 畫素尺寸、圖片質量共同決定分配記憶體。

其中第2點已經在上文講解過,這裡主要分析第1點。使用圖片編輯軟體,將原本是350 * 350的鋼鐵俠圖片放大至700 * 700,並分別放入xhdpi、xxxhdpi兩個目錄下。

為什麼使用這樣的組合呢?因為從上表得知,xhdpi與xxxhdpi的顯示密度是1:2,意味著一臺xxxhdpi的裝置在顯示drawable-xhdpi目錄下的圖片時,會將其放大為2倍進行展示。因此我們將350 * 350的骨片放入drawable-xhdpi,將700 * 700的圖片放入drawable-xxxhdpi,預期它們最終在螢幕上顯示的尺寸相同。

在佈局裡建立兩個ImageView,觀察這兩張圖片最終的顯示效果,以及分配記憶體情況。

<FrameLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="#000000">
​
    <!-- 350 * 350,位於drawable-xhdpi -->
    <ImageView
        android:id="@+id/iv_image_1"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:padding="40dp"
        android:src="@drawable/iron_man_350_square_xhdpi"
        />
​
    <!-- 700 * 700,位於drawable-xxxhdpi -->
    <ImageView
        android:id="@+id/iv_image_2"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:padding="40dp"
        android:layout_gravity="bottom"
        android:src="@drawable/iron_man_700_square_xxxhdpi"
        />
​
</FrameLayout>

顯示效果以及記憶體分配如下:

可以分析得出以下結論:

對於顯示尺寸613 * 613的圖片,其佔據記憶體為613 * 613 * 4 = 1,503,076B ≈ 1.5MB,符合上文中我們對圖片記憶體的分析;

決定圖片佔用記憶體的是其最終顯示在螢幕上的尺寸,與圖片本身解析度、在哪個drawable目錄下沒有直接關係;

由於xxxhdpi密度是xhdpi密度的兩倍,故在螢幕密度屬於xxxhdpi的Nexus 6P裝置上,drawable-xxxhdpi目錄下的圖片被以近似於原畫素尺寸(700px)進行顯示(顯示為613px),而位於drawable-xhdpi目錄下的圖片被放大至2倍顯示,最終顯示尺寸同樣是613px。

五、優化策略

在實際的開發中,我們希望中高階機型載入更清晰的圖片(ARGB_8888),以提升使用者體驗,對於低端機型則希望載入佔用記憶體更小的圖片(RGB_565),以降低OOM發生的概率。可以在初始化Glide時進行這樣的配置。需要留意的是不要對含透明區域的圖片採用這種優化方案。

@GlideModule
class MyGlideModule : AppGlideModule() {
​
    override fun applyOptions(context: Context, builder: GlideBuilder) {
        builder.setDefaultRequestOptions(RequestOptions().format(getBitmapQuality()))
    }
​
    private fun getBitmapQuality(): DecodeFormat {
        return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N || hasLowRam()) {
            // 低端機型採用RGB_565以節約記憶體
            DecodeFormat.PREFER_RGB_565
        } else {
            DecodeFormat.PREFER_ARGB_8888
        }
    }
}

六、小結

藉助一些開源工具,我們可以便捷地定位大圖,如滴滴開源的DoKit,篇幅原因不進行詳細介紹。最後,對於我們日常開發總結幾點建議,希望大家的應用穩定性節節攀升。

  • 在多圖的場景(比如RecyclerView)注意及時釋放圖片資源;

  • 使用佔據記憶體更小的圖片格式;

  • 圖片原始檔尺寸應當與目標ImageView相近;

  • 優先滿足xxhdpi、xxxhdpi的圖片資源需求;

  • 根據裝置效能,採用不同的圖片載入策略。

作者:vivo網際網路客戶端團隊-Li Lei

相關文章