Android效能優化典範(三)
Android效能優化典範的課程最近更新到第三季了,這次一共12個短視訊課程,包括的內容大致有:更高效的ArrayMap容器,使用Android系統提供的特殊容器來避免自動裝箱,避免使用列舉型別,注意onLowMemory與onTrimMemory的回撥,避免記憶體洩漏,高效的位置更新操作,重複layout操作的效能影響,以及使用Batching,Prefetching優化網路請求,壓縮傳輸資料等等使用技巧。下面是對這些課程的總結摘要,認知有限,理解偏差的地方請多多交流指正!
1) Fun with ArrayMaps
程式記憶體的管理是否合理高效對應用的效能有著很大的影響,有的時候對容器的使用不當也會導致記憶體管理效率低下。Android為移動作業系統特意編寫了一些更加高效的容器,例如SparseArray,今天要介紹的是一個新的容器,叫做
我們經常會使用到HashMap這個容器,它非常好用,但是卻很佔用記憶體。下圖演示了HashMap的簡要工作原理:
為了解決HashMap更佔記憶體的弊端,Android提供了記憶體效率更高的ArrayMap。它內部使用兩個陣列進行工作,其中一個數組記錄key hash過後的順序列表,另外一個數組按key的順序記錄Key-Value值,如下圖所示:
當你想獲取某個value的時候,ArrayMap會計算輸入key轉換過後的hash值,然後對hash陣列使用二分查詢法尋找到對應的index,然後我們可以通過這個index在另外一個數組中直接訪問到需要的鍵值對。如果在第二個陣列鍵值對中的key和前面輸入的查詢key不一致,那麼就認為是發生了碰撞衝突。為了解決這個問題,我們會以該key為中心點,分別上下展開,逐個去對比查詢,直到找到匹配的值。如下圖所示:
隨著陣列中的物件越來越多,查詢訪問單個物件的花費也會跟著增長,這是在記憶體佔用與訪問時間之間做權衡交換。
既然ArrayMap中的記憶體佔用是連續不間斷的,那麼它是如何處理插入與刪除操作的呢?請看下圖所示,演示了Array的特性:
很明顯,ArrayMap的插入與刪除的效率是不夠高的,但是如果陣列的列表只是在一百這個數量級上,則完全不用擔心這些插入與刪除的效率問題。HashMap與ArrayMap之間的記憶體佔用效率對比圖如下:
與HashMap相比,ArrayMap在迴圈遍歷的時候也更加簡單高效,如下圖所示:
前面演示了很多ArrayMap的優點,但並不是所有情況下都適合使用ArrayMap,我們應該在滿足下面2個條件的時候才考慮使用ArrayMap:
-
物件個數的數量級最好是千以內;
-
資料組織形式包含Map結構。
我們需要學會在特定情形下選擇相對更加高效的實現方式。
2) Beware Autoboxing
有時候效能問題也可能是因為那些不起眼的小細節引起的,例如在程式碼中不經意的“自動裝箱”。我們知道基礎資料型別的大小:boolean(8 bits), int(32 bits), float(32 bits),long(64 bits),為了能夠讓這些基礎資料型別在大多數Java容器中運作,會需要做一個autoboxing的操作,轉換成Boolean,Integer,Float等物件,如下演示了迴圈操作的時候是否發生autoboxing行為的差異:
Autoboxing的行為還經常發生在類似HashMap這樣的容器裡面,對HashMap的增刪改查操作都會發生了大量的autoboxing的行為。
為了避免這些autoboxing帶來的效率問題,Android特地提供了一些如下的Map容器用來替代HashMap,不僅避免了autoboxing,還減少了記憶體佔用:
3) SparseArray Family Ties
為了避免HashMap的autoboxing行為,Android系統提供了SparseBoolMap,SparseIntMap,SparseLongMap,LongSparseMap等容器。關於這些容器的基本原理請參考前面的ArrayMap的介紹,另外這些容器的使用場景也和ArrayMap一致,需要滿足數量級在千以內,資料組織形式需要包含Map結構。
4) The price of ENUMs
在StackOverFlow等問答社群常常出現關於在Android系統裡面使用列舉型別的效能討論,關於這一點,Android官方的Training課程裡面有下面這樣一句話:
Enums often require more than twice as much memory as static constants. You should strictly avoid using enums on Android.
關於enum的效率,請看下面的討論。假設我們有這樣一份程式碼,編譯之後的dex大小是2556 bytes,在此基礎之上,新增一些如下程式碼,這些程式碼使用普通static常量相關作為判斷值:
增加上面那段程式碼之後,編譯成dex的大小是2680 bytes,相比起之前的2556 bytes只增加124 bytes。假如換做使用enum,情況如下:
使用enum之後的dex大小是4188 bytes,相比起2556增加了1632 bytes,增長量是使用static int的13倍。不僅僅如此,使用enum,執行時還會產生額外的記憶體佔用,如下圖所示:
Android官方強烈建議不要在Android程式裡面使用到enum。
5) Trimming and Sharing Memory
Android系統的一大特色是多工,使用者可以隨意在不同的app之間進行快速切換。為了確保你的應用在這種複雜的多工環境中正常執行,我們需要了解下面的知識。
為了讓background的應用能夠迅速的切換到forground,每一個background的應用都會佔用一定的記憶體。Android系統會根據當前的系統記憶體使用情況,決定回收部分background的應用記憶體。如果background的應用從暫停狀態直接被恢復到forground,能夠獲得較快的恢復體驗,如果background應用是從Kill的狀態進行恢復,就會顯得稍微有點慢。
Android系統提供了一些回撥來通知應用的記憶體使用情況,通常來說,當所有的background應用都被kill掉的時候,forground應用會收到onLowMemory()的回撥。在這種情況下,需要儘快釋放當前應用的非必須記憶體資源,從而確保系統能夠穩定繼續執行。Android系統還提供了onTrimMemory()的回撥,當系統記憶體達到某些條件的時候,所有正在執行的應用都會收到這個回撥,同時在這個回撥裡面會傳遞以下的引數,代表不同的記憶體使用情況,下圖介紹了各種不同的回撥引數:
關於每個引數的更多介紹,請參考《 Android Training - 管理應用的記憶體》,另外onTrimMemory()的回撥可以發生在Application,Activity,Fragment,Service,Content Provider。
從Android 4.4開始,ActivityManager提供了isLowRamDevice()的API,通常指的是Heap Size低於512M或者螢幕大小<=800*480的裝置。
6) DO NOT LEAK VIEWS
記憶體洩漏的概念,下面一張圖演示下:
通常來說,View會保持Activity的引用,Activity同時還和其他內部物件也有可能保持引用關係。當螢幕發生旋轉的時候,activity很容易發生洩漏,這樣的話,裡面的view也會發生洩漏。Activity以及view的洩漏是非常嚴重的,為了避免出現洩漏,請特別留意以下的規則:
6.1) 避免使用非同步回撥
非同步回撥被執行的時間不確定,很有可能發生在activity已經被銷燬之後,這不僅僅很容易引起crash,還很容易發生記憶體洩露。
6.2) 避免使用Static物件
因為static的生命週期過長,使用不當很可能導致leak,在Android中應該儘量避免使用static物件。
6.3) 避免把View新增到沒有清除機制的容器裡面
假如把view新增到 WeekHashMap,如果沒有執行清除操作,很可能會導致洩漏。
7) Location & Battery Drain
開啟定位功能是一個相對來說比較耗電的操作,通常來說,我們會使用類似下面這樣的程式碼來發出定位請求:
上面演示中有一個方法是setInterval()指的意思是每隔多長的時間獲取一次位置更新,時間相隔越短,自然花費的電量就越多,但是時間相隔太長,又無法及時獲取到更新的位置資訊。其中存在的一個優化點是,我們可以通過判斷返回的位置資訊是否相同,從而決定設定下次的更新間隔是否增加一倍,通過這種方式可以減少電量的消耗,如下圖所示:
在位置請求的演示程式碼中還有一個方法是setFastestInterval(),因為整個系統中很可能存在其他的應用也在請求位置更新,那些應用很有可能設定的更新間隔時間很短,這種情況下,我們就可以通過setFestestInterval的方法來過濾那些過於頻繁的更新。
通過GPS定位服務相比起使用網路進行定位更加的耗電,但是也相對更加精準一些,他們的圖示關係如下:
為了提供不同精度的定位需求,同時遮蔽實現位置請求的細節,Android提供了下面4種不同精度與耗電量的引數給應用進行設定呼叫,應用只需要決定在適當的場景下使用對應的引數就好了,通過LocationRequest.setPriority()方法傳遞下面的引數就好了。
8) Double Layout Taxation
佈局中的任何一個View一旦發生一些屬性變化,都可能引起很大的連鎖反應。例如某個button的大小突然增加一倍,有可能會導致兄弟檢視的位置變化,也有可能導致父檢視的大小發生改變。當大量的layout()操作被頻繁呼叫執行的時候,就很可能引起丟幀的現象。
例如,在RelativeLayout中,我們通常會定義一些類似alignTop,alignBelow等等屬性,如圖所示:
為了獲得檢視的準確位置,需要經過下面幾個階段。首先子檢視會觸發計算自身位置的操作,然後RelativeLayout使用前面計算出來的位置資訊做邊界的調整的操作,如下面兩張圖所示:
經歷過上面2個步驟,relativeLayout會立即觸發第二次layout()的操作來確定所有子檢視的最終位置與大小資訊。
除了RelativeLayout會發生兩次layout操作之外,LinearLayout也有可能觸發兩次layout操作,通常情況下LinearLayout只會發生一次layout操作,可是一旦呼叫了measureWithLargetChild()方法就會導致觸發兩次layout的操作。另外,通常來說,GridLayout會自動預處理子檢視的關係來避免兩次layout,可是如果GridLayout裡面的某些子檢視使用了weight等複雜的屬性,還是會導致重複的layout操作。
如果只是少量的重複layout本身並不會引起嚴重的效能問題,但是如果它們發生在佈局的根節點,或者是ListView裡面的某個ListItem,這樣就會引起比較嚴重的效能問題。如下圖所示:
我們可以使用Systrace來跟蹤特定的某段操作,如果發現了疑似丟幀的現象,可能就是因為重複layout引起的。通常我們無法避免重複layout,在這種情況下,我們應該儘量保持View Hierarchy的層級比較淺,這樣即使發生重複layout,也不會因為佈局的層級比較深而增大了重複layout的倍數。另外還有一點需要特別注意,在任何時候都請避免呼叫requestLayout()的方法,因為一旦呼叫了requestLayout,會導致該layout的所有父節點都發生重新layout的操作。
9) Network Performance 101
在效能優化第一季與第二季的課程裡面都介紹過,網路請求的操作是非常耗電的,其中在移動蜂窩網路情況下執行網路資料的請求則尤其比較耗電。關於如何減少行動網路下的網路請求的耗電量,有兩個重要的原則需要遵守:第一個是減少行動網路被啟用的時間與次數,第二個是壓縮傳輸資料。
9.1) 減少行動網路被啟用的時間與次數
通常來說,發生網路行為可以劃分為如下圖所示的三種類型,一個是使用者主動觸發的請求,另外被動接收伺服器的返回資料,最後一個是資料上報,行為上報,位置更新等等自定義的後臺操作。
我們絕對堅決肯定不應該使用Polling(輪詢)的方式去執行網路請求,這樣不僅僅會造成嚴重的電量消耗,還會浪費許多網路流量,例如:
Android官方推薦使用Google Cloud Messaging(在大陸,然並卵),這個框架會幫助把更新的資料推送給手機客戶端,效率極高!我們應該遵循下面的規則來處理資料同步的問題:
首先,我們應該使用回退機制來避免固定頻繁的同步請求,例如,在發現返回資料相同的情況下,推遲下次的請求時間,如下圖所示:
其次,我們還可以使用Batching(批處理)的方式來集中發出請求,避免頻繁的間隔請求,如下圖所示:
最後,我們還可以使用Prefetching(預取)的技術提前把一些資料拿到,避免後面頻繁再次發起網路請求,如下圖所示:
Google Play Service中提供了一個叫做 GCMNetworkManager的類來幫助我們實現上面的那些功能,我們只需要呼叫對應的API,設定一些簡單的引數,其餘的工作就都交給Google來幫我們實現了。
9.2) 壓縮傳輸資料
關於壓縮傳輸資料,我們可以學習以下的一些課程(真的夠喝好幾壺了):
10) Effective Network Batching
在效能優化課程的第一季與第二季裡面,我們都有提到過下面這樣一個網路請求與電量消耗的示意圖:
發起網路請求與接收返回資料都是比較耗電的,在網路硬體模組被啟用之後,會繼續保持幾十秒的電量消耗,直到沒有新的網路操作行為之後,才會進入休眠狀態。前面一個段落介紹了使用Batching的技術來捆綁網路請求,從而達到減少網路請求的頻率。那麼如何實現Batching技術呢?通常來說,我們可以會把那些發出的網路請求,先暫存到一個PendingQueue裡面,等到條件合適的時候再觸發Queue裡面的網路請求。
可是什麼時候才算是條件合適了呢?最簡單粗暴的,例如我們可以在Queue大小到10的時候觸發任務,也可以是當手機開始充電,或者是手機連線到WiFi等情況下才觸發佇列中的任務。手動編寫程式碼去實現這些功能會比較複雜繁瑣,Google為了解決這個問題,為我們提供了GCMNetworkManager來幫助實現那些功能,僅僅只需要呼叫API,設定觸發條件,然後就OK了。
11) Optimizing Network Request Frequencies
前面的段落已經提到了應該減少網路請求的頻率,這是為了減少電量的消耗。我們可以使用Batching,Prefetching的技術來避免頻繁的網路請求。Google提供了GCMNetworkManager來幫助開發者實現那些功能,通過提供的API,我們可以選擇在接入WiFi,開始充電,等待行動網路被啟用等條件下再次啟用網路請求。
12) Effective Prefetching
假設我們有這樣的一個場景,最開始網路請求了一張圖片,隔了10秒需要請求另外一張圖片,再隔6秒會請求第三張圖片,如下圖所示:
類似上面的情況會頻繁觸發網路請求,但是如果我們能夠預先請求後續可能會使用到網路資源,避免頻繁的觸發網路請求,這樣就能夠顯著的減少電量的消耗。可是預先獲取多少資料量是很值得考量的,因為如果預取資料量偏少,就起不到減少頻繁請求的作用,可是如果預取資料過多,就會造成資源的浪費。
我們可以參考在WiFi,4G,3G等不同的網路下設計不同大小的預取資料量,也可以是按照圖片數量或者操作時間來作為閥值。這需要我們需要根據特定的場景,不同的網路情況設計合適的方案。