1. 程式人生 > >mPaaS 3.0 多媒體元件釋出 | 支付寶百億級圖片元件 AntMedia 錘鍊之路(圖片快取篇)

mPaaS 3.0 多媒體元件釋出 | 支付寶百億級圖片元件 AntMedia 錘鍊之路(圖片快取篇)

一. 背景介紹

圖片載入一直是 Android App 面臨的“老大難”問題,載入速度與記憶體消耗天生就是一個矛盾統一體。我們依託支付寶超級 App 複雜的生態業務場景,借鑑業界領先的開源框架 Fresco、Picasso,取其精華,棄其糟粕,並獨創性地使用 Ashmem、Native Mem Cache、Bitmap Reuse、分場景快取、圖片分大小快取等多維一體的圖片載入技術,實現了載入速度與記憶體消耗的完美平衡。

歷經三年的風雨洗禮沉澱,AntMedia 多媒體圖片載入元件已經成為支付寶重要的驅動力,承載了絕大部分業務,與此同時,我們也通過移動開發平臺 mPaaS 對外輸出,向外界企業提供穩定的圖片載入技術。

二. Android 記憶體基礎與挑戰

Android 系統應用單個程序堆記憶體分配有限,再加上不同 Android 手機硬體效能和系統版本參差不齊,對於大型App 來說,尤其是包含圖片載入元件的 App,如何高效合理使用 Android 記憶體已經是一個必不可少的話題。 工欲善其事,必先利其器。想要 App 高效合理地利用記憶體,還需要先了解下 Android 系統記憶體相關的一些基礎知識。

1. Android 記憶體分類

對於手機來說,儲存空間跟計算機裝置一樣分為 ROM 和 RAM。

| ROM (Read Only Memory):

名字上解釋為只讀記憶體,其實ROM種類也分很多種,有隻讀的,有可讀寫的,主要用於儲存一些資料資訊,斷點後資料不會丟失。

| RAM (Rondom Access Memory):

手機的執行時的實體記憶體,負責程式的執行以及資料交換,斷電時儲存資訊丟失。程式程序的記憶體空間只是虛擬記憶體,而程式執行實際需要的是 RAM 實際實體記憶體,作業系統會將程式申請的程序虛擬記憶體對映到實體記憶體 RAM 中。

在 Android 應用程序中一般記憶體可分為 Heap 堆記憶體、Code 程式碼區、Stack 棧記憶體、Graphics 視訊記憶體、私有非共享記憶體以及系統記憶體,其中 Heap 記憶體又分為 Davilk Heap 以及 Native Heap。

Android 可以通過 adb shell dumpsys meminfo+package name 或 pid 命令來檢視當前程序記憶體佔用情況,如圖 1 所示。

圖1:通過dumsys輸出的記憶體佔用情況

記憶體分類說明如下:

型別 描述
Native Heap 從 C 或C++ 程式碼分配的物件記憶體。 Native Heap 就是在 Native Code 中使用 malloc 等分配出來的記憶體,這部分記憶體是不受 Java Object Heap 的大小限制的,也就是它可以自由使用,當然它是會受到系統的限制,其上限值一般為系統 RAM 的 2/3 大小。
Dalvik Heap 從 Java 或 Kotlin 程式碼分配的物件記憶體,Android 系統對每個程序的 Dalvik Heap 大小做了限制,具體可以通過反射呼叫 SystemProperties 的方法來獲取到程序的最大 Heap 記憶體值。
Code 程式碼和資源(如 dex 位元組碼、已優化或已編譯的 dex 碼、.so 庫和字型)佔用的記憶體。
Stack 系統棧,由作業系統分配,主要儲存函式地址、引數、區域性變數、遞迴資訊等,stack 空間不大,一般為幾 MB。
Cursor 位於 /dev/ashmem/Cursor,Cursor 佔用的記憶體。
.* mmap 各種用於存放 .so.dex.apk.jar.ttf 等檔案檔案儲存對映所佔用的記憶體。
AshMem 匿名共享記憶體,基於 mmap 系統實現,跟mmap的區別在於 AshMem 通過註冊 Cache Shrinker 來控制記憶體的回收。
Other dev 內部 Driver 佔用。
EGL mtrack 佔用的是 Graphics 記憶體,用於圖形緩衝佇列項螢幕顯示圖形畫素所使用的記憶體。

