實踐App內存優化:如何有序地做內存分析與優化

由於項目裏之前線上版本出現過一定比例的OOM,雖然比例並不大,但是還是暴露了一定的問題,所以打算對我們App分爲幾個步驟進行內存分析和優化,當然內存的優化是個長期的過程,不是一兩個版本的事,每個版本都需要收集線上內存數據進行監控以及分析。

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

本文主要會根據實際項目中優化步驟分爲以下幾部分:

  1. Android內存分析基礎
  2. 內存泄漏
  3. 靜態內存分析優化
  4. 運行時內存分析優化
  5. 監控

1.Android內存分析基礎

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

一.App的內存使用情況概覽

每個App進程可以分配到的最大內存是有限的,當然不同手機每個App進程可以分配到的最大內存有可能不一樣,可以通過以下命令進行查看:

adb shell getprop | grep dalvik.vm.heapsize

我們可以輸出我們App的內存使用情況概覽:

adb shell dumpsys meminfo 包名

我們就可以看到:

Pss: 該進程獨佔的內存+與其他進程共享的內存(按比例分配,比如與其他3個進程共享9K內存,則這部分爲3K)

Privete Dirty:該進程獨享內存

Heap Size:分配的內存

Heap Alloc:已使用的內存

Heap Free:空閒內存

二、Android Profiler

AndroidStduio3.0後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出的內存快照,進行分析後製圖如下:

通過對靜態內存數據的分析,主要發現了以下幾個問題:

問題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上的紅色圓點來記錄查看這段波峯裏的內存分配。

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

問題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.總結

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

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

學習分享,共勉

題外話,我從事Android開發已經五年了,此前我指導過不少同行。但很少跟大家一起探討,正好最近我花了一個多月的時間整理出來一份包括不限於高級UI、性能優化、移動架構師、NDK、混合式開發(ReactNative+Weex)微信小程序、Flutter等全方面的Android進階實踐技術,今天暫且開放給有需要的人,若有關於此方面可以轉發+關注+點贊後加羣 878873098 領取,或者評論與我一起交流探討。

資料免費領取方式:轉發+關注+點贊後,加入點擊鏈接加入羣聊:Android高級開發交流羣(878873098)即可獲取免費領取方式!

重要的事說三遍,關注!關注!關注!

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