實踐App記憶體優化:如何有序地做記憶體分析與優化

舒大飛發表於2019-03-04

由於專案裡之前線上版本出現過一定比例的OOM,雖然比例並不大,但是還是暴露了一定的問題,所以打算對我們App分為幾個步驟進行記憶體分析和優化,當然記憶體的優化是個長期的過程,不是一兩個版本的事,每個版本都需要收集線上記憶體資料進行監控以及分析。

版本迭代過程中,記憶體增長過快,不僅會導致一定概率的OOM,執行時若出現記憶體抖動,導致頻繁GC,則會對App的流暢度以及使用者體驗造成很大影響。

本文主要會根據實際專案中優化步驟分為以下幾部分:

  1. Android記憶體分析基礎
  2. 記憶體洩漏
  3. 靜態記憶體分析優化
  4. 執行時記憶體分析優化
  5. 監控

1.Android記憶體分析基礎

這部分主要先介紹一些進行記憶體分析的基礎方法以及工具,對這部分比較熟悉的同學可以先跳過哈。

一.App的記憶體使用情況概覽

每個App程式可以分配到的最大記憶體是有限的,當然不同手機每個App程式可以分配到的最大記憶體有可能不一樣,可以通過以下命令進行檢視:

//dvm最大可用記憶體:
adb shell getprop | grep dalvik.vm.heapsize
複製程式碼
//單個程式限制最大可用記憶體:
adb shell getprop|grep heapgrowthlimit
複製程式碼

超過單個程式限制最大記憶體則OOM,如果設定了開啟largeHeap,則可提高到dvm最大記憶體才OOM。

我們可以輸出我們App的記憶體使用情況概覽:

adb shell dumpsys meminfo 包名
複製程式碼

我們就可以看到:

記憶體概覽

Pss: 該程式獨佔的記憶體+與其他程式共享的記憶體(按比例分配,比如與其他3個程式共享9K記憶體,則這部分為3K)

Privete Dirty:該程式獨享記憶體

Heap Size:分配的記憶體

Heap Alloc:已使用的記憶體

Heap Free:空閒記憶體

二、Android Profiler

AndroidStduio3.0後Android Profiler變得比之前更強大,記憶體分析頁變得更加直觀更加方便,下面是截圖:

Android profiler

  • 程式佔用總記憶體
  • javaHeap:這部分記憶體大小是有限制的,溢位則會OOM,這部分記憶體也是我們分析優化的重點
  • NativeHeap:native層的 so 中呼叫malloc或new建立的記憶體,對於單個程式來說大小沒有限制,所以可以利用在native層分配記憶體來緩解javaHeap的壓力(比如2.3.3之前Android Bitmap的記憶體分配就是在native層,之後移到javaHeap, 8.0又回到native)
  • Graphics:這部分一般遊戲app中用的較多,OpenGL和SurfaceFlinger相關的記憶體,若沒有直接呼叫到OpenGL,則一般不會涉及到這塊記憶體
  • Stack:棧,瞭解jvm記憶體模型的應該都知道
  • Code: 程式碼,主要是dex以及so等佔用的記憶體
  • Others:就是others啦

所以我們可以看到事實上我們可以優化的點有:JavaHeap、NativeHeap、Stack、Code所佔用的記憶體

三、強大的MAT

MAT是做比較細緻的記憶體分析的利器了,功能十分強大,其中的:

Hisogram:Lists number of instances per class

Dominator Tree:List the biggest objects and what they keep alive.

可以非常方便的排序檢視當前記憶體中最佔記憶體的class或者實體物件,而且有一條非常清晰的引用鏈來檢視該物件的持有者,這對記憶體的分析以及記憶體洩漏的分析都是非常友好的。

同時MAT支援compare對比功能,將兩個.hprof檔案匯入,都Add to Compare Basket之後即可進行對比,這對於對比某個頁面相較與前一頁面的記憶體增量來說是非常有意義的。

有一點比較不友好的是,MAT需要標準的.hprof檔案,所以在AndroidStduio的Profiler中GC後dump出的記憶體快照還要自己手動利用android sdk platform-tools下的hprof-conv進行轉換一下才能被MAT開啟。 當然如果覺得麻煩的話也可以自己寫個指令碼執行幾條命令來直接完成GC->dump java heap->轉換.hprof檔案 這個流程:

//adb and hprof-conv
ADB=${ANDROID_HOME}/platform-tools/adb
HPROF_CONV=${ANDROID_HOME}/platform-tools/hprof-conv
複製程式碼
//GC
${ADB} shell pkill -l 10 $(PACKAGE_NAME)
複製程式碼
//dump java heap
${ADB} shell "am dumpheap $(PACKAGE_NAME) $(OUT_PATH)"
複製程式碼
//conv hprof
${HPROF_CONV} -z ${FILE_NAME} droid-${FILE_NAME}
複製程式碼

