天天看點

在 Android 開發中使用協程 | 背景介紹

在 Android 開發中使用協程 | 背景介紹

本文是介紹 Android 協程系列中的第一部分,主要會介紹協程是如何工作的,它們主要解決什麼問題。

協程用來解決什麼問題?

Kotlin 中的協程提供了一種全新處理并發的方式,您可以在 Android 平台上使用它來簡化異步執行的代碼。協程是從 Kotlin 1.3 版本開始引入,但這一概念在程式設計世界誕生的黎明之際就有了,最早使用協程的程式設計語言可以追溯到 1967 年的 Simula 語言。

在過去幾年間,協程這個概念發展勢頭迅猛,現已經被諸多主流程式設計語言采用,比如 Javascript、C#、Python、Ruby 以及 Go 等。Kotlin 的協程是基于來自其他語言的既定概念。

在 Android 平台上,協程主要用來解決兩個問題:

  1. 處理耗時任務 (Long running tasks),這種任務常常會阻塞住主線程;
  2. 保證主線程安全 (Main-safety) ,即確定安全地從主線程調用任何 suspend 函數。

讓我們來深入上述問題,看看該如何将協程運用到我們代碼中。

處理耗時任務

擷取網頁内容或與遠端 API 互動都會涉及到發送網絡請求,從資料庫裡擷取資料或者從磁盤中讀取圖檔資源涉及到檔案的讀取操作。通常我們把這類操作歸類為耗時任務 —— 應用會停下并等待它們處理完成,這會耗費大量時間。

當今手機處理代碼的速度要遠快于處理網絡請求的速度。以 Pixel 2 為例,單個 CPU 周期耗時低于 0.0000000004 秒,這個數字很難用人類語言來表述,然而,如果将網絡請求以 “眨眼間” 來表述,大概是 400 毫秒 (0.4 秒),則更容易了解 CPU 運作速度之快。僅僅是一眨眼的功夫内,或是一個速度比較慢的網絡請求處理完的時間内,CPU 就已完成了超過 10 億次的時鐘周期了。

Android 中的每個應用都會運作一個主線程,它主要是用來處理 UI (比如進行界面的繪制) 和協調使用者互動。如果主線程上需要處理的任務太多,應用運作會變慢,看上去就像是 “卡” 住了,這樣是很影響使用者體驗的。是以想讓應用運作上不 “卡”、做到動畫能夠流暢運作或者能夠快速響應使用者點選事件,就得讓那些耗時的任務不阻塞主線程的運作。

要做到處理網絡請求不會阻塞主線程,一個常用的做法就是使用回調。回調就是在之後的某段時間去執行您的回調代碼,使用這種方式,請求 developer.android.google.cn 的網站資料的代碼就會類似于下面這樣:

class ViewModel: ViewModel() {
   fun fetchDocs() {
       get("developer.android.google.cn") { result ->
           show(result)
       }
    }
}
複制代碼           

複制

在上面示例中,即使 get 是在主線程中調用的,但是它會使用另外一個線程來執行網絡請求。一旦網絡請求傳回結果,result 可用後,回調代碼就會被主線程調用。這是一個處理耗時任務的好方法,類似于 Retrofit 這樣的庫就是采用這種方式幫您處理網絡請求,并不會阻塞主線程的執行。

使用協程來處理協程任務

使用協程可以簡化您的代碼來處理類似 fetchDocs 這樣的耗時任務。我們先用協程的方法來重寫上面的代碼,以此來講解協程是如何處理耗時任務,進而使代碼更清晰簡潔的。

// Dispatchers.Main
suspend fun fetchDocs() {
    // Dispatchers.Main
    val result = get("developer.android.google.cn")
    // Dispatchers.Main
    show(result)
}
// 在接下來的章節中檢視這段代碼
suspend fun get(url: String) = withContext(Dispatchers.IO){/*...*/}
複制代碼           

複制

在上面的示例中,您可能會有很多疑問,難道它不會阻塞主線程嗎?get 方法是如何做到不等待網絡請求和線程阻塞而傳回結果的?其實,是 Kotlin 中的協程提供了這種執行代碼而不阻塞主線程的方法。

協程在正常函數的基礎上新增了兩項操作。在invoke (或 call) 和 return 之外,協程新增了 suspend 和 resume:

  • suspend — 也稱挂起或暫停,用于暫停執行目前協程,并儲存所有局部變量;
  • resume — 用于讓已暫停的協程從其暫停處繼續執行。

Kotlin 通過新增 suspend 關鍵詞來實作上面這些功能。您隻能夠在 suspend 函數中調用另外的 suspend 函數,或者通過協程構造器 (如 launch) 來啟動新的協程。

搭配使用 suspend 和 resume 來替代回調的使用。

在上面的示例中,get 仍在主線程上運作,但它會在啟動網絡請求之前暫停協程。當網絡請求完成時,get 會恢複已暫停的協程,而不是使用回調來通知主線程。

在 Android 開發中使用協程 | 背景介紹

上述動畫展示了 Kotlin 如何使用 suspend 和 resume 來代替回調 觀察上圖中 fetchDocs 的執行,就能明白** suspend** 是如何工作的。Kotlin 使用堆棧幀來管理要運作哪個函數以及所有局部變量。暫停協程時,會複制并儲存目前的堆棧幀以供稍後使用。恢複協程時,會将堆棧幀從其儲存位置複制回來,然後函數再次開始運作。在上面的動畫中,當主線程下所有的協程都被暫停,主線程處理螢幕繪制和點選事件時就會毫無壓力。是以用上述的 suspend 和 resume 的操作來代替回調看起來十分的清爽。