通過圖 1 可以簡單直觀的瞭解 Android 程序的記憶體分類和使用基本情況。對於應用開發者來說,直接接觸到的記憶體操作主要集中在 Dalvik Heap 和 Native Heap,尤其是 Dalvik Heap 記憶體,經常程式使用不當就遇到 OOM 的情況。

為何應用程式容易出現 OOM,並不是系統 RAM 實體記憶體不夠,而是系統對虛擬機器程序的 Dalvik Heap 大小做了強制限制,一旦應用程式分配所使用的 Dalvik Heap 記憶體總和大小超過了程序限制閾值時,底層就會往應用層丟擲 OOM 的異常。

2. Android 記憶體回收機制

既然應用程式容易出現 OOM,而 Android 上層應用大部分基於 Java 語言的程式開發,開發者不用像 C/C++ 開發那樣需要顯示的分配和釋放記憶體,絕大部分都是統一交由系統的垃圾回收機制進行記憶體的回收管理,記憶體好像變得一切都不在自己掌控中似的。 開發中也經常因為一些記憶體洩露和記憶體不合理造成系統頻繁觸發 GC 和 OOM,在系統 GC 時會暫停執行緒工作,導致應用執行卡頓。因此作為應用開發者瞭解其中的記憶體回收機制還是有必要的。

Android 記憶體 GC 回收有兩個層面,分別為程序內的記憶體回收和程序級的記憶體回收。

| 程序內的記憶體回收:

主要是虛擬機器自身的垃圾回收和系統記憶體狀態發生變化時,通知應用程式讓開發者自己進行記憶體回收。其中虛擬機器的垃圾回收機制是通過虛擬機器監測應用程式裡面的物件建立和使用情況,並在一定條件下銷燬回收無用物件佔用的記憶體,這裡無用物件的識別通常有引用計數、物件標記追蹤以及分代等演算法,相關演算法具體原理可以參考。即使有了虛擬機器自動回收那些不再被引用的物件,但開發者也不能無節制的使用記憶體從而導致 OOM,開發者一般需要在適當的場合確認某些物件不再被使用時,主動將其引用釋放,避免出現無用物件被長期持有造成記憶體洩露,而虛擬機器在記憶體回收的時候無法對洩露物件釋放記憶體。

| 程序級記憶體回收:

原則是按照程序的優先順序進行記憶體回收,程序的優先順序越低越容易被回收,如圖 2 所示,Android 程序優先順序預設分為 5 種,其優先順序從低到高依次為“空程序->後臺程序->服務程序->可見程序->前臺程序”。

在 Android 中以程序的 oom_adj 值代表程序的優先順序,可通過 adb shell cat /proc/ 程序 pid/oom_adj 來檢視程序的 oom_adj 值大小,程序的 oom_adj 值越大其優先順序越低。Android 的記憶體回收是通過 Frame Work 層和 Linux 核心層協調完成的,整體流程如圖 3 所示。

在 Framework 層,AMS(Activity Manager Service) 負責集中管理程序的記憶體分配以及調整程序的 oom_adj 值,然後將 oom_adj 值通知到核心層,同時根據系統記憶體以及程序狀態通知應用程式記憶體不足,便於開發者自己主動回收記憶體。

核心層裡面又分為 OOM Killer 和 LMK(Low Memory Killer),OOM Killer 是 Linux 下的記憶體回收機制,在系統記憶體耗盡無法分配新的記憶體情況下,啟用它選擇性的殺掉一些程序,到了 OOM 的時候,整個系統已經出現不穩定;而LMK 是 Andorid 基於 OOM Killer 原理所擴充套件的一個多層次 OOM Killer,在未到達 OOM 之前根據記憶體閾值級別提前觸發記憶體回收,在使用者內建空間中指定了一組記憶體臨界值,當其中的某個值與程序描述中的 oom_adj 值在同一範圍時,將該程序 kill 掉。關於 LMK 的詳細介紹請參考

圖2:Android的程序優先順序

圖3:Android的程序級記憶體回收流程

3. 業界圖片元件

