Android 效能優化(四)之記憶體優化實戰

頭條祁同偉發表於2017-03-09

在上一篇《Android效能優化(三)之記憶體管理》中我們對Android的記憶體管理有了一定的認識,本篇文章從實際出發對記憶體進行優化,主要包含以下部分:

1. Memory Leak

記憶體洩漏:對於Java來說,就是new出來的Object 放在Heap上無法被GC回收(記憶體中存在無法被回收的物件);記憶體洩漏發生時的主要表現為記憶體抖動,可用記憶體慢慢變少。

1.1 Memory Monitor

AndroidStudio自帶的Memory Monitor可以方便的觀察堆記憶體的分配情況,並且可以粗略的觀察有沒有Memory Leak。

Android 效能優化(四)之記憶體優化實戰
頻繁的記憶體抖動,可能存在記憶體洩漏

  • A:initiate GC 手動觸發GC操作;
  • B:Dump Java Heap 獲取當前的堆疊資訊,生成一個.hprof檔案,AndroidStudip會自動使用HeapViewer開啟;一般用於操作之後檢測記憶體洩漏的情況;
  • C:Start Allocation Tracking 記憶體分配追蹤工具,用於追蹤一段時間的記憶體分配使用情況,能夠知道執行一些列操作後,有哪些物件被分配空間。一般用於追蹤某項操作之後的記憶體分配,調整相關的方法呼叫來優化app效能與記憶體使用;
  • D:剩餘可用記憶體;
  • E:已經使用的記憶體。

點選Memory Monitor的Dump Java Heap,會生成一個.hprof檔案,AndroidStudio會自動使用HeapViewer開啟。

Android 效能優化(四)之記憶體優化實戰
Hprof Viewer開啟.hprof檔案

左皮膚說明:

  • Total Count 該類的例項個數
  • Heap Count 選定的Heap中例項的個數
  • Sizeof 每個例項佔用的記憶體大小
  • Shallow Size 所有該類的例項佔用的記憶體大小
  • Retained Size 該類的所有例項可支配的記憶體大小

右皮膚說明:

  • Instance 該類的所有例項物件(左側Total Count為15,此處就有15個物件)
  • Depth 深度, GC Root點到該例項的最短鏈路數
  • Dominating Size 該例項可支配的記憶體大小

此處可以看出MainActivity存在了15個示例物件,懷疑此處有問題。

1.2 MAT

上述只是可以粗略的看出是不是有問題,而要知道問題出在哪裡就需要藉助MAT了。將生成的.hprof檔案進行轉換,然後使用MAT開啟;

格式轉換命令:hprof-conv 原檔案路徑 轉換後檔案路徑複製程式碼

Android 效能優化(四)之記憶體優化實戰
MAT開啟.hprof

注意下面的Actions:

  • Histogram可以列出記憶體中每個物件的名字、數量以及大小。
  • Dominator Tree會將所有記憶體中的物件按大小進行排序,並且我們可以分析物件之間的引用結構。
    一般使用最多的也是這兩個功能。

Retained Heap表示這個物件以及它所持有的其它引用(包括直接和間接)所佔的總記憶體

  • 使用Histogram:
  1. 點選Histogram並在頂部的Regex中輸入MainActivity會進行正則匹配,會將包含“MainActivity”的所有物件全部列出了出來,其中第一行就是MainActivity的例項。
    Android 效能優化(四)之記憶體優化實戰
  2. 對著想檢視的物件點選右鍵 -> List objects -> with incoming references 檢視具體MainActivity例項。
    Android 效能優化(四)之記憶體優化實戰
  3. 對想要檢視的物件例項點選右鍵-> Path To Gc Roots -> exclude weak reference(排除掉軟引用)。

Android 效能優化(四)之記憶體優化實戰

注意:
this$0前面的圖示的左下角有個圓圈,這代表這個引用可以被Gc Roots引用到,由於MainActivity$LeakClass能被GC Roots訪問到導致其不能被回收,從而它所持有的其它引用也無法被回收了,包括MainActivity,也包括MainActivity中所包含的其它資源。
此時我們就找到了記憶體洩漏的原因。

  • 使用Dominator Tree

Android 效能優化(四)之記憶體優化實戰

使用上面Histogram的操作方式也可以找到洩漏的具體原因,此處不再累述。
注意:每個物件前的圖示的圓圈,並不代表一定是導致記憶體洩漏的原因,有些物件就是需要在記憶體中存活的,需要區別對待。

1.3 LeakCanary

LeakCanary是square出品的一個檢測記憶體洩漏的庫,整合到App之後便無需關心,在發生記憶體洩漏之後會Toast、通知欄彈出等方式提示,可以指出洩漏的引用路徑,而且可以抓取當前的堆疊資訊供詳細分析。

