在 Android 開發(fā)中使用協(xié)程 | 背景介紹
本文是介紹 Android 協(xié)程系列中的第一部分,主要會(huì)介紹協(xié)程是如何工作的,它們主要解決什么問題。
協(xié)程用來解決什么問題?
Kotlin 中的協(xié)程提供了一種全新處理并發(fā)的方式,您可以在 Android 平臺(tái)上使用它來簡化異步執(zhí)行的代碼。協(xié)程是從 Kotlin 1.3 版本開始引入,但這一概念在編程世界誕生的黎明之際就有了,最早使用協(xié)程的編程語言可以追溯到 1967 年的 Simula 語言。
在過去幾年間,協(xié)程這個(gè)概念發(fā)展勢頭迅猛,現(xiàn)已經(jīng)被諸多主流編程語言采用,比如 Javascript、C#、Python、Ruby 以及 Go 等。Kotlin 的協(xié)程是基于來自其他語言的既定概念。
- 協(xié)程: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 平臺(tái)上,協(xié)程主要用來解決兩個(gè)問題:
- 處理耗時(shí)任務(wù) (Long running tasks),這種任務(wù)常常會(huì)阻塞住主線程;
- 保證主線程安全 (Main-safety) ,即確保安全地從主線程調(diào)用任何 suspend 函數(shù)。
讓我們來深入上述問題,看看該如何將協(xié)程運(yùn)用到我們代碼中。
處理耗時(shí)任務(wù)
獲取網(wǎng)頁內(nèi)容或與遠(yuǎn)程 API 交互都會(huì)涉及到發(fā)送網(wǎng)絡(luò)請(qǐng)求,從數(shù)據(jù)庫里獲取數(shù)據(jù)或者從磁盤中讀取圖片資源涉及到文件的讀取操作。通常我們把這類操作歸類為耗時(shí)任務(wù) —— 應(yīng)用會(huì)停下并等待它們處理完成,這會(huì)耗費(fèi)大量時(shí)間。
當(dāng)今手機(jī)處理代碼的速度要遠(yuǎn)快于處理網(wǎng)絡(luò)請(qǐng)求的速度。以 Pixel 2 為例,單個(gè) CPU 周期耗時(shí)低于 0.0000000004 秒,這個(gè)數(shù)字很難用人類語言來表述,然而,如果將網(wǎng)絡(luò)請(qǐng)求以 “眨眼間” 來表述,大概是 400 毫秒 (0.4 秒),則更容易理解 CPU 運(yùn)行速度之快。僅僅是一眨眼的功夫內(nèi),或是一個(gè)速度比較慢的網(wǎng)絡(luò)請(qǐng)求處理完的時(shí)間內(nèi),CPU 就已完成了超過 10 億次的時(shí)鐘周期了。
Android 中的每個(gè)應(yīng)用都會(huì)運(yùn)行一個(gè)主線程,它主要是用來處理 UI (比如進(jìn)行界面的繪制) 和協(xié)調(diào)用戶交互。如果主線程上需要處理的任務(wù)太多,應(yīng)用運(yùn)行會(huì)變慢,看上去就像是 “卡” 住了,這樣是很影響用戶體驗(yàn)的。所以想讓應(yīng)用運(yùn)行上不 “卡”、做到動(dòng)畫能夠流暢運(yùn)行或者能夠快速響應(yīng)用戶點(diǎn)擊事件,就得讓那些耗時(shí)的任務(wù)不阻塞主線程的運(yùn)行。
要做到處理網(wǎng)絡(luò)請(qǐng)求不會(huì)阻塞主線程,一個(gè)常用的做法就是使用回調(diào)?;卣{(diào)就是在之后的某段時(shí)間去執(zhí)行您的回調(diào)代碼,使用這種方式,請(qǐng)求 developer.android.google.cn 的網(wǎng)站數(shù)據(jù)的代碼就會(huì)類似于下面這樣:
- class ViewModel: ViewModel() {
- fun fetchDocs() {
- get("developer.android.google.cn") { result ->
- show(result)
- }
- }
- }
在上面示例中,即使 get 是在主線程中調(diào)用的,但是它會(huì)使用另外一個(gè)線程來執(zhí)行網(wǎng)絡(luò)請(qǐng)求。一旦網(wǎng)絡(luò)請(qǐng)求返回結(jié)果,result 可用后,回調(diào)代碼就會(huì)被主線程調(diào)用。這是一個(gè)處理耗時(shí)任務(wù)的好方法,類似于 Retrofit 這樣的庫就是采用這種方式幫您處理網(wǎng)絡(luò)請(qǐng)求,并不會(huì)阻塞主線程的執(zhí)行。
Retrofi:thttps://square.github.io/retrofit/
使用協(xié)程來處理協(xié)程任務(wù)
使用協(xié)程可以簡化您的代碼來處理類似 fetchDocs 這樣的耗時(shí)任務(wù)。我們先用協(xié)程的方法來重寫上面的代碼,以此來講解協(xié)程是如何處理耗時(shí)任務(wù),從而使代碼更清晰簡潔的。
- // Dispatchers.Main
- suspend fun fetchDocs() {
- // Dispatchers.Main
- val result = get("developer.android.google.cn")
- // Dispatchers.Main
- show(result)
- }
- // 在接下來的章節(jié)中查看這段代碼
- suspend fun get(url: String) = withContext(Dispatchers.IO){/*...*/}
在上面的示例中,您可能會(huì)有很多疑問,難道它不會(huì)阻塞主線程嗎?get 方法是如何做到不等待網(wǎng)絡(luò)請(qǐng)求和線程阻塞而返回結(jié)果的?其實(shí),是 Kotlin 中的協(xié)程提供了這種執(zhí)行代碼而不阻塞主線程的方法。
協(xié)程在常規(guī)函數(shù)的基礎(chǔ)上新增了兩項(xiàng)操作。在 invoke (或 call) 和 return 之外,協(xié)程新增了 suspend 和 resume:
- suspend — 也稱掛起或暫停,用于暫停執(zhí)行當(dāng)前協(xié)程,并保存所有局部變量;
- resume — 用于讓已暫停的協(xié)程從其暫停處繼續(xù)執(zhí)行。
Kotlin 通過新增 suspend 關(guān)鍵詞來實(shí)現(xiàn)上面這些功能。您只能夠在 suspend 函數(shù)中調(diào)用另外的 suspend 函數(shù),或者通過協(xié)程構(gòu)造器 (如 launch) 來啟動(dòng)新的協(xié)程。
- launch:https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/launch.html
(1) 搭配使用 suspend 和 resume 來替代回調(diào)的使用
在上面的示例中,get 仍在主線程上運(yùn)行,但它會(huì)在啟動(dòng)網(wǎng)絡(luò)請(qǐng)求之前暫停協(xié)程。當(dāng)網(wǎng)絡(luò)請(qǐng)求完成時(shí),get 會(huì)恢復(fù)已暫停的協(xié)程,而不是使用回調(diào)來通知主線程。
上述動(dòng)畫展示了 Kotlin 如何使用 suspend 和 resume 來代替回調(diào)
觀察上圖中 fetchDocs 的執(zhí)行,就能明白 suspend 是如何工作的。Kotlin 使用堆棧幀來管理要運(yùn)行哪個(gè)函數(shù)以及所有局部變量。暫停協(xié)程時(shí),會(huì)復(fù)制并保存當(dāng)前的堆棧幀以供稍后使用?;謴?fù)協(xié)程時(shí),會(huì)將堆棧幀從其保存位置復(fù)制回來,然后函數(shù)再次開始運(yùn)行。在上面的動(dòng)畫中,當(dāng)主線程下所有的協(xié)程都被暫停,主線程處理屏幕繪制和點(diǎn)擊事件時(shí)就會(huì)毫無壓力。所以用上述的 suspend 和 resume 的操作來代替回調(diào)看起來十分的清爽。
(2) 當(dāng)主線程下所有的協(xié)程都被暫停,主線程處理別的事件時(shí)就會(huì)毫無壓力
即使代碼可能看起來像普通的順序阻塞請(qǐng)求,協(xié)程也能確保網(wǎng)絡(luò)請(qǐng)求避免阻塞主線程。
接下來,讓我們來看一下協(xié)程是如何保證主線程安全 (main-safety),并來探討一下調(diào)度器。
使用協(xié)程保證主線程安全
在 Kotlin 的協(xié)程中,主線程調(diào)用編寫良好的 suspend 函數(shù)通常是安全的。不管那些 suspend 函數(shù)是做什么的,它們都應(yīng)該允許任何線程調(diào)用它們。
但是在我們的 Android 應(yīng)用中有很多的事情處理起來太慢,是不應(yīng)該放在主線程上去做的,比如網(wǎng)絡(luò)請(qǐng)求、解析 JSON 數(shù)據(jù)、從數(shù)據(jù)庫中進(jìn)行讀寫操作,甚至是遍歷比較大的數(shù)組。這些會(huì)導(dǎo)致執(zhí)行時(shí)間長從而讓用戶感覺很 “卡” 的操作都不應(yīng)該放在主線程上執(zhí)行。
使用 suspend 并不意味著告訴 Kotlin 要在后臺(tái)線程上執(zhí)行一個(gè)函數(shù),這里要強(qiáng)調(diào)的是,協(xié)程會(huì)在主線程上運(yùn)行。事實(shí)上,當(dāng)要響應(yīng)一個(gè) UI 事件從而啟動(dòng)一個(gè)協(xié)程時(shí),使用 Dispatchers.Main.immediate 是一個(gè)非常好的選擇,這樣的話哪怕是最終沒有執(zhí)行需要保證主線程安全的耗時(shí)任務(wù),也可以在下一幀中給用戶提供可用的執(zhí)行結(jié)果。
Dispatchers.Main.immediateh:
ttps://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-main-coroutine-dispatcher/immediate.html
(1) 協(xié)程會(huì)在主線程中運(yùn)行,suspend 并不代表后臺(tái)執(zhí)行。
如果需要處理一個(gè)函數(shù),且這個(gè)函數(shù)在主線程上執(zhí)行太耗時(shí),但是又要保證這個(gè)函數(shù)是主線程安全的,那么您可以讓 Kotlin 協(xié)程在 Default 或 IO 調(diào)度器上執(zhí)行工作。在 Kotlin 中,所有協(xié)程都必須在調(diào)度器中運(yùn)行,即使它們是在主線程上運(yùn)行也是如此。協(xié)程可以自行暫停,而調(diào)度器負(fù)責(zé)將其恢復(fù)。
Kotlin 提供了三個(gè)調(diào)度器,您可以使用它們來指定應(yīng)在何處運(yùn)行協(xié)程:
如果您在 Room 中使用了 suspend 函數(shù)、RxJava 或者 LiveData,Room 會(huì)自動(dòng)保障主線程安全。
類似于 Retrofit 和 Volley 這樣的網(wǎng)絡(luò)庫會(huì)管理它們自身所使用的線程,所以當(dāng)您在 Kotlin 協(xié)程中調(diào)用這些庫的代碼時(shí)不需要專門來處理主線程安全這一問題。
- 調(diào)度器: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
接著前面的示例來講,您可以使用調(diào)度器來重新定義 get 函數(shù)。在 get 的主體內(nèi),調(diào)用 withContext(Dispatchers.IO) 來創(chuàng)建一個(gè)在 IO 線程池中運(yùn)行的塊。您放在該塊內(nèi)的任何代碼都始終通過 IO 調(diào)度器執(zhí)行。由于 withContext 本身就是一個(gè) suspend 函數(shù),它會(huì)使用協(xié)程來保證主線程安全。
- // 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
借助協(xié)程,您可以通過精細(xì)控制來調(diào)度線程。由于 withContext 可讓您在不引入回調(diào)的情況下控制任何代碼行的線程池,因此您可以將其應(yīng)用于非常小的函數(shù),如從數(shù)據(jù)庫中讀取數(shù)據(jù)或執(zhí)行網(wǎng)絡(luò)請(qǐng)求。一種不錯(cuò)的做法是使用 withContext 來確保每個(gè)函數(shù)都是主線程安全的,這意味著,您可以從主線程調(diào)用每個(gè)函數(shù)。這樣,調(diào)用方就無需再考慮應(yīng)該使用哪個(gè)線程來執(zhí)行函數(shù)了。
在這個(gè)示例中,fetchDocs 會(huì)在主線程中執(zhí)行,不過,它可以安全地調(diào)用 get 來在后臺(tái)執(zhí)行網(wǎng)絡(luò)請(qǐng)求。因?yàn)閰f(xié)程支持 suspend 和 resume,所以一旦 withContext 塊完成后,主線程上的協(xié)程就會(huì)恢復(fù)繼續(xù)執(zhí)行。
(2) 主線程調(diào)用編寫良好的 suspend 函數(shù)通常是安全的。
確保每個(gè) suspend 函數(shù)都是主線程安全的是很有用的。如果某個(gè)任務(wù)是需要接觸到磁盤、網(wǎng)絡(luò),甚至只是占用過多的 CPU,那應(yīng)該使用 withContext 來確??梢园踩貜闹骶€程進(jìn)行調(diào)用。這也是類似于 Retrofit 和 Room 這樣的代碼庫所遵循的原則。如果您在寫代碼的過程中也遵循這一點(diǎn),那么您的代碼將會(huì)變得非常簡單,并且不會(huì)將線程問題與應(yīng)用邏輯混雜在一起。同時(shí),協(xié)程在這個(gè)原則下也可以被主線程自由調(diào)用,網(wǎng)絡(luò)請(qǐng)求或數(shù)據(jù)庫操作代碼也變得非常簡潔,還能確保用戶在使用應(yīng)用的過程中不會(huì)覺得 “卡”。
withContext 的性能
withContext 同回調(diào)或者是提供主線程安全特性的 RxJava 相比的話,性能是差不多的。在某些情況下,甚至還可以優(yōu)化 withContext 調(diào)用,讓它的性能超越基于回調(diào)的等效實(shí)現(xiàn)。如果某個(gè)函數(shù)需要對(duì)數(shù)據(jù)庫進(jìn)行 10 次調(diào)用,您可以使用外部 withContext 來讓 Kotlin 只切換一次線程。這樣一來,即使數(shù)據(jù)庫的代碼庫會(huì)不斷調(diào)用 withContext,它也會(huì)留在同一調(diào)度器并跟隨快速路徑,以此來保證性能。此外,在 Dispatchers.Default 和 Dispatchers.IO 中進(jìn)行切換也得到了優(yōu)化,以盡可能避免了線程切換所帶來的性能損失。
下一步
本篇文章介紹了使用協(xié)程來解決什么樣的問題。協(xié)程是一個(gè)計(jì)算機(jī)編程語言領(lǐng)域比較古老的概念,但因?yàn)樗鼈兡軌蜃尵W(wǎng)絡(luò)請(qǐng)求的代碼比較簡潔,從而又開始流行起來。
在 Android 平臺(tái)上,您可以使用協(xié)程來處理兩個(gè)常見問題:
- 簡化處理類似于網(wǎng)絡(luò)請(qǐng)求、磁盤讀取甚至是較大 JSON 數(shù)據(jù)解析這樣的耗時(shí)任務(wù);
- 保證主線程安全,這樣可以在不增加代碼復(fù)雜度和保證代碼可讀性的前提下做到不會(huì)阻塞主線程的執(zhí)行。
下篇文章:《在 Android 開發(fā)中使用協(xié)程 | 上手指南》
【本文是51CTO專欄機(jī)構(gòu)“谷歌開發(fā)者”的原創(chuàng)稿件,轉(zhuǎn)載請(qǐng)聯(lián)系原作者(微信公眾號(hào):Google_Developers)】