2.記憶體洩漏

根據以往經驗,其實做記憶體優化最先要搞定的應該是記憶體中的大頭,這類大頭對記憶體的佔用很大,也是記憶體問題的主要禍首,相對來說比較容易定位問題,且優化後效果也非常明顯,價效比非常高。

事實上很多優化都是這樣,比如減包大小的優化,也是要先分析出主要大頭禍首,比如可能你的包裡包含了一張3M大小的無用圖片,如果你沒找到這種禍首,可能你做了大量的工作去想辦法減少無用程式碼等,最終可能只有幾百K的收益。

相對記憶體來說,這個大頭就是:

  • 記憶體洩漏
  • 圖片

所以首先你要確保你的應用裡沒有存在記憶體洩漏,然後再去做其他的記憶體優化。

記憶體洩漏檢測

現在記憶體洩漏的檢測已經變得非常簡便了,使用App後在Android Profiler中先觸發GC然後dump記憶體快照,之後點選按package分類,就可以迅速檢視到你的App目前在記憶體中殘留的class,點選class即可在右邊檢視到對應的例項以及引用物件。

當然你也可以在debug下整合LeakCanary做記憶體洩漏監控警告

排除記憶體洩漏後,圖片就是另一個佔用記憶體大頭的物件了。

圖片

對於圖片來說一個是顏色模式,檢查一下專案裡的圖片的顏色模式,是否可以降低,比如從RGB_8888降到RGB_565,則每張圖片可以節省1/2的記憶體,如果沒有使用到透明通道等的話基本上肉眼看不出差別。

還有一個是降低圖片的大小,可能你的ImageView只有你圖片的一半大,則這部分記憶體就大大浪費了,我們專案服務端會根據前端的引數做動態切圖。

前端也可以通過降低取樣率(inSampleSize)來達到降低圖片佔用記憶體大小的目的,但是這個取樣率InSampleSize只能是整數(甚至只能是2的次方),如果inSampleSize=2,則最終記憶體佔用就會是原來的1/4,適用於圖片過大很多的情況,對於只是想做小幅度壓縮的話,基本沒用。

ok,接下來開始做具體的記憶體分析與稍微細緻一點的記憶體優化。

3.靜態記憶體分析優化

這邊說的靜態記憶體指的是在伴隨著App的整個生命週期一直存在的那部分記憶體,也就是打底的,具體獲取這部分記憶體快照的方式是: 開啟App開始重度使用App,基本開啟每一個主要頁面主要功能,然後回到首頁,進開發者選項開啟"不保留後臺活動",然後將我們的app退到後臺。最後GC,dump出記憶體快照。 下面是我們app dump出的記憶體快照,進行分析後製圖如下:

實踐App記憶體優化:如何有序地做記憶體分析與優化

通過對靜態記憶體資料的分析,主要發現了以下幾個問題:

問題1: App首頁的主圖有兩張(一張是保底圖,一張是動態載入的圖),都比較大,而且動態載入的圖回來後,保底圖並沒有及時被釋放

優化:首先是對首頁的主圖進行顏色通道的改變以及壓縮,可以大大降低這兩張圖所佔的記憶體,然後在動態載入圖回來後及時釋放掉保底圖 -5M

問題2: 首頁底部的輪播背景圖佔用記憶體1.6M,且在圖片載入回來後,背景圖一直沒有置空

優化:首先一般來說對背景圖的質量並沒有很高的要求,所以這張背景圖是可以被成倍壓縮的,並且在圖片載入回來後,背景圖要及時的釋放掉。同時首頁的多張輪播圖以及其他圖片都可以進行顏色模式的改變以及質量壓縮。 -1.6M -4M

問題3: 專案會在App啟動時拉一個介面獲取一些實驗配置,放進單例,在記憶體分析時發現,這些實驗配置竟然接近1M

優化:排查後發現,介面拉的是整個公司所有部門的實驗配置,上千個,這也給遍歷拿一個實驗配置帶來一定的效能損耗,推動介面去改進,只獲取當前部門業務需要的實驗配置,可節省記憶體90%以上 -700K

問題4: 發現幾個lottie動畫一直沒有被回收,並且同一個lottie動畫會有幾個不同的例項存在,總共佔用記憶體450K

優化:首先要確定幾個lottie動畫為什麼在頁面退出後沒有被回收,並且同一個動畫有幾個不同的例項,很容易就聯想到記憶體洩漏,由於頁面沒有被銷燬,所以導致幾個lottie動畫也沒有被回收,排查下來是專案裡的RN頁面存在記憶體洩漏,解決後大概可以節省3-5M記憶體