Android 效能優化(四)之記憶體優化實戰

2. Out Of Memory

2.1 Android OOM

Android系統的每個程式都有一個最大記憶體限制,如果申請的記憶體資源超過這個限制,系統就會丟擲OOM錯誤。

  • Android 2.x系統,當dalvik allocated + external allocated + 新分配的大小 >= dalvik heap 最大值時候就會發生OOM。其中bitmap是放於external中 。
  • Android 4.x系統,廢除了external的計數器,類似bitmap的分配改到dalvik的java heap中申請,只要allocated + 新分配的記憶體 >= dalvik heap 最大值的時候就會發生OOM(art執行環境的統計規則還是和dalvik保持一致)

記憶體溢位是程式執行到某一階段的最終結果,直接原因是剩餘的記憶體不能滿足記憶體的申請,但是再分析間接原因記憶體為什麼沒有了:

  • 記憶體洩漏的存在可能導致可用記憶體越來越少;
  • 記憶體申請的峰值超過了系統時間點剩餘的記憶體;(例如:某手機單個程式可用最大記憶體為192M,目前分配記憶體80M,此時申請5M記憶體,但是當前時間點整個系統可用記憶體只有3M,此時沒有超出單個程式可用最大記憶體,但是OOM也會發生)

2.2 Avoid Android OOM

除了避免記憶體洩漏之外,根據《Manage Your App's Memory》,我們可以對記憶體的狀態進行監聽,在Activity中覆寫此方法,根據不同的case進行不同的處理:

    @Override
    public void onTrimMemory(int level) {
        super.onTrimMemory(level);
    }複製程式碼

TRIM_MEMORY_RUNNING_MODERATE:你的應用正在執行並且不會被列為可殺死的。但是裝置此時正執行於低記憶體狀態下,系統開始觸發殺死LRU Cache中的Process的機制。
TRIM_MEMORY_RUNNING_LOW:你的應用正在執行且沒有被列為可殺死的。但是裝置正執行於更低記憶體的狀態下,你應該釋放不用的資源用來提升系統效能。
TRIM_MEMORY_RUNNING_CRITICAL:你的應用仍在執行,但是系統已經把LRU Cache中的大多數程式都已經殺死,因此你應該立即釋放所有非必須的資源。如果系統不能回收到足夠的RAM數量,系統將會清除所有的LRU快取中的程式,並且開始殺死那些之前被認為不應該殺死的程式,例如那個包含了一個執行態Service的程式。
當應用程式退到後臺正在被Cached的時候,可能會接收到從onTrimMemory()中返回的下面的值之一:
TRIM_MEMORY_BACKGROUND: 系統正執行於低記憶體狀態並且你的程式正處於LRU快取名單中最不容易殺掉的位置。儘管你的應用程式並不是處於被殺掉的高危險狀態,系統可能已經開始殺掉LRU快取中的其他程式了。你應該釋放那些容易恢復的資源,以便於你的程式可以保留下來,這樣當使用者回退到你的應用的時候才能夠迅速恢復。
TRIM_MEMORY_MODERATE: 系統正執行於低記憶體狀態並且你的程式已經已經接近LRU名單的中部位置。如果系統開始變得更加記憶體緊張,你的程式是有可能被殺死的。
TRIM_MEMORY_COMPLETE: 系統正執行於低記憶體的狀態並且你的程式正處於LRU名單中最容易被殺掉的位置。你應該釋放任何不影響你的應用恢復狀態的資源。

3. Memory Churn

Memory Churn記憶體抖動:大量的物件被建立又在短時間內馬上被釋放。
瞬間產生大量的物件會嚴重佔用Young Generation的記憶體區域,當達到閥值,剩餘空間不夠的時候,也會觸發GC。系統花費在GC上的時間越多,進行介面繪製或流音訊處理的時間就越短。即使每次分配的物件佔用了很少的記憶體,但是他們疊加在一起會增加Heap的壓力,從而觸發更多其他型別的GC。這個操作有可能會影響到幀率,並使得使用者感知到效能問題。

Android 效能優化(四)之記憶體優化實戰
Drop Frame Occur

常見的可能引發記憶體抖動的情形:

  • 迴圈中建立臨時物件;
  • onDraw中建立Paint或Bitmap物件等;

例如之前使用過的有些下拉重新整理控制元件的實現方式,在onDraw中建立Bitmap等多個臨時大物件會導致記憶體抖動。

4. Bitmap

Bitmap的處理也是Android中的一個難點,當然使用第三方框架的話就遮蔽掉了這個難點。

  • Bitmap的記憶體模型
  • Bitmap的載入、壓縮、快取等策略
  • 版本的相容等

