天天看點

【Bugly幹貨】Android性能優化典範之多線程篇

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

1. Threading Performance

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

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

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

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

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

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

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

    2. Understanding Android Threading

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

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

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

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

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

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

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

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

    3. Memory & Threading

    增加并發的線程數會導緻記憶體消耗的增加,平衡好這兩者的關系是非常重要的。我們知道,多線程并發通路同一塊記憶體區域有可能帶來很多問題,例如讀寫的權限争奪問題,ABA 問題等等。為了解決這些問題,我們會需要引入鎖的概念。

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

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

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

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

    4. Good AsyncTask Hunting

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

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

    AsyncTask 的出現就是為了快速的實作上面的使用場景,AsyncTask 把在主線程裡面的準備工作放到

    onPreExecute()

    方法裡面進行執行,

    doInBackground()

    方法執行在工作線程中,用來處理那些繁重的任務,一旦任務執行完畢,就會調用

    onPostExecute()

    方法傳回到主線程。

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

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

    為了解決上面提到的線性隊列等待的問題,我們可以使用

    AsyncTask.executeOnExecutor()

    強制指定 AsyncTask 使用線程池并發排程任務。

    其次,如何才能夠真正的取消一個 AsyncTask 的執行呢?我們知道 AsyncTaks 有提供

    cancel()

    的方法,但是這個方法實際上做了什麼事情呢?線程本身并不具備中止正在執行的代碼的能力,為了能夠讓一個線程更早的被銷毀,我們需要在

    doInBackground()

    的代碼中不斷的添加程式是否被中止的判斷邏輯,如下圖所示:

    一旦任務被成功中止,AsyncTask 就不會繼續調用

    onPostExecute()

    ,而是通過調用

    onCancelled()

    的回調方法回報任務執行取消的結果。我們可以根據任務回調到哪個方法(是 onPostExecute 還是 onCancelled)來決定是對 UI 進行正常的更新還是把對應的任務所占用的記憶體進行銷毀等。

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

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

    5. Getting a HandlerThread

    大多數情況下,AsyncTask 都能夠滿足多線程并發的場景需要(在工作線程執行任務并傳回結果到主線程),但是它并不是萬能的。例如打開相機之後的預覽幀資料是通過

    onPreviewFrame()

    的方法進行回調的,

    onPreviewFrame()

    open()

    相機的方法是執行在同一個線程的。

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

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

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

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

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

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

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

    6. Swimming in Threadpools

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

    為了能夠實作上面的線程池模型,系統為我們提供了

    ThreadPoolExecutor

    幫助類來簡化實作,剩下需要做的就隻是對任務進行分解就好了。

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

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

    另外需要關注的一個問題是:

    Runtime.getRuntime().availableProcesser()

    方法并不可靠,他傳回的值并不是真實的 CPU 核心數,因為 CPU 會在某些情況下選擇對部分核心進行睡眠處理,在這種情況下,傳回的數量就隻能是激活的 CPU 核心數。

    7. The Zen of IntentService

    預設的 Service 是執行在主線程的,可是通常情況下,這很容易影響到程式的繪制性能(搶占了主線程的資源)。除了前面介紹過的 AsyncTask 與 HandlerThread,我們還可以選擇使用 IntentService 來實作異步操作。IntentService 繼承自普通 Service 同時又在内部建立了一個 HandlerThread,在

    onHandlerIntent()

    的回調裡面處理扔到 IntentService 的任務。是以 IntentService 就不僅僅具備了異步線程的特性,還同時保留了 Service 不受首頁面生命周期影響的特點。

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

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

    LoaderManager 會對查詢的操作進行緩存,隻要對應 Cursor 上的資料源沒有發生變化,在配置資訊發生改變的時候(例如螢幕的旋轉),Loader 可以直接把緩存的資料回調到

    onLoadFinished()

    ,進而避免重新查詢資料。另外系統會在 Loader 不再需要使用到的時候(例如使用 Back 按鈕退出目前頁面)回調

    onLoaderReset()

    方法,我們可以在這裡做資料的清除等等操作。

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

    9. The Importance of Thread Priority

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

    Android 系統會根據目前運作的可見的程式和不可見的背景程式對線程進行歸類,劃分為 forground 的那部分線程會大緻占用掉 CPU 的90%左右的時間片,background 的那部分線程就總共隻能分享到5%-10%左右的時間片。之是以設計成這樣是因為 forground 的程式本身的優先級就更高,理應得到更多的執行時間。

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

    在 Android 系統裡面,我們可以通過

    android.os.Process.setThreadPriority(int)

    設定線程的優先級,參數範圍從-20到19,數值越小優先級越高。Android 系統還為我們提供了以下的一些預設值,我們可以通過給不同的工作線程設定不同數值的優先級來達到更細粒度的控制。

    大多數情況下,新建立的線程優先級會被設定為預設的0,主線程設定為0的時候,新建立的線程還可以利用

    THREAD_PRIORITY_LESS_FAVORABLE

    或者

    THREAD_PRIORITY_MORE_FAVORABLE

    來控制線程的優先級。

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

    10. Profile GPU Rendering : M Update

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

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

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

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

  • 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 的門檻值線,這樣就很容易找出那些不合理的性能問題,再仔細看對應具體哪個步驟相對來說耗費時間比例更大,結合上面介紹的細化步驟,進而快速定位問題,修複問題。

  • 如果你覺得内容意猶未盡,如果你想了解更多相關資訊,請掃描以下二維碼,關注我們的公衆賬号,可以擷取更多技術類幹貨,還有精彩活動與你分享~

    騰訊 Bugly是一款專為移動開發者打造的品質監控工具,幫助開發者快速,便捷的定位線上應用崩潰的情況以及解決方案。智能合并功能幫助開發同學把每天上報的數千條 Crash 根據根因合并分類,每日日報會列出影響使用者數最多的崩潰,精準定位功能幫助開發同學定位到出問題的代碼行,實時上報可以在釋出後快速的了解應用的品質情況,适配最新的 iOS, Android 官方作業系統,鵝廠的工程師都在使用,快來加入我們吧!