1. 程式人生 > >Android效能優化典範(五)

Android效能優化典範(五)

這是Android效能優化典範第5季的課程學習筆記,拖拖拉拉很久,記錄分享給大家,請多多包涵、擔待指正!文章共有10個段落,涉及的內容有:多執行緒併發的效能問題,介紹了AsyncTask、HandlerThread、IntentService與ThreadPool分別適合的使用場景以及各自的使用注意事項。這是一篇瞭解Android多執行緒程式設計不可多得的基礎文章,清楚地瞭解這些Android系統提供的多執行緒基礎元件之間的差異以及優缺點,才能夠在專案實戰中做出最恰當的選擇。

1) Threading Performance

在程式開發的實踐當中,為了讓程式表現得更加流暢,我們肯定會需要使用到多執行緒來提升程式的併發執行效能。但是編寫多執行緒併發的程式碼一直以來都是一個相對棘手的問題,所以想要獲得更佳的程式效能,我們非常有必要掌握多執行緒併發程式設計的基礎技能。

眾所周知,Android程式的大多數程式碼操作都必須執行在主執行緒,例如系統事件(例如裝置螢幕發生旋轉),輸入事件(例如使用者點選滑動等),程式回撥服務,UI繪製以及鬧鐘事件等等。那麼我們在上述事件或者方法中插入的程式碼也將執行在主執行緒。

android_perf_5_threading_main_thread

一旦我們在主執行緒裡面添加了操作複雜的程式碼,這些程式碼就很可能阻礙主執行緒去響應點選/滑動事件,阻礙主執行緒的UI繪製等等。我們知道,為了讓螢幕的重新整理幀率達到60fps,需要確保16ms內完成單次重新整理的操作。一旦我們在主執行緒裡面執行的任務過於繁重就可能導致接收到重新整理訊號的時候,會因為資源被佔用而無法完成這次重新整理操作。由此導致產生掉幀的現象,重新整理幀率自然也就跟著下降了(一旦重新整理幀率降到20fps左右,使用者就可以明顯感知到卡頓不流暢了)。

android_perf_5_threading_dropframe

為了避免上面提到的掉幀問題,我們需要使用多執行緒的技術方案,把那些操作複雜的任務移動到其他執行緒當中執行,這樣就不容易阻塞主執行緒的操作,也就減小了出現掉幀的可能性。

android_perf_5_threading_workthread

那麼問題來了,為主執行緒減輕負的多執行緒方案有哪些呢?這些方案分別適合在什麼場景下使用?Android系統為我們提供了若干組工具類來幫助解決這個問題。

  • AsyncTask: 為UI執行緒與工作執行緒之間進行快速的切換提供一種簡單便捷的機制。適用於當下立即需要啟動,但是非同步執行的生命週期短暫的使用場景。
  • HandlerThread: 為某些回撥方法或者等待某些任務的執行設定一個專屬的執行緒,並提供執行緒任務的排程機制。
  • ThreadPool: 把任務分解成不同的單元,分發到各個不同的執行緒上,進行同時併發處理。
  • IntentService: 適合於執行由UI觸發的後臺Service任務,並可以把後臺任務執行的情況通過一定的機制反饋給UI。

瞭解這些系統提供的多執行緒工具類分別適合在什麼場景下,可以幫助我們選擇合適的解決方案,避免出現不可預期的麻煩。雖然使用多執行緒可以提高程式的併發量,但是我們需要特別注意因為引入多執行緒而可能伴隨而來的記憶體問題。舉個例子,在Activity內部定義的一個AsyncTask,它屬於一個內部類,該類本身和外面的Activity是有引用關係的,如果Activity要銷燬的時候,AsyncTask還仍然在執行,這會導致Activity沒有辦法完全釋放,從而引發記憶體洩漏。所以說,多執行緒是提升程式效能的有效手段之一,但是使用多執行緒卻需要十分謹慎小心,如果不瞭解背後的執行機制以及使用的注意事項,很可能引起嚴重的問題。

2) Understanding Android Threading