當主線程下所有的協程都被暫停,主線程處理别的事件時就會毫無壓力。

即使代碼可能看起來像普通的順序阻塞請求,協程也能確定網絡請求避免阻塞主線程。

接下來,讓我們來看一下協程是如何保證主線程安全 (main-safety),并來探讨一下排程器。

使用協程保證主線程安全

在 Kotlin 的協程中,主線程調用編寫良好的 suspend 函數通常是安全的。不管那些 suspend 函數是做什麼的,它們都應該允許任何線程調用它們。

但是在我們的 Android 應用中有很多的事情處理起來太慢,是不應該放在主線程上去做的,比如網絡請求、解析 JSON 資料、從資料庫中進行讀寫操作,甚至是周遊比較大的數組。這些會導緻執行時間長進而讓使用者感覺很 “卡” 的操作都不應該放在主線程上執行。

使用 suspend 并不意味着告訴 Kotlin 要在背景線程上執行一個函數,這裡要強調的是,協程會在主線程上運作。事實上,當要響應一個 UI 事件進而啟動一個協程時,使用 Dispatchers.Main.immediate 是一個非常好的選擇,這樣的話哪怕是最終沒有執行需要保證主線程安全的耗時任務,也可以在下一幀中給使用者提供可用的執行結果。

協程會在主線程中運作,suspend 并不代表背景執行。

如果需要處理一個函數,且這個函數在主線程上執行太耗時,但是又要保證這個函數是主線程安全的,那麼您可以讓 Kotlin 協程在 Default 或 IO 排程器上執行工作。在 Kotlin 中,所有協程都必須在排程器中運作,即使它們是在主線程上運作也是如此。協程可以自行暫停,而排程器負責将其恢複。

Kotlin 提供了三個排程器,您可以使用它們來指定應在何處運作協程:

在 Android 開發中使用協程 | 背景介紹
  • 如果您在 Room 中使用了 suspend 函數、RxJava 或者 LiveData,Room 會自動保障主線程安全。
  • 類似于 Retrofit 和 Volley 這樣的網絡庫會管理它們自身所使用的線程,是以當您在 Kotlin 協程中調用這些庫的代碼時不需要專門來處理主線程安全這一問題。

接着前面的示例來講,您可以使用排程器來重新定義 get 函數。在 get 的主體内,調用 withContext(Dispatchers.IO) 來建立一個在 IO 線程池中運作的塊。您放在該塊内的任何代碼都始終通過 IO 排程器執行。由于 withContext 本身就是一個 suspend 函數,它會使用協程來保證主線程安全。

// Dispatchers.Main
suspend fun fetchDocs() {
    // Dispatchers.Main
    val result = get("developer.android.google.cn")
    // Dispatchers.Main
    show(result)
}
// Dispatchers.Main
suspend fun get(url: String) =
    // Dispatchers.Main
    withContext(Dispatchers.IO) {
        // Dispatchers.IO
    }
    // Dispatchers.Main
複制代碼           

複制

借助協程,您可以通過精細控制來排程線程。由于 withContext 可讓您在不引入回調的情況下控制任何代碼行的線程池,是以您可以将其應用于非常小的函數,如從資料庫中讀取資料或執行網絡請求。一種不錯的做法是使用 withContext 來確定每個函數都是主線程安全的,這意味着,您可以從主線程調用每個函數。這樣,調用方就無需再考慮應該使用哪個線程來執行函數了。

在這個示例中,fetchDocs 會在主線程中執行,不過,它可以安全地調用 get 來在背景執行網絡請求。因為協程支援 suspend 和 resume,是以一旦 withContext 塊完成後,主線程上的協程就會恢複繼續執行。

主線程調用編寫良好的 suspend 函數通常是安全的。

確定每個 suspend 函數都是主線程安全的是很有用的。如果某個任務是需要接觸到磁盤、網絡,甚至隻是占用過多的 CPU,那應該使用 withContext 來確定可以安全地從主線程進行調用。這也是類似于 Retrofit 和 Room 這樣的代碼庫所遵循的原則。如果您在寫代碼的過程中也遵循這一點,那麼您的代碼将會變得非常簡單,并且不會将線程問題與應用邏輯混雜在一起。同時,協程在這個原則下也可以被主線程自由調用,網絡請求或資料庫操作代碼也變得非常簡潔,還能確定使用者在使用應用的過程中不會覺得 “卡”。

withContext 的性能

withContext 同回調或者是提供主線程安全特性的 RxJava 相比的話,性能是差不多的。在某些情況下,甚至還可以優化 withContext 調用,讓它的性能超越基于回調的等效實作。如果某個函數需要對資料庫進行 10 次調用,您可以使用外部 withContext 來讓 Kotlin 隻切換一次線程。這樣一來,即使資料庫的代碼庫會不斷調用 withContext,它也會留在同一排程器并跟随快速路徑,以此來保證性能。此外,在 Dispatchers.Default 和 Dispatchers.IO 中進行切換也得到了優化,以盡可能避免了線程切換所帶來的性能損失。

下一步

本篇文章介紹了使用協程來解決什麼樣的問題。協程是一個計算機程式設計語言領域比較古老的概念,但因為它們能夠讓網絡請求的代碼比較簡潔,進而又開始流行起來。

在 Android 平台上,您可以使用協程來處理兩個常見問題:

  1. 似于網絡請求、磁盤讀取甚至是較大 JSON 資料解析這樣的耗時任務;
  2. 線程安全,這樣可以在不增加代碼複雜度和保證代碼可讀性的前提下做到不會阻塞主線程的執行。

接下來的文章中我們将繼續探讨協程在 Android 中是如何使用的,感興趣的讀者請繼續關注。