問題5: SharePreference在記憶體裡佔用了700K的記憶體

優化:由於SP中的東西是會一次性載入到記憶體裡並且儲存為靜態的,直到App程式結束才會被銷燬,所以SP中千萬別放大的物件,別圖一時方便把物件序列化成json後儲存到SP裡,優化點就是把已經儲存在SP中的一些較大的json字串或者物件遷移到檔案或者資料庫快取。 -400K

問題6: 埋點資料

優化:產品或者運營為了統計資料會在每個版本不斷的增加新埋點,但是也需要定期去清理掉一些過時的不需要的埋點,來適當優化記憶體以及CPU的壓力。

問題7: 還有就是一些App裡的單例以及一些靜態快取

優化:整個看下來在我們專案中這部分佔整體的靜態記憶體其實較小,綜合考慮記憶體情況以及使用的高效性可以進行一定程度的優化,不過這部分記憶體在App記憶體緊張時可以選擇清理掉他們

我們可以選擇在App退到後臺後記憶體緊張即將被Kill掉時選擇釋放掉一些記憶體,如圖片的快取,靜態快取等來自保,具體做法是在Activity中重寫onTrimMemory()方法(4.0之前是onLowMemory()),在這裡面來做記憶體的釋放。

靜態記憶體優化:約15M

4.執行時記憶體分析優化

接下來做一下每個頁面的執行時記憶體分析優化,這一部分就是隨著App執行過程增長以及回收的記憶體,這部分工作十分繁瑣,需要耐得住寂寞啊。

分析和優化執行時記憶體主要是通過以下兩個核心方式:

  • 從首頁開始用指令碼dump出每個頁面的記憶體快照檔案,然後利用MAT的對比功能,找出每個頁面相對於上個頁面記憶體裡主要增加了哪些東西,做針對性優化
  • 利用Android Profiler實時觀察進入每個頁面後的記憶體變化情況,對產生的記憶體較大波峰做分析

首先介紹一下我們App中我們產線的主要核心頁面流程:搜尋頁-->列表頁-->詳情頁-->資訊頁-->支付,這裡重點對列表頁和詳情頁做執行時記憶體分析優化。

(1)列表頁記憶體優化

下面是列表頁的記憶體快照與搜尋頁的對比:

記憶體對比

可以看到,絕大部分的記憶體增加還是圖片,當然還有一些靜態快取:

問題1:列表item被回收時還持有圖片的引用

優化:應該在item被回收不可見時釋放掉對圖片的引用,這裡注意RecyclerView與ListView的區別,如果是ListView,因為每次item被回收後再次利用都會重新繫結資料,只需在ImageView onDetchFromWindow的時候釋放掉圖片引用即可。而對於RecyclerView來說,因為被回收不可見時第一選擇是放進mCacheView中,而這裡面的item被複用時並不會執行bindViewHolder來重新繫結資料,只有被回收進mRecyclePool中後拿出來複用才會重新繫結資料,所以如果是RecyclerView,我們釋放圖片引用的時機應該是item被回收進RecyclePool的時候,只要重寫Adapter中的onViewRecycled方法即可:

@Override
public void onViewRecycled(@Nullable VH holder) {
    super.onViewRecycled(holder);
 if (holder != null) {
        //做釋放圖片引用的操作
 }
}
複製程式碼

問題2:圖片大小有優化空間

優化:這個因為我司在服務端會對圖片進行動態切圖,所以最簡單的方法就是根據實際情況來改變動態切圖的大小達到節省記憶體的作用,當然如果從服務端請求回來的圖片實在大(一般不要比裝載的ImageView要大),前端就可以採用降低取樣率的方式來進行壓縮,當然這個上面說了取樣率(inSampleSize)只支援2的次方,所以對圖片佔用記憶體大小的壓縮是非常大的,如果你只是想小幅度的壓縮,基本上這個是沒用的。

問題3:對ImageLoader圖片快取策略的思考

①對於UIL這個圖片框架,他的快取策略是記憶體快取+磁碟快取,記憶體快取預設的資料結構是LruMemoryCache,對圖片是強引用,預設最大Size是記憶體的1/8,滿後會按照LRU演算法對最近最不常用的圖片進行移除,看起來比較合理,但是會有一個問題,就是當圖片快取達到1/8後則圖片所佔的記憶體一直會保持在接近1/8,它沒有自我清理的能力,可能長時間過去了這1/8記憶體裡的有些圖片都不再需要了,它也依然會保留在記憶體裡不會被清除,所以我們可以考慮對快取的圖片做一個有效期的管理,圖片過期後則自動清理一波,這樣可以優化很大一部分記憶體空間。