通常來說,一個執行緒需要經歷三個生命階段:開始、執行、結束。執行緒會在任務執行完畢之後結束,那麼為了確保執行緒的存活,我們會在執行階段給執行緒賦予不同的任務,然後在裡面新增退出的條件從而確保任務能夠執行完畢後退出。

android_perf_5_thread_lifecycle

在很多時候,執行緒不僅僅是線性執行一系列的任務就結束那麼簡單的,我們會需要增加一個任務佇列,讓執行緒不斷的從任務佇列中獲取任務去進行執行,另外我們還可能線上程執行的任務過程中與其他的執行緒進行協作。如果這些細節都交給我們自己來處理,這將會是件極其繁瑣又容易出錯的事情。

android_perf_5_thread_thread

所幸的是,Android系統為我們提供了Looper、Handler、MessageQueue來幫助實現上面的執行緒任務模型:

Looper: 能夠確保執行緒持續存活並且可以不斷的從任務佇列中獲取任務並進行執行。

android_perf_5_thread_looper

Handler: 能夠幫助實現佇列任務的管理,不僅僅能夠把任務插入到佇列的頭部,尾部,還可以按照一定的時間延遲來確保任務從佇列中能夠來得及被取消掉。

android_perf_5_thread_handler

MessageQueue: 使用Intent,Message,Runnable作為任務的載體在不同的執行緒之間進行傳遞。

android_perf_5_thread_messagequeue

把上面三個元件打包到一起進行協作,這就是HandlerThread

android_perf_5_thread_handlerthread

我們知道,當程式被啟動,系統會幫忙建立程序以及相應的主執行緒,而這個主執行緒其實就是一個HandlerThread。這個主執行緒會需要處理系統事件、輸入事件、系統回撥、UI繪製等等任務,為了避免主執行緒任務過重,我們就會需要不斷地開啟新的工作執行緒來處理那些子任務。

3) Memory & Threading

增加併發的執行緒數會導致記憶體消耗的增加,平衡好這兩者的關係是非常重要的。我們知道,多執行緒併發訪問同一塊記憶體區域有可能帶來很多問題,例如讀寫的許可權爭奪問題,ABA問題等等。為了解決這些問題,我們會需要引入的概念。

在Android系統中,也無法避免因為多執行緒的引入,而導致出現諸如上文提到的種種問題。Android UI物件的建立、更新、銷燬等等操作都預設是執行在主執行緒,但是如果我們在非主執行緒對UI物件進行操作,程式將可能出現異常甚至是崩潰。

android_perf_5_memory_thread_update

另外,在非UI執行緒中直接持有UI物件的引用也很可能出現問題。例如Work執行緒中持有某個UI物件的引用,在Work執行緒執行完畢之前,UI物件在主執行緒中被從ViewHierarchy中移除了,這個時候UI物件的任何屬性都已經不再可用了,另外對這個UI物件的更新操作也都沒有任何意義了,因為它已經從ViewHierarchy中被移除,不再繪製到畫面上了。

android_perf_5_memory_view_remove

不僅如此,View物件本身對所屬的Activity是有引用關係的,如果工作執行緒持續保有View的引用,這就可能導致Activity無法完全釋放。除了直接顯式的引用關係可能導致記憶體洩露之外,我們還需要特別留意隱式的引用關係也可能導致洩露。例如通常我們會看到在Activity裡面定義的一個AsyncTask,這種型別的AsyncTask與外部的Activity是存在隱式引用關係的,只要Task沒有結束,引用關係就會一直存在,這很容易導致Activity的洩漏。更糟糕的情況是,它不僅僅發生了記憶體洩漏,還可能導致程式異常或者崩潰。

android_perf_5_memory_asynctask

為了解決上面的問題,我們需要謹記的原則就是:不要在任何非UI執行緒裡面去持有UI物件的引用。系統為了確保所有的UI物件都只會被UI執行緒所進行建立,更新,銷燬的操作,特地設計了對應的工作機制(當Activity被銷燬的時候,由該Activity所觸發的非UI執行緒都將無法對UI物件進行操作,否者就會丟擲程式執行異常的錯誤)來防止UI物件被錯誤的使用。