關於Bitmap之後會寫專門的一篇文章來介紹,此處可以參考《Handling Bitmaps》

5. Program Advice

5.1 節制地使用Service

記憶體管理最大的錯誤之一就是讓Service一直執行。在後臺使用service時,除非它需要被觸發並執行一個任務,否則其他時候Service都應該是停止狀態。另外需要注意Service工作完畢之後需要被停止,以免造成記憶體洩漏。

系統會傾向於保留有Service所在的程式,這使得程式的執行代價很高,因為系統沒有辦法把Service所佔用的RAM空間騰出來讓給其他元件,另外Service還不能被Paged out。這減少了系統能夠存放到LRU快取當中的程式數量,它會影響應用之間的切換效率,甚至會導致系統記憶體使用不穩定,從而無法繼續保持住所有目前正在執行的service。

建議使用JobScheduler,而儘量避免使用永續性的Service。還有建議使用IntentService,它會在處理完交代給它的任務之後儘快結束自己。

5.2 使用優化過的集合

Android API當中提供了一些優化過後的資料集合工具類,如SparseArray,SparseBooleanArray,以及LongSparseArray等,使用這些API可以讓我們的程式更加高效。傳統Java API中提供的HashMap工具類會相對比較低效,因為它需要為每一個鍵值對都提供一個物件入口,而SparseArray就避免掉了基本資料型別轉換成物件資料型別的時間。

5.3 謹慎對待面向抽象

開發者經常把抽象作為好的程式設計實踐,因為抽象能夠提升程式碼的靈活性與可維護性。然而,抽象會導致一個顯著的開銷:面向抽象需要額外的程式碼(不會被執行到),同樣會被諮對映到記憶體中,耗費了更多的時間以及記憶體空間。因此如果面向抽象對你的程式碼沒有顯著的收益,那你應該避免使用。

例如:使用列舉通常會比使用靜態常量要消耗兩倍以上的記憶體,在Android開發當中我們應當儘可能地不使用列舉。

5.4 使用nano protobufs序列化資料

Protocol buffers是Google為序列化資料設計的一種語言無關、平臺無關、具有良好擴充套件性的資料描述語言,與XML類似,但是更加輕量、快速、簡單。如果使用protobufs來實現資料的序列化及反序列化,建議在客戶端使用nano protobufs,因為通常的protobufs會生成冗餘程式碼,會導致可用記憶體減少,Apk體積變大,執行速度減慢。

5.5 避免記憶體抖動

垃圾回收通常不會影響應用的表現,但是短時間內多次的垃圾回收會消耗掉介面繪製的時間。系統花費在GC上的時間越多,進行介面繪製或流音訊處理的時間就越短。通常記憶體抖動會導致多次的GC,實踐中記憶體抖動代表了一段時間內分配了臨時物件。

例如:在For迴圈中分配了多個臨時物件,或在onDraw()方法中建立了Paint、Bitmap物件,應用產生了大量的物件;這會很快耗盡young generation的可用記憶體,導致GC發生。

使用Analyze your RAM usage中的工具找出程式碼裡記憶體抖動的地方。考慮把操作移出內部迴圈,或者將其移動到基於工廠的分配結構中。

5.6 移除消耗記憶體的庫、縮減Apk的大小

檢視Apk的大小,包括三方庫和內嵌的資源,這些都會影響應用消耗的記憶體。通過減少冗餘、非必須或大的元件、庫、圖片、資源、動畫等,都可以改善應用的記憶體消耗。

5.7 使用Dagger 2進行依賴注入

如果您打算在應用程式中使用依賴注入框架,請考慮使用Dagger 2。 Dagger不使用反射來掃描應用程式的程式碼。 Dagger的編譯時註解技術實現意味著它不需要不必要的執行時成本。而使用反射的其它依賴注入框架通常通過掃描程式碼來初始化過程。 此過程可能需要顯著更多的CPU週期和RAM,並可能導致應用程式啟動時明顯的卡頓。

備註:之前的文件是不建議使用依賴注入框架,因為實現原理是使用反射,而進化為編譯時註解之後,就不再有反射帶來的影響了。

5.8 謹慎使用第三方庫

很多開源的library程式碼都不是為移動端而編寫的,如果運用在移動裝置上,並不一定適合。即使是針對Android而設計的library,也需要特別謹慎,特別是在你不知道引入的library具體做了什麼事情的時候。例如,其中一個library使用的是nano protobufs, 而另外一個使用的是micro protobufs。這樣一來,在你的應用裡面就有2種protobuf的實現方式。這樣類似的衝突還可能發生在輸出日誌,載入圖片,快取等等模組裡面。另外不要為了1個或者2個功能而匯入整個library,如果沒有一個合適的庫與你的需求相吻合,你應該考慮自己去實現,而不是匯入一個大而全的解決方案。

