在 Android 開發中使用協程 | 背景介紹
本文是介紹 Android 協程系列中的第一部分,主要會介紹協程是如何工作的,它們主要解決什么問題。
協程用來解決什么問題?
Kotlin 中的協程提供了一種全新處理并發的方式,您可以在 Android 平臺上使用它來簡化異步執行的代碼。協程是從 Kotlin 1.3 版本開始引入,但這一概念在編程世界誕生的黎明之際就有了,最早使用協程的編程語言可以追溯到 1967 年的 Simula 語言。
在過去幾年間,協程這個概念發展勢頭迅猛,現已經被諸多主流編程語言采用,比如 Javascript、C#、Python、Ruby 以及 Go 等。Kotlin 的協程是基于來自其他語言的既定概念。
- 協程:https://kotlinlang.org/docs/reference/coroutines-overview.html
- Simula:https://en.wikipedia.org/wiki/Simula
- Javascript:https://javascript.info/async-await
- C#:https://docs.microsoft.com/en-us/dotnet/csharp/programming-guide/concepts/async/
- Python:https://docs.python.org/3/library/asyncio-task.html
- Ruby:https://ruby-doc.org/core-2.1.1/Fiber.html
- Go:https://tour.golang.org/concurrency/1
在 Android 平臺上,協程主要用來解決兩個問題:
- 處理耗時任務 (Long running tasks),這種任務常常會阻塞住主線程;
- 保證主線程安全 (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 這樣的庫就是采用這種方式幫您處理網絡請求,并不會阻塞主線程的執行。
Retrofi:thttps://square.github.io/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) 來啟動新的協程。
- launch:https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/launch.html
(1) 搭配使用 suspend 和 resume 來替代回調的使用
在上面的示例中,get 仍在主線程上運行,但它會在啟動網絡請求之前暫停協程。當網絡請求完成時,get 會恢復已暫停的協程,而不是使用回調來通知主線程。
上述動畫展示了 Kotlin 如何使用 suspend 和 resume 來代替回調
觀察上圖中 fetchDocs 的執行,就能明白 suspend 是如何工作的。Kotlin 使用堆棧幀來管理要運行哪個函數以及所有局部變量。暫停協程時,會復制并保存當前的堆棧幀以供稍后使用。恢復協程時,會將堆棧幀從其保存位置復制回來,然后函數再次開始運行。在上面的動畫中,當主線程下所有的協程都被暫停,主線程處理屏幕繪制和點擊事件時就會毫無壓力。所以用上述的 suspend 和 resume 的操作來代替回調看起來十分的清爽。
(2) 當主線程下所有的協程都被暫停,主線程處理別的事件時就會毫無壓力
即使代碼可能看起來像普通的順序阻塞請求,協程也能確保網絡請求避免阻塞主線程。
接下來,讓我們來看一下協程是如何保證主線程安全 (main-safety),并來探討一下調度器。
使用協程保證主線程安全
在 Kotlin 的協程中,主線程調用編寫良好的 suspend 函數通常是安全的。不管那些 suspend 函數是做什么的,它們都應該允許任何線程調用它們。
但是在我們的 Android 應用中有很多的事情處理起來太慢,是不應該放在主線程上去做的,比如網絡請求、解析 JSON 數據、從數據庫中進行讀寫操作,甚至是遍歷比較大的數組。這些會導致執行時間長從而讓用戶感覺很 “卡” 的操作都不應該放在主線程上執行。
使用 suspend 并不意味著告訴 Kotlin 要在后臺線程上執行一個函數,這里要強調的是,協程會在主線程上運行。事實上,當要響應一個 UI 事件從而啟動一個協程時,使用 Dispatchers.Main.immediate 是一個非常好的選擇,這樣的話哪怕是最終沒有執行需要保證主線程安全的耗時任務,也可以在下一幀中給用戶提供可用的執行結果。
Dispatchers.Main.immediateh:
ttps://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-main-coroutine-dispatcher/immediate.html
(1) 協程會在主線程中運行,suspend 并不代表后臺執行。
如果需要處理一個函數,且這個函數在主線程上執行太耗時,但是又要保證這個函數是主線程安全的,那么您可以讓 Kotlin 協程在 Default 或 IO 調度器上執行工作。在 Kotlin 中,所有協程都必須在調度器中運行,即使它們是在主線程上運行也是如此。協程可以自行暫停,而調度器負責將其恢復。
Kotlin 提供了三個調度器,您可以使用它們來指定應在何處運行協程:
如果您在 Room 中使用了 suspend 函數、RxJava 或者 LiveData,Room 會自動保障主線程安全。
類似于 Retrofit 和 Volley 這樣的網絡庫會管理它們自身所使用的線程,所以當您在 Kotlin 協程中調用這些庫的代碼時不需要專門來處理主線程安全這一問題。
- 調度器:https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-coroutine-dispatcher/
- suspend:https://medium.com/androiddevelopers/room-coroutines-422b786dc4c5
- RxJava:https://medium.com/androiddevelopers/room-rxjava-acb0cd4f3757
- LiveData:https://developer.android.google.cn/topic/libraries/architecture/livedata#use_livedata_with_room
- Room:https://developer.android.google.cn/topic/libraries/architecture/room
- Retrofit:https://square.github.io/retrofit/
- Volley:https://developer.android.google.cn/training/volley
接著前面的示例來講,您可以使用調度器來重新定義 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 塊完成后,主線程上的協程就會恢復繼續執行。
(2) 主線程調用編寫良好的 suspend 函數通常是安全的。
確保每個 suspend 函數都是主線程安全的是很有用的。如果某個任務是需要接觸到磁盤、網絡,甚至只是占用過多的 CPU,那應該使用 withContext 來確??梢园踩貜闹骶€程進行調用。這也是類似于 Retrofit 和 Room 這樣的代碼庫所遵循的原則。如果您在寫代碼的過程中也遵循這一點,那么您的代碼將會變得非常簡單,并且不會將線程問題與應用邏輯混雜在一起。同時,協程在這個原則下也可以被主線程自由調用,網絡請求或數據庫操作代碼也變得非常簡潔,還能確保用戶在使用應用的過程中不會覺得 “卡”。
withContext 的性能
withContext 同回調或者是提供主線程安全特性的 RxJava 相比的話,性能是差不多的。在某些情況下,甚至還可以優化 withContext 調用,讓它的性能超越基于回調的等效實現。如果某個函數需要對數據庫進行 10 次調用,您可以使用外部 withContext 來讓 Kotlin 只切換一次線程。這樣一來,即使數據庫的代碼庫會不斷調用 withContext,它也會留在同一調度器并跟隨快速路徑,以此來保證性能。此外,在 Dispatchers.Default 和 Dispatchers.IO 中進行切換也得到了優化,以盡可能避免了線程切換所帶來的性能損失。
下一步
本篇文章介紹了使用協程來解決什么樣的問題。協程是一個計算機編程語言領域比較古老的概念,但因為它們能夠讓網絡請求的代碼比較簡潔,從而又開始流行起來。
在 Android 平臺上,您可以使用協程來處理兩個常見問題:
- 簡化處理類似于網絡請求、磁盤讀取甚至是較大 JSON 數據解析這樣的耗時任務;
- 保證主線程安全,這樣可以在不增加代碼復雜度和保證代碼可讀性的前提下做到不會阻塞主線程的執行。
下篇文章:《在 Android 開發中使用協程 | 上手指南》
【本文是51CTO專欄機構“谷歌開發者”的原創稿件,轉載請聯系原作者(微信公眾號:Google_Developers)】