4) Good AsyncTask Hunting

AsyncTask是一個讓人既愛又恨的元件,它提供了一種簡便的非同步處理機制,但是它又同時引入了一些令人厭惡的麻煩。一旦對AsyncTask使用不當,很可能對程式的效能帶來負面影響,同時還可能導致記憶體洩露。

舉個例子,常遇到的一個典型的使用場景:使用者切換到某個介面,觸發了介面上的圖片的載入操作,因為圖片的載入相對來說耗時比較長,我們需要在子執行緒中處理圖片的載入,當圖片在子執行緒中處理完成之後,再把處理好的圖片返回給主執行緒,交給UI更新到畫面上。

android_perf_5_asynctask_main

AsyncTask的出現就是為了快速的實現上面的使用場景,AsyncTask把在主執行緒裡面的準備工作放到onPreExecute()方法裡面進行執行,doInBackground()方法執行在工作執行緒中,用來處理那些繁重的任務,一旦任務執行完畢,就會呼叫onPostExecute()方法返回到主執行緒。

android_perf_5_asynctask_mode

使用AsyncTask需要注意的問題有哪些呢?請關注以下幾點:

  • 首先,預設情況下,所有的AsyncTask任務都是被線性排程執行的,他們處在同一個任務隊列當中,按順序逐個執行。假設你按照順序啟動20個AsyncTask,一旦其中的某個AsyncTask執行時間過長,佇列中的其他剩餘AsyncTask都處於阻塞狀態,必須等到該任務執行完畢之後才能夠有機會執行下一個任務。情況如下圖所示:

android_perf_5_asynctask_single_queue

為了解決上面提到的線性佇列等待的問題,我們可以使用AsyncTask.executeOnExecutor()強制指定AsyncTask使用執行緒池併發排程任務。

android_perf_5_asynctask_thread_pool

  • 其次,如何才能夠真正的取消一個AsyncTask的執行呢?我們知道AsyncTaks有提供cancel()的方法,但是這個方法實際上做了什麼事情呢?執行緒本身並不具備中止正在執行的程式碼的能力,為了能夠讓一個執行緒更早的被銷燬,我們需要在doInBackground()的程式碼中不斷的新增程式是否被中止的判斷邏輯,如下圖所示:

android_perf_5_asynctask_cancel

一旦任務被成功中止,AsyncTask就不會繼續呼叫onPostExecute(),而是通過呼叫onCancelled()的回撥方法反饋任務執行取消的結果。我們可以根據任務回撥到哪個方法(是onPostExecute還是onCancelled)來決定是對UI進行正常的更新還是把對應的任務所佔用的記憶體進行銷燬等。

  • 最後,使用AsyncTask很容易導致記憶體洩漏,一旦把AsyncTask寫成Activity的內部類的形式就很容易因為AsyncTask生命週期的不確定而導致Activity發生洩漏。

android_perf_5_memory_asynctask

綜上所述,AsyncTask雖然提供了一種簡單便捷的非同步機制,但是我們還是很有必要特別關注到他的缺點,避免出現因為使用錯誤而導致的嚴重系統性能問題。

5) Getting a HandlerThread

大多數情況下,AsyncTask都能夠滿足多執行緒併發的場景需要(在工作執行緒執行任務並返回結果到主執行緒),但是它並不是萬能的。例如開啟相機之後的預覽幀資料是通過onPreviewFrame()的方法進行回撥的,onPreviewFrame()open()相機的方法是執行在同一個執行緒的。

android_perf_5_handlerthread_camera_open

如果這個回撥方法執行在UI執行緒,那麼在onPreviewFrame()裡面將要執行的資料轉換操作將和主執行緒的介面繪製,事件傳遞等操作爭搶系統資源,這就有可能影響到主介面的表現效能。

android_perf_5_handlerthread_main_thread2