通過上面對 Android 記憶體分類及回收機制的簡單介紹,對於使用大量圖片的 App 來說,解碼後的圖片,即 Bitmap,佔用大量的記憶體,勢必更加容易觸發頻繁的 GC。 目前業界幾款比較成熟的開源圖片載入元件有 Facebook 的 Fresco,Google 的 Glide,Square 的 Picasso 等,其圖片快取均使用了三級快取技術,即“記憶體快取+磁碟快取+網路”。載入的優先順序從高到低依次為“記憶體快取->磁碟快取->網路”。在記憶體快取方面,採用的是直接快取 Bitmap 物件,部分策略大同小異,如圖 4 所示。

圖4:業界圖片元件的記憶體快取策略示意圖

| Fresco:

記憶體快取使用的是 CountingMemoryCahce,裡面有包含了正在使用的快取 mCachedEntries 以及將要回收的快取 mExclusiveEntries,都是基於 CountingLruMap 存放的。記憶體快取的內容包含 Bitmap 以及未解碼的圖片資料 EncodedImage,優先檢查 Bitmap 的快取,若沒有再去未解碼的圖片記憶體快取中獲取並解碼。 對於 Bitmap 記憶體快取:

在 5.0 以下系統,其 KitKatPurgreableDecoder 解碼器利用系統特性將解碼 Bitmap  的pixel(畫素資料)放到 AshMem 中(在實際測試中 Native Heap 也佔用了一份資料),在圖片不佔用的時候主動釋放,從圖 1 中可以看到,AshMem 是不佔用 Java Heap  記憶體的,因此Bitmap 的快取不會佔用大量的 Java Heap ,可以減少因圖片佔用 Java 堆記憶體而引發 GC 和 OOM 的頻率。

在 5.0 以上系統,其 ArtDecoder 裡面直接呼叫 BitmapFactory 進行圖片解碼生成 Bitmap,生成的 Bitmap 佔用的記憶體為 Java Heap 記憶體,只不過在解碼過程中將 BitmapOptions 的 inBitmap 和 inTempStorage 屬性分別與 BitpmapPool 和 SyncronizedPool 實現複用,從而最大合理的利用和優化記憶體。詳細的解碼流程可參考

| Glide:

記憶體快取設計取樣的是 LruCache+Weakference 結合的方式來直接儲存 Bitmap 物件,而 Bitmap 物件是從 BitmapPool 中重複複用的,這樣減少了頻繁建立和回收 Bitmap 減少記憶體抖動。

| Picasso:

基於 LinkedHashMap 基礎上實現的 LruCache 來儲存 Bitmap 物件,Bitmap 物件佔用的完全是 Java Heap 記憶體,因此其最大快取容量僅為單程序最大記憶體值的 15%。

通過對比知道,除了 Fresco 外,另外兩種圖片元件基本都是直接採用 LruCache+Bitmap 的方式,且 Bitmap 佔用的都是 Java Heap 記憶體,而 Fresco 在部分系統版本上使用了所謂的黑科技將 Bitmap 佔用的記憶體轉移到  AshMem,從而減少 Java Heap 記憶體的佔用。

AntMedia 圖片元件的記憶體快取則採用了多維一體的快取設計,後面會詳細介紹。

4.技術挑戰

對於支付寶這種 App 複雜的生態業務場景,AntMedia 一開始使用基於 LRU 淘汰機制的普通堆記憶體快取技術已經不能滿足體驗與效能之間的平衡,在整個開發過程中遇到了以下坑:

| 主程序圖片記憶體快取佔用 Java Heap 過高

大量的圖片記憶體快取導致 App 佔用 Java Heap 記憶體過高,容易頻繁觸發 GC 導致頁面卡頓。 後臺程序記憶體過高容易被 kill 掉,保持 App 低記憶體而不影響體驗很重要。 圖片記憶體在整個 App 程序中不能佔用過多,否則容易導致其他業務或功能記憶體吃緊而導致功能或體驗影響。

| 大圖快取會加速小圖快取淘汰

