由于項目里之前線上版本出現過一定比例的OOM,雖然比例并不大,但是還是暴露了一定的問題,所以打算對我們App分為幾個步驟進行內存分析和優化,當然內存的優化是個長期的過程,不是一兩個版本的事,每個版本都需要收集線上內存數據進行監控以及分析。
版本迭代過程中,內存增長過快,不僅會導致一定概率的OOM,運行時若出現內存抖動,導致頻繁GC,則會對App的流暢度以及用戶體驗造成很大影響。
本文主要會根據實際項目中優化步驟分為以下幾部分:
- Android內存分析基礎
- 內存泄漏
- 靜態內存分析優化
- 運行時內存分析優化
- 監控
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變得比之前更強大,內存分析頁變得更加直觀更加方便,下面是截圖:
進程占用總內存
-
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加載同一張圖片時既實現在內存緩存里進行復用又不會在內存緩存里保留兩份緩存呢?
- 開啟
denyCacheImageMultipleSizesInMemory()
避免同一張圖片因為targetSize不同而存在多個內存緩存 - 將有輕微大小差異的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.總結
這個版本利用了點時間對項目的內存占用做了以上分析以及優化,還需要做的還有很多,之后的版本會繼續跟進,總得來說做內存分析和優化還是比較辛苦的,特別是各種內存快照的分析以及對代碼問題的排查,當然時間有限,可能很多地方說的可能也有疏漏或者錯誤,紙上得來終覺淺,絕知此事要躬行,對于性能優化特別內存優化這一塊,實踐遠比理論得到的要多。
目前項目里關于流暢度以及耗電量還沒發現太大的問題,因為每個版本或多或少都會做一些優化,線上也有數據監測,之后還是想整理一下關于卡頓流程度的分析優化
以及耗電量的分析優化
實踐。