我們需要確保onPreviewFrame()執行在工作執行緒。如果使用AsyncTask,會因為AsyncTask預設的線性執行的特性(即使換成併發執行)會導致因為無法把任務及時傳遞給工作執行緒而導致任務在主執行緒中被延遲,直到工作執行緒空閒,才可以把任務切換到工作執行緒中進行執行。

android_perf_5_handlerthread_asynctask

所以我們需要的是一個執行在工作執行緒,同時又能夠處理佇列中的複雜任務的功能,而HandlerThread的出現就是為了實現這個功能的,它組合了Handler,MessageQueue,Looper實現了一個長時間執行的執行緒,不斷的從佇列中獲取任務進行執行的功能。

android_perf_5_handlerthread_outline

回到剛才的處理相機回撥資料的例子,使用HandlerThread我們可以把open()操作與onPreviewFrame()的操作執行在同一個執行緒,同時還避免了AsyncTask的弊端。如果需要在onPreviewFrame()裡面更新UI,只需要呼叫runOnUiThread()方法把任務回撥給主執行緒就夠了。

android_perf_5_handlerthread_camera

HandlerThread比較合適處理那些在工作執行緒執行,需要花費時間偏長的任務。我們只需要把任務傳送給HandlerThread,然後就只需要等待任務執行結束的時候通知返回到主執行緒就好了。

另外很重要的一點是,一旦我們使用了HandlerThread,需要特別注意給HandlerThread設定不同的執行緒優先順序,CPU會根據設定的不同執行緒優先順序對所有的執行緒進行排程優化。

android_perf_5_handlerthread_priority

掌握HandlerThread與AsyncTask之間的優缺點,可以幫助我們選擇合適的方案。

6) Swimming in Threadpools

執行緒池適合用在把任務進行分解,併發進行執行的場景。通常來說,系統裡面會針對不同的任務設定一個單獨的守護執行緒用來專門處理這項任務。例如使用Networking Thread用來專門處理網路請求的操作,使用IO Thread用來專門處理系統的I\O操作。針對那些場景,這樣設計是沒有問題的,因為對應的任務單次執行的時間並不長而且可以是順序執行的。但是這種專屬的單執行緒並不能滿足所有的情況,例如我們需要一次性decode 40張圖片,每個執行緒需要執行4ms的時間,如果我們使用專屬單執行緒的方案,所有圖片執行完畢會需要花費160ms(40*4),但是如果我們建立10個執行緒,每個執行緒執行4個任務,那麼我們就只需要16ms就能夠把所有的圖片處理完畢。

android_perf_5_threadpool_1

為了能夠實現上面的執行緒池模型,系統為我們提供了ThreadPoolExecutor幫助類來簡化實現,剩下需要做的就只是對任務進行分解就好了。

android_perf_5_threadpool_2

使用執行緒池需要特別注意同時併發執行緒數量的控制,理論上來說,我們可以設定任意你想要的併發數量,但是這樣做非常的不好。因為CPU只能同時執行固定數量的執行緒數,一旦同時併發的執行緒數量超過CPU能夠同時執行的閾值,CPU就需要花費精力來判斷到底哪些執行緒的優先順序比較高,需要在不同的執行緒之間進行排程切換。

android_perf_5_threadpool_3

一旦同時併發的執行緒數量達到一定的量級,這個時候CPU在不同執行緒之間進行排程的時間就可能過長,反而導致效能嚴重下降。另外需要關注的一點是,每開一個新的執行緒,都會耗費至少64K+的記憶體。為了能夠方便的對執行緒數量進行控制,ThreadPoolExecutor為我們提供了初始化的併發執行緒數量,以及最大的併發數量進行設定。

android_perf_5_threadpool_4

另外需要關注的一個問題是:Runtime.getRuntime().availableProcesser()方法並不可靠,他返回的值並不是真實的CPU核心數,因為CPU會在某些情況下選擇對部分核心進行睡眠處理,在這種情況下,返回的數量就只能是啟用的CPU核心數。

7) The Zen of IntentService