採用 LruCache+Bitmap,超大圖片解碼後佔用記憶體過大,例如一張 1280*1280 按 ARGB8888 模式解碼出來佔用的記憶體接近 6M,而低端機上單個程序分配總的 Heap 記憶體大小才 100M 左右,圖片記憶體快取最多隻能幾十兆,存放大圖頂多也就 10 來張,很容易引發圖片記憶體快取 LRU 淘汰,影響小圖載入的體驗。 普通業務的圖片記憶體快取在到達快取上限值時是希望能有效被回收,但是也有特定業務是不希望被頻繁回收,比如頭像記憶體佔用小但使用頻率較高的業務場景。 Gif 包含多幀圖片,每幀如果單獨解碼生成 Bitmap,則一個動畫需要快取很多 Bitmap,更容易導致普通圖片被回收。

三. 精細化記憶體快取

為了解決以上踩過的坑,思路是比較明確的,就是儘量減小圖片快取在 Java Heap 中所佔比例,如圖片快取單獨程序、修改程序 Java Heap 限制、轉移圖片記憶體至非 Java Heap 儲存區。最終 AntMedia 選擇瞭如圖4中的方案,採用了三類記憶體快取設計:普通快取 NativeHeap,快取記憶體 Heap,臨時快取 SoftReference。

1. 普通快取 NativeHeap

顧名思義使用 Native 記憶體作為圖片的記憶體快取,主要是 Native 記憶體不受虛擬機器記憶體回收控制,能有效減少Java堆記憶體佔用從而降低 GC 的概率。

  • 在 5.0 系統版本以下,使用 LruCache 直接管理解碼使用 AshMem 記憶體的 Bitmap。

AshMem 記憶體不同於普通的堆記憶體,這部分記憶體與 Native 記憶體區類似,受 Android 系統底層管理的,在 Android 圖片呼叫系統解碼的時候 BitmapFactory.Options 中有這 2 個屬性 inPurgeable 和 inInputShareable,通過這個屬性設定就能保證解碼出來的 Bitmap 使用 AshMem,這種記憶體在 Android 系統裡面是不被計算到普通堆記憶體的佔用,因此不容易觸發 GC 和 OOM。

  • 在 5.0 及以上版本使用 NativeCache。

NativeCache 方案佔用的是 Native Heap 記憶體,對於使用頻率一般的圖片,建議使用,實現原理:上層使用LruCache 管理快取資訊,key 是唯一索引圖片的 key,value 是儲存了 Bitmap Native 記憶體拷貝的指標的BitmapInfo。有當快取發生淘汰時,就把對應的 Native 的記憶體進行釋放。兩種方案都是佔程序記憶體的 3/8,最大不超過 96M。

在最開始的記憶體快取優化中,進行了多套方案嘗試對比,在 Android 4.0 及以上系統支援 Bitmap 的複用情況下最終選擇了使用 JNI 介面自己管理 C 記憶體的 Native 方案,具體的載入流程如圖 5 所示:

圖5.Native 圖片記憶體快取載入流程圖

圖6.Native 圖片記憶體快取釋放流程

Native 圖片記憶體載入流程說明:

A.業務發起一個圖片載入任務,把 ImageView 顯示的 Bitmap 取出並傳入進來,判斷記憶體中是否存在此圖片資訊,不存在則調轉到E。

B.記憶體中存在則根據 key 從 cache 中取出此圖資訊 BitmapInfo,根據此資訊判斷 Native 層的圖片快取是否有效,無效則跳轉到E。

C.Native 圖片快取有效,則將底層圖片快取的pixel通過jni介面拷貝到建立或者複用的bitmap中。

D.將圖片資料填充後的 Bitmap 渲染到 ImageView 或直接返回給業務。

E.從本地磁碟快取/檔案/網路上載入圖片,將解碼後的 Bitmap 畫素資料快取到 Native 層,生成 BitmapInfo,並BitmapInfo將加入到LRU快取進行管理,最後渲染圖片。

Native 圖片記憶體快取釋放流程說明,如圖 6 所示:

A.主動釋放或自動淘汰時,獲取對應圖片的 BitmapInfo 資訊,並將從存放此資訊的記憶體快取中移除,如果資訊無效則流程結束。

B.有效,則傳入 BitmapInfo 中的指標地址資訊給 Native,通過呼叫 JNI 中 C 的 free 介面進行釋放並結束。

以下為記憶體讀取耗時資料測試對比,結果如圖 7 和圖 8 所示:

圖7.Native(Bitmap 複用)與 Heap 記憶體圖片載入耗時

圖8.Native 記憶體 Bitmap 複用與未複用載入耗時

測試條件:

紅米 Note1,系統版本 4.4.2,單個程序系統預設分配 128M 最大堆記憶體。

測試結果:

1)從圖 7 看,基於 Native 的圖片記憶體快取在讀取速度上基本控制在 3ms 以內,比純粹的基於 Heap 的記憶體速度耗時平均多1ms左右,基本可認為基於 Native 的記憶體讀取速度跟跟普通 Heap 記憶體讀取速度一樣。

2)從圖 8 看,Native 記憶體在 Bitmap 未複用(每次載入都從系統建立新的 Bitmap)的情況下,會週期性出現某次載入耗時到 100ms 以上的情況,原因主每次載入都頻繁建立新的 Bitmap 會增加系統堆記憶體開銷,引起記憶體抖動,從而增大了系統 GC 的頻率,尤其在低端機型上較明顯,如圖 9 所示。

圖9.未複用情況頻繁觸發了 GC

2. 快取記憶體 Heap

此快取是普通的基於 LRU 淘汰策略的堆記憶體快取,總大小為當前程序的 1/8,最大不超過 64M,儲存的內容為圖片解碼後的 Bitmap 物件,主要用於解決頭像這種佔用記憶體不大但使用頻率較高的業務場景。

3.臨時快取 SoftReference

此快取主要用於兩種場景:儲存 Gif 相關的物件和超大圖物件,佔用的是 Java Heap 記憶體,實現原理,通過 SoftReference 保留對 Bitmap 或 Gif 物件的引用,在記憶體吃緊時,可以及時 GC,騰出記憶體。主要為了減少因單個大記憶體圖(5M 預設為大圖)載入會淘汰很多小記憶體圖的場景,提升使用者圖片體驗。

上面三種記憶體快取組合起來的整個圖片記憶體載入以及存放流程如圖 10 和圖 11 所示:

圖 10.Bitmap 獲取流程 圖 11.Bitmap 存放流程

四. 競品測試對比

測試條件:

基於 Android 4.4 和 6.0 系統上,在同一介面使用不同的圖片元件載入 20 張本地圖片。以下為各圖片元件的記憶體佔用情況,結果如圖 12 和圖 13 所示。

圖12:Android 4.4系統上記憶體佔用對比 圖13:Android 6.0系統上記憶體佔用對比

測試結果說明:

1. Android 4.4 系統上

| Java Heap 記憶體佔用:

由高到低依次為 Picasso->Glide->(Fresco 和 Antmedia)。其中 Fresco 和 AntMedia 圖片快取是沒有佔用 Java Heap 記憶體。在退出測試介面 GC 後,Picasso 沒有釋放 Java Heap 記憶體,而 Glide 內部則進行了主動釋放。

| Native Heap 記憶體佔用:

由高到低依次為 Fresco->AntMedia->(Picasso和Glide),其中 Fresco 使用所謂黑科技到將圖片記憶體快取放到AshMem,但實際上 AshMem 跟 Native Heap 是兩塊不同的記憶體區域,Fresco 在 AshMem 和 Native Heap 各佔用一份;而 AntMedia 並沒有佔用 Native Heap,而是隻佔用 AshMem;Picasso 和 Glide 則均不佔用 Native 和 AshMem 記憶體。至於為何說 Fresco在AshMem 和 Native Heap 各佔用一份,而 AntMedia 只佔用了 AshMem,通過 dump 當前程序記憶體佔用就一目瞭然,圖 14 中 Fresco 載入圖片前後 Native Heap 以及 AshMem 佔用均發生較大變化;而圖 15 中 AntMedia 圖片載入前後只有 AshMem 變化較大。

圖14:Fresco 載入圖片前後記憶體佔用情況

圖15:AntMedia 載入圖片前後記憶體佔用情況

2. Android 6.0 系統上

| Java Heap 記憶體佔用:

由高到低依次為 Fresco->Picasso->AntMedia->Glide。四種圖片元件均佔有 Java Heap,其中 AntMedia 並不直接快取 Bitmap,而是介面UI控制元件引用了這些 Bitmap,所以導致使用 AntMedia 時佔用 Java Heap,但是當退出測試介面並 GC 後整體 Java Heap 便釋放,下次再進入測試頁面則直接從 Native 將對應的圖片資料 copy 到新建立或複用的 Bitmap 中即可顯示;Glide 在退出測試介面後內部會主動釋放掉所有的圖片記憶體快取,但是在重新進入測試頁面載入時需要全部重新解碼,快取的複用率不高。

| Native Heap 記憶體佔用:

由高到低依次為 AntMedia->(Fresco、Picasso和Glide),其中只有 AntMedia 的圖片快取用到 Native Heap,而其它三個均使用的是 Java Heap。

總的來說,在 5.0 系統以下,AntMedia 在J ava Heap 和 Native Heap 上均佔有優勢;5.0 以上系統,AntMedia 突破了圖片記憶體快取使用 Native Heap 的技術,雖說從 Java Heap 還是 Native Heap 佔用來看,Glide 的 Java Heap 和 Native Heap 最小,但 Glide 只要 Bitmap 不再使用後就會主動回收,下次載入需要重新解碼,快取複用率不高;另外 AntMedia 對於正在顯示的圖片會佔用雙份記憶體,對於不再顯示的圖片只佔用 Native Heap,但是相對 Glide 好處在於退出介面後 Native 的記憶體快取仍然存在,下次再使用時不需要重新解碼圖片,效率上更有優勢。 Fresco 和 Picasso 的整體表現相對 AntMedia 和 Glide 要偏弱。

五. 其它優化點

  1. 針對普通大圖,通過限制最大邊為 1280 降低圖片大小以及記憶體大小,針對社交圖片,我們提供了縮圖(120x120)、大圖(1280x1280)、原圖 3 個不同級別尺寸的圖片,即使超大原圖,我們也會限制最大邊 12000 的尺寸,然後解碼的時候再取樣處理。
  2. 對於社交會話的縮略模糊圖,直接通過服務端裁剪縮放後由push訊息將縮放後的模糊圖片推送到客戶端直接渲染顯示,避免了檢視圖片訊息時再次網路請求會後渲染中間出現灰底情況。
  3. 壓後臺分不同階段對圖片記憶體快取進行主動清理,保證壓後臺後錢包整體記憶體處於低位執行,減少後臺程序被kill掉的概率。
  4. 定時清理不常用記憶體快取,原理是每次使用時更新快取的使用時間,然後定時去掃描超過一定時間的快取並主動清理掉。
  5. 支援普通 Listview、ViewPager、RecyclerView 的滑動過程中停止載入,滑動結束後再載入,減少一些不必要的任務開銷。
  6. Gif 圖片使用自研解碼器,通過複用一個 Bitmap 物件來達到對每幀的資料的解碼顯示,減少了記憶體佔用。

六. 總結與展望

本文介紹了 AntMedia 在圖片記憶體快取上多維一體的精細化記憶體管理方案,並重點講解使用 JNI 管理 Native C 層記憶體達到圖片記憶體快取目的,突破了 Java Heap 大小限制。此方案也存在小瑕疵,即在顯示當前圖片的時候,除了Native 佔用了一份解碼後的記憶體,Java 堆記憶體在業務上也同樣佔用了一份記憶體,因此需要業務在使用的時候儘量複用 ImageView,使用完後要及時釋放。隨著移動終端智慧化和大資料化的發展,後續如果能對圖片記憶體做一些基於大資料的人工智慧化管理,相信會帶來更好的技術體驗。

如果你對 mPaaS 多媒體元件感興趣,歡迎你登入mPaaS 文件頁瞭解更多。

往期閱讀

《開篇 | 螞蟻金服 mPaaS 服務端核心元件體系概述》

《螞蟻金服 mPaaS 服務端核心元件體系概述:移動 API 閘道器 MGS》

《螞蟻金服 mPaaS 服務端核心元件:億級併發下的移動端到端網路接入架構解析》

《支付寶 App 構建優化解析:通過安裝包重排布優化 Android 端啟動效能》

《支付寶 App 構建優化解析:Android 包大小極致壓縮》

關注我們公眾號,獲得第一手 mPaaS 技術實踐乾貨

QRCode