②由於UIL對於記憶體快取圖片是以“url+targetWidth+targetHeight”作為key,如果我們載入圖片的時候沒有設定targetSize,則框架裡預設會以ImageView的大小作為targetSize,那麼就會出現一種情況,同一張圖片,由於放在大小有輕微差異的ImageView上顯示,則由於targetSize不一樣,會在記憶體中被快取兩份,當然要解決這個問題也很簡單,只要設定denyCacheImageMultipleSizesInMemory()即可避免這種情況,這樣同一張圖片在記憶體裡就只會有一份快取(之前的會被之後的替換掉)。 設定完denyCacheImageMultipleSizesInMemory()後又會出現一個新問題,雖然記憶體裡同一張圖片只有一份了,但這也意味著有輕微差異的ImageView載入的同一張圖片在記憶體裡沒辦法被複用了,每次都要去磁碟快取裡重新載入(磁碟快取是隻以url作為key的)。

那麼如何做到讓有輕微大小差異的ImageView載入同一張圖片時既實現在記憶體快取裡進行復用又不會在記憶體快取裡保留兩份快取呢?

  1. 開啟denyCacheImageMultipleSizesInMemory()避免同一張圖片因為targetSize不同而存在多個記憶體快取
  2. 將有輕微大小差異的ImageView載入圖片時手動設定一樣的targetSize,這樣快取的Key就一致了,就可以實現在記憶體裡進行復用了,而指定一樣的targetSize並不會有什麼風險,因為上面說了,只有你指定的targetSize比圖片實際大小小2倍以上,取樣率才會生效,實際圖片才會被壓縮。

(2)詳情頁的記憶體分析優化

可以看看剛進入詳情頁後會有一個明顯的波峰,通過點選Adnroid Profiler上的紅色圓點來記錄檢視這段波峰裡的記憶體分配。

實踐App記憶體優化:如何有序地做記憶體分析與優化

首先詳情頁依然有大量的圖片,所以對於圖片的大小以及複用上的優化上面已經說了,這裡就不重複說了。

問題1:在記憶體裡發現兩個極少概率出現的empty view,佔用了接近2M的記憶體

優化:用ViewStub對empty view做了懶載入,對於這些沒有馬上用到的資源要做延遲載入,還有很多大概率不會出現的View更加要做懶載入。 -2M

問題2:發現詳情頁的輪播大圖的Viewpager用的Adapter是FragmentPagerAdapter,導致了所有的page都會被儲存,當圖片頁數多的時候,往後翻記憶體會不斷上升。

優化:這種頁數多的ViewPager使用FragmentStatePagerAdapter來替代,它只會保留前後pager,在頁數多的時候可以 節省大量記憶體

問題3:對於一些實在大的圖並且複用頻率並不高的大圖只採用檔案快取就行了,不做記憶體快取。

問題4:我們專案在debug下會列印網路請求的reqeust和response,並且會用String.subString()對較長的response json進行擷取

優化:本身subString()就比較耗記憶體,所以在response較大的時候就會申請大量的記憶體,好在這種情況只會在debug下發生,但是依然需要改進這種列印。

5.監控

記憶體的分析優化並不是一兩個版本的事,而是一個必須每個版本持續進行的工作,這需要一套完善的線上使用者記憶體使用情況監測系統來進行資料上傳、資料分析、資料整理、資料對比,方便我們明確的瞭解每個版本線上App記憶體的具體情況。公司的一套效能監控平臺,可以在這方面給我們App開發人員提供很直觀的監控資料和版本迭代對比。

通過上面我們專案的記憶體分析,可以發現圖片絕對是記憶體中的一塊大頭,所以對於圖片的使用監控就顯得尤為重要,我們自定義了一個簡單的可以監控載入的圖片是否過大的ImageView,可以在debug階段發出警告,方便開發人員及早發現過大的圖片。

當然要做的工作還有很多,比如當我們發現佔用記憶體過高時,可以嘗試來釋放一些靜態的快取,一次來快取記憶體的壓力。

6.總結

這個版本利用了點時間對專案的記憶體佔用做了以上分析以及優化,還需要做的還有很多,之後的版本會繼續跟進,總得來說做記憶體分析和優化還是比較辛苦的,特別是各種記憶體快照的分析以及對程式碼問題的排查,當然時間有限,可能很多地方說的可能也有疏漏或者錯誤,紙上得來終覺淺,絕知此事要躬行,對於效能優化特別記憶體優化這一塊,實踐遠比理論得到的要多。

目前專案裡關於流暢度以及耗電量還沒發現太大的問題,因為每個版本或多或少都會做一些優化,線上也有資料監測,之後還是想整理一下關於卡頓流程度的分析優化以及耗電量的分析優化實踐。

相關文章