預設的Service是執行在主執行緒的,可是通常情況下,這很容易影響到程式的繪製效能(搶佔了主執行緒的資源)。除了前面介紹過的AsyncTask與HandlerThread,我們還可以選擇使用IntentService來實現非同步操作。IntentService繼承自普通Service同時又在內部建立了一個HandlerThread,在onHandlerIntent()的回撥裡面處理扔到IntentService的任務。所以IntentService就不僅僅具備了非同步執行緒的特性,還同時保留了Service不受主頁面生命週期影響的特點。

android_perf_5_intentservice_outline

如此一來,我們可以在IntentService裡面通過設定鬧鐘間隔性的觸發非同步任務,例如重新整理資料,更新快取的圖片或者是分析使用者操作行為等等,當然處理這些任務需要小心謹慎。

使用IntentService需要特別留意以下幾點:

  • 首先,因為IntentService內建的是HandlerThread作為非同步執行緒,所以每一個交給IntentService的任務都將以佇列的方式逐個被執行到,一旦佇列中有某個任務執行時間過長,那麼就會導致後續的任務都會被延遲處理。

  • 其次,通常使用到IntentService的時候,我們會結合使用BroadcastReceiver把工作執行緒的任務執行結果返回給主UI執行緒。使用廣播容易引起效能問題,我們可以使用LocalBroadcastManager來發送只在程式內部傳遞的廣播,從而提升廣播的效能。我們也可以使用runOnUiThread()快速回調到主UI執行緒。

  • 最後,包含正在執行的IntentService的程式相比起純粹的後臺程式更不容易被系統殺死,該程式的優先順序是介於前臺程式與純後臺程式之間的。

8) Threading and Loaders

當啟動工作執行緒的Activity被銷燬的時候,我們應該做點什麼呢?為了方便的控制工作執行緒的啟動與結束,Android為我們引入了Loader來解決這個問題。我們知道Activity有可能因為使用者的主動切換而頻繁的被建立與銷燬,也有可能是因為類似螢幕發生旋轉等被動原因而銷燬再重建。在Activity不停的建立與銷燬的過程當中,很有可能因為工作執行緒持有Activity的View而導致記憶體洩漏(因為工作執行緒很可能持有View的強引用,另外工作執行緒的生命週期還無法保證和Activity的生命週期一致,這樣就容易發生記憶體洩漏了)。除了可能引起記憶體洩漏之外,在Activity被銷燬之後,工作執行緒還繼續更新檢視是沒有意義的,因為此時檢視已經不在介面上顯示了。

android_perf_5_loader_bad

Loader的出現就是為了確保工作執行緒能夠和Activity的生命週期保持一致,同時避免出現前面提到的問題。

android_perf_5_loader_good

LoaderManager會對查詢的操作進行快取,只要對應Cursor上的資料來源沒有發生變化,在配置資訊發生改變的時候(例如螢幕的旋轉),Loader可以直接把快取的資料回撥到onLoadFinished(),從而避免重新查詢資料。另外系統會在Loader不再需要使用到的時候(例如使用Back按鈕退出當前頁面)回撥onLoaderReset()方法,我們可以在這裡做資料的清除等等操作。

在Activity或者Fragment中使用Loader可以方便的實現非同步載入的框架,Loader有諸多優點。但是實現Loader的這套程式碼還是稍微有點點複雜,Android官方為我們提供了使用Loader的示例程式碼進行參考學習。

9) The Importance of Thread Priority

理論上來說,我們的程式可以創建出非常多的子執行緒一起併發執行的,可是基於CPU時間片輪轉排程的機制,不可能所有的執行緒都可以同時被排程執行,CPU需要根據執行緒的優先順序賦予不同的時間片。

android_perf_5_threadpriority_CPU

Android系統會根據當前執行的可見的程式和不可見的後臺程式對執行緒進行歸類,劃分為forground的那部分執行緒會大致佔用掉CPU的90%左右的時間片,background的那部分執行緒就總共只能分享到5%-10%左右的時間片。之所以設計成這樣是因為forground的程式本身的優先順序就更高,理應得到更多的執行時間。

android_perf_5_threadpriority_90

預設情況下,新建立的執行緒的優先順序預設和建立它的母執行緒保持一致。如果主UI執行緒創建出了幾十個工作執行緒,這些工作執行緒的優先順序就預設和主執行緒保持一致了,為了不讓新建立的工作執行緒和主執行緒搶佔CPU資源,需要把這些執行緒的優先順序進行降低處理,這樣才能給幫組CPU識別主次,提高主執行緒所能得到的系統資源。

android_perf_5_threadpriority_less

在Android系統裡面,我們可以通過android.os.Process.setThreadPriority(int)設定執行緒的優先順序,引數範圍從-20到24,數值越小優先順序越高。Android系統還為我們提供了以下的一些預設值,我們可以通過給不同的工作執行緒設定不同數值的優先順序來達到更細粒度的控制。

android_perf_5_threadpriority_const

大多數情況下,新建立的執行緒優先順序會被設定為預設的0,主執行緒設定為0的時候,新建立的執行緒還可以利用THREAD_PRIORITY_LESS_FAVORABLE或者THREAD_PRIORITY_MORE_FAVORABLE來控制執行緒的優先順序。

android_perf_5_threadpriority_value

Android系統裡面的AsyncTask與IntentService已經預設幫助我們設定執行緒的優先順序,但是對於那些非官方提供的多執行緒工具類,我們需要特別留意根據需要自己手動來設定執行緒的優先順序。

android_perf_5_threadpriority_asynctask

android_perf_5_threadpriority_intentservice

10) Profile GPU Rendering : M Update

從Android M系統開始,系統更新了GPU Profiling的工具來幫助我們定位UI的渲染效能問題。早期的CPU Profiling工具只能粗略的顯示出Process、Execute、Update三大步驟的時間耗費情況。

android_perf_5_gpu_profiling_old

但是僅僅顯示三大步驟的時間耗費情況,還是不太能夠清晰幫助我們定位具體的程式程式碼問題,所以在Android M版本開始,GPU Profiling工具把渲染操作拆解成如下8個詳細的步驟進行顯示。

android_perf_5_gpu_profiling_8steps

舊版本中提到的Process、Execute、Update還是繼續得到了保留,他們的對應關係如下:

android_perf_5_gpu_profiling_3steps

接下去我們看下其他5個步驟分別代表了什麼含義:

  • Sync & Upload:通常表示的是準備當前介面上有待繪製的圖片所耗費的時間,為了減少該段區域的執行時間,我們可以減少螢幕上的圖片數量或者是縮小圖片本身的大小。

  • Measure & Layout:這裡表示的是佈局的onMeasure與onLayout所花費的時間,一旦時間過長,就需要仔細檢查自己的佈局是不是存在嚴重的效能問題。

  • Animation:表示的是計算執行動畫所需要花費的時間,包含的動畫有ObjectAnimator,ViewPropertyAnimator,Transition等等。一旦這裡的執行時間過長,就需要檢查是不是使用了非官方的動畫工具或者是檢查動畫執行的過程中是不是觸發了讀寫操作等等。

  • Input Handling:表示的是系統處理輸入事件所耗費的時間,粗略等於對於的事件處理方法所執行的時間。一旦執行時間過長,意味著在處理使用者的輸入事件的地方執行了複雜的操作。

  • Misc/Vsync Delay:如果稍加註意,我們可以在開發應用的Log日誌裡面看到這樣一行提示:I/Choreographer(691): Skipped XXX frames! The application may be doing too much work on its main thread。這意味著我們在主執行緒執行了太多的任務,導致UI渲染跟不上vSync的訊號而出現掉幀的情況。

上面8種不同的顏色區分了不同的操作所耗費的時間,為了便於我們迅速找出那些有問題的步驟,GPU Profiling工具會顯示16ms的閾值線,這樣就很容易找出那些不合理的效能問題。再仔細看對應具體哪個步驟相對來說耗費時間比例更大,結合上面介紹的細化步驟,從而快速定位問題,修復問題。