Android性能優化典範之多線程篇

本文涉及的內容有:多線程併發的性能問題,介紹了 AsyncTask,HandlerThread,IntentService 與 ThreadPool 分別適合的使用場景以及各自的使用注意事項,這是一篇瞭解 Android 多線程編程不可多得的基礎文章,清楚的瞭解這些 Android 系統提供的多線程基礎組件之間的差異以及優缺點,才能夠在項目實戰中做出最恰當的選擇。

1. Threading Performance

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

衆所周知,Android 程序的大多數代碼操作都必須執行在主線程,例如系統事件(例如設備屏幕發生旋轉),輸入事件(例如用戶點擊滑動等),程序回調服務,UI 繪製以及鬧鐘事件等等。那麼我們在上述事件或者方法中插入的代碼也將執行在主線程。

騰訊bugly

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

騰訊bugly

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

騰訊bugly

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

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

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

2. Understanding Android Threading

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

騰訊bugly

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

騰訊bugly

所幸的是,Android 系統爲我們提供了 Looper,Handler,MessageQueue 來幫助實現上面的線程任務模型:

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

騰訊bugly

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

騰訊bugly

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

騰訊bugly

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

我們知道,當程序被啓動,系統會幫忙創建進程以及相應的主線程,而這個主線程其實就是一個 HandlerThread。這個主線程會需要處理系統事件,輸入事件,系統回調的任務,UI繪製等等任務,爲了避免主線程任務過重,我們就會需要不斷的開啓新的工作線程來處理那些子任務。

3. Memory & Threading

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

在 Android 系統中也無法避免因爲多線程的引入而導致出現諸如上文提到的種種問題。Android UI 對象的創建,更新,銷燬等等操作都默認是執行在主線程,但是如果我們在非主線程對UI對象進行操作,程序將可能出現異常甚至是崩潰。

騰訊bugly

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

騰訊bugly

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

騰訊bugly

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

4. Good AsyncTask Hunting

AsyncTask 是一個讓人既愛又恨的組件,它提供了一種簡便的異步處理機制,但是它又同時引入了一些令人厭惡的麻煩。一旦對 AsyncTask 使用不當,很可能對程序的性能帶來負面影響,同時還可能導致內存泄露。

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

騰訊bugly

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

騰訊bugly

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

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

騰訊bugly

爲了解決上面提到的線性隊列等待的問題,我們可以使用 AsyncTask.executeOnExecutor()強制指定 AsyncTask 使用線程池併發調度任務。

騰訊bugly

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

騰訊bugly

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

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

騰訊bugly

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

5. Getting a HandlerThread

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

騰訊bugly

如果這個回調方法執行在 UI 線程,那麼在 onPreviewFrame()裏面將要執行的數據轉換操作將和主線程的界面繪製,事件傳遞等操作爭搶系統資源,這就有可能影響到主界面的表現性能。

騰訊bugly

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

騰訊bugly

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

騰訊bugly

回到剛纔的處理相機回調數據的例子,使用 HandlerThread 我們可以把 open()操作與 onPreviewFrame()的操作執行在同一個線程,同時還避免了 AsyncTask 的弊端。如果需要在 onPreviewFrame()裏面更新 UI,只需要調用 runOnUiThread()方法把任務回調給主線程就夠了。

騰訊bugly

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

另外很重要的一點是,一旦我們使用了 HandlerThread,需要特別注意給 HandlerThread 設置不同的線程優先級,CPU 會根據設置的不同線程優先級對所有的線程進行調度優化。

騰訊bugly

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

6. Swimming in Threadpools

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

騰訊bugly

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

騰訊bugly

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

騰訊bugly

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

騰訊bugly

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

7. The Zen of IntentService

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

騰訊bugly

如此一來,我們可以在 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 被銷燬之後,工作線程還繼續更新視圖是沒有意義的,因爲此時視圖已經不在界面上顯示了。

騰訊bugly

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

騰訊bugly

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

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

9. The Importance of Thread Priority

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

騰訊bugly

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

騰訊bugly

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

騰訊bugly

在 Android 系統裏面,我們可以通過 android.os.Process.setThreadPriority(int) 設置線程的優先級,參數範圍從-20到19,數值越小優先級越高。Android 系統還爲我們提供了以下的一些預設值,我們可以通過給不同的工作線程設置不同數值的優先級來達到更細粒度的控制。

騰訊bugly

大多數情況下,新創建的線程優先級會被設置爲默認的0,主線程設置爲0的時候,新創建的線程還可以利用 THREAD_PRIORITY_LESS_FAVORABLE 或者THREAD_PRIORITY_MORE_FAVORABLE 來控制線程的優先級。

騰訊bugly

Android 系統裏面的 AsyncTask 與 IntentService已經默認幫助我們設置線程的優先級,但是對於那些非官方提供的多線程工具類,我們需要特別留意根據需要自己手動來設置線程的優先級。

騰訊bugly 
騰訊bugly

10. Profile GPU Rendering : M Update

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

騰訊bugly

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

騰訊bugly

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

騰訊bugly

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

  • 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 的信號而出現掉幀的情況。

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

發佈了65 篇原創文章 · 獲贊 112 · 訪問量 20萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章