6. Other

6.1 謹慎使用LargeHeap屬性

可以通過在manifest的application標籤下新增largeHeap=true的屬性來為應用宣告一個更大的heap空間(可以通過getLargeMemoryClass()來獲取到這個更大的heap size閾值)。然而,宣告得到更大Heap閾值的本意是為了一小部分會消耗大量RAM的應用(例如一個大圖片的編輯應用)。不要輕易的因為你需要使用更多的記憶體而去請求一個大的Heap Size。只有當你清楚的知道哪裡會使用大量的記憶體並且知道為什麼這些記憶體必須被保留時才去使用large heap,使用額外的記憶體空間會影響系統整體的使用者體驗,並且會使得每次gc的執行時間更長。在任務切換時,系統的效能會大打折扣。另外, large heap並不一定能夠獲取到更大的heap。在某些有嚴格限制的機器上,large heap的大小和通常的heap size是一樣的。

6.2 謹慎使用多程式

多程式確實是一種可以幫助我們節省和管理記憶體的高階技巧。如果你要使用它的話一定要謹慎使用,因為絕大多數的應用程式都不應該在多個程式當中執行的,一旦使用不當,它甚至會增加額外的記憶體而不是幫我們節省記憶體;同時需要知曉多程式帶來的缺點。這個技巧比較適用於那些需要在後臺去完成一項獨立的任務,和前臺的功能是可以完全區分開的場景。

這裡舉一個比較適合去使用多程式技巧的場景,比如說我們正在做一個音樂播放器軟體,其中播放音樂的功能應該是一個獨立的功能,它不需要和UI方面有任何關係,即使軟體已經關閉了也應該可以正常播放音樂。如果此時我們只使用一個程式,那麼即使使用者關閉了軟體,已經完全由Service來控制音樂播放了,系統仍然會將許多UI方面的記憶體進行保留。在這種場景下就非常適合使用兩個程式,一個用於UI展示,另一個則用於在後臺持續地播放音樂。

6.3 實現方式可能存在的問題:例如啟動頁閃屏圖,show完畢之後應該釋放掉Bitmap。

一些實現方式看起來沒有問題實現了功能但是實際上可能對記憶體造成了影響。我在使用Heap Viewer檢視Bitmap物件時發現了一張只需下載不應該被載入的圖。

Android 效能優化(四)之記憶體優化實戰
使用HeapViewer可直接檢視Bitmap

Android 效能優化(四)之記憶體優化實戰
記憶體中出現的不應該被載入的圖

通過查閱程式碼,發現問題出在:此處下載圖片作為另一個模組的使用圖,但是下載的方法竟然是使用圖片載入器載入出來Bitmap然後再儲存到本地;而且儲存之後也沒有將Bitmap物件釋放掉。

與之類似的還有:首頁閃屏圖展示之後,Bitmap物件應該及時釋放掉。

6.4 使用try catch進行捕獲

對高風險OOM程式碼塊如展示高清大圖等進行try catch,在catch塊載入非高清的圖片並做相應記憶體回收的處理。注意OOM是OutOfMemoryError,不能使用Exception進行捕獲。

7. Summary

記憶體優化的套路:

  1. 解決所有的記憶體洩漏

    • 整合LeakCanary,可以方便的定位出90%的記憶體洩漏問題;
    • 通過反覆進出可疑介面,觀察記憶體增減的情況,Dump Java Heap獲取當前堆疊資訊使用MAT進行分析。
    • 記憶體洩漏的常見情形可參照《Android 記憶體洩漏分析心得》
  2. 避免記憶體抖動

    • 避免在迴圈中建立臨時物件;
    • 避免在onDraw中建立Paint、Bitmap物件等。
  3. Bitmap的使用

    • 使用三方庫載入圖片一般不會出記憶體問題,但是需要注意圖片使用完畢的釋放,而不是被動等待釋放。
  4. 使用優化過的資料結構

  5. 使用onTrimMemory根據不同的記憶體狀態做相應處理
  6. Library的使用
    • 去掉無用的Library,對生成的Apk進行反編譯檢視使用到的Library,避免出現無用的Lib仍然被打進Apk;
    • 避免引入巨大的Library;
    • 使用Proguard進行混淆、壓縮。

參考:

歡迎關注微信公眾號:定期分享Java、Android乾貨!

Android 效能優化(四)之記憶體優化實戰
歡迎關注

相關文章