在 Android 開發(fā)中使用協(xié)程 | 上手指南
接上篇文章《在 Android 開發(fā)中使用協(xié)程 | 背景介紹》
本文是介紹 Android 協(xié)程系列中的第二部分,這篇文章主要會介紹如何使用協(xié)程來處理任務,并且能在任務開始執(zhí)行后保持對它的追蹤。
保持對協(xié)程的追蹤
本系列文章的第一篇,我們探討了協(xié)程適合用來解決哪些問題。這里再簡單回顧一下,協(xié)程適合解決以下兩個常見的編程問題:
- 處理耗時任務 (Long running tasks),這種任務常常會阻塞住主線程;
- 保證主線程安全 (Main-safety),即確保安全地從主線程調用任何 suspend 函數(shù)。
協(xié)程通過在常規(guī)函數(shù)之上增加 suspend 和 resume 兩個操作來解決上述問題。當某個特定的線程上的所有協(xié)程被 suspend 后,該線程便可騰出資源去處理其他任務。
協(xié)程自身并不能夠追蹤正在處理的任務,但是有成百上千個協(xié)程并對它們同時執(zhí)行掛起操作并沒有太大問題。協(xié)程是輕量級的,但處理的任務卻不一定是輕量的,比如讀取文件或者發(fā)送網(wǎng)絡請求。
使用代碼來手動追蹤上千個協(xié)程是非常困難的,您可以嘗試對所有協(xié)程進行跟蹤,手動確保它們都完成了或者都被取消了,那么代碼會臃腫且易出錯。如果代碼不是很完美,就會失去對協(xié)程的追蹤,也就是所謂 "work leak" 的情況。
任務泄漏 (work leak) 是指某個協(xié)程丟失無法追蹤,它類似于內(nèi)存泄漏,但比它更加糟糕,這樣丟失的協(xié)程可以恢復自己,從而占用內(nèi)存、CPU、磁盤資源,甚至會發(fā)起一個網(wǎng)絡請求,而這也意味著它所占用的這些資源都無法得到重用。
泄漏協(xié)程會浪費內(nèi)存、CPU、磁盤資源,甚至發(fā)送一個無用的網(wǎng)絡請求。
為了能夠避免協(xié)程泄漏,Kotlin 引入了結構化并發(fā) (structured concurrency) 機制,它是一系列編程語言特性和實踐指南的結合,遵循它能幫助您追蹤到所有運行于協(xié)程中的任務。
在 Android 平臺上,我們可以使用結構化并發(fā)來做到以下三件事:
- 取消任務 —— 當某項任務不再需要時取消它;
- 追蹤任務 —— 當任務正在執(zhí)行時,追蹤它;
- 發(fā)出錯誤信號 —— 當協(xié)程失敗時,發(fā)出錯誤信號表明有錯誤發(fā)生。
接下來我們對以上幾點一一進行探討,看看結構化并發(fā)是如何幫助能夠追蹤所有協(xié)程,而不會導致泄漏出現(xiàn)的。
結構化并發(fā):
https://kotlinlang.org/docs/reference/coroutines/basics.html#structured-concurrency
借助 scope 來取消任務
在 Kotlin 中,定義協(xié)程必須指定其 CoroutineScope 。CoroutineScope 可以對協(xié)程進行追蹤,即使協(xié)程被掛起也是如此。同第一篇文章中講到的調度程序 (Dispatcher) 不同,CoroutineScope 并不運行協(xié)程,它只是確保您不會失去對協(xié)程的追蹤。
為了確保所有的協(xié)程都會被追蹤,Kotlin 不允許在沒有使用 CoroutineScope 的情況下啟動新的協(xié)程。CoroutineScope 可被看作是一個具有超能力的 ExecutorService 的輕量級版本。它能啟動新的協(xié)程,同時這個協(xié)程還具備我們在第一部分所說的 suspend 和 resume 的優(yōu)勢。
CoroutineScope 會跟蹤所有協(xié)程,同樣它還可以取消由它所啟動的所有協(xié)程。這在 Android 開發(fā)中非常有用,比如它能夠在用戶離開界面時停止執(zhí)行協(xié)程。
CoroutineScope 會跟蹤所有協(xié)程,并且可以取消由它所啟動的所有協(xié)程。
啟動新的協(xié)程
需要特別注意的是,您不能隨便就在某個地方調用 suspend 函數(shù),suspend 和 resume 機制要求您從常規(guī)函數(shù)中切換到協(xié)程。
有兩種方式能夠啟動協(xié)程,它們分別適用于不同的場景:
- launch 構建器適合執(zhí)行 "一勞永逸" 的工作,意思就是說它可以啟動新協(xié)程而不將結果返回給調用方;
- async 構建器可啟動新協(xié)程并允許您使用一個名為 await 的掛起函數(shù)返回 result。
通常,您應使用 launch 從常規(guī)函數(shù)中啟動新協(xié)程。因為常規(guī)函數(shù)無法調用 await (記住,它無法直接調用 suspend 函數(shù)),所以將 async 作為協(xié)程的主要啟動方法沒有多大意義。稍后我們會討論應該如何使用 async。
您應該改為使用 coroutine scope 調用 launch 方法來啟動協(xié)程。
- scope.launch {
- // 這段代碼在作用域里啟動了一個新協(xié)程
- // 它可以調用掛起函數(shù)
- fetchDocs()
- }
您可以將 launch 看作是將代碼從常規(guī)函數(shù)送往協(xié)程世界的橋梁。在 launch 函數(shù)體內(nèi),您可以調用 suspend 函數(shù)并能夠像我們上一篇介紹的那樣保證主線程安全。
Launch 是將代碼從常規(guī)函數(shù)送往協(xié)程世界的橋梁。
注意:launch 和 async 之間的很大差異是它們對異常的處理方式不同。async 期望最終是通過調用 await 來獲取結果 (或者異常),所以默認情況下它不會拋出異常。這意味著如果使用 async 啟動新的協(xié)程,它會靜默地將異常丟棄。
由于 launch 和 async 僅能夠在 CouroutineScope 中使用,所以任何您所創(chuàng)建的協(xié)程都會被該 scope 追蹤。Kotlin 禁止您創(chuàng)建不能夠被追蹤的協(xié)程,從而避免協(xié)程泄漏。
- launchhttps://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/launch.html
- asynchttps://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/async.html
在 ViewModel 中啟動協(xié)程
既然 CoroutineScope 會追蹤由它啟動的所有協(xié)程,而 launch 會創(chuàng)建一個新的協(xié)程,那么您應該在什么地方調用 launch 并將其放在 scope 中呢? 又該在什么時候取消在 scope 中啟動的所有協(xié)程呢?
在 Android 平臺上,您可以將 CoroutineScope 實現(xiàn)與用戶界面相關聯(lián)。這樣可讓您避免泄漏內(nèi)存或者對不再與用戶相關的 Activities 或 Fragments 執(zhí)行額外的工作。當用戶通過導航離開某界面時,與該界面相關的 CoroutineScope 可以取消掉所有不需要的任務。
結構化并發(fā)能夠保證當某個作用域被取消后,它內(nèi)部所創(chuàng)建的所有協(xié)程也都被取消。
當將協(xié)程同 Android 架構組件 (Android Architecture Components) 集成起來時,您往往會需要在 ViewModel 中啟動協(xié)程。因為大部分的任務都是在這里開始進行處理的,所以在這個地方啟動是一個很合理的做法,您也不用擔心旋轉屏幕方向會終止您所創(chuàng)建的協(xié)程。
從生命周期感知型組件 (AndroidX Lifecycle) 的 2.1.0 版本開始 (發(fā)布于 2019 年 9 月),我們通過添加擴展屬性 ViewModel.viewModelScope 在 ViewModel 中加入了協(xié)程的支持。
看看如下示例:
- class MyViewModel(): ViewModel() {
- fun userNeedsDocs() {
- // 在 ViewModel 中啟動新的協(xié)程
- viewModelScope.launch {
- fetchDocs()
- }
- }
- }
當 viewModelScope 被清除 (當 onCleared() 回調被調用時) 之后,它將自動取消它所啟動的所有協(xié)程。這是一個標準做法,如果一個用戶在尚未獲取到數(shù)據(jù)時就關閉了應用,這時讓請求繼續(xù)完成就純粹是在浪費電量。
為了提高安全性,CoroutineScope 會進行自行傳播。也就是說,如果某個協(xié)程啟動了另一個新的協(xié)程,它們都會在同一個 scope 中終止運行。這意味著,即使當某個您所依賴的代碼庫從您創(chuàng)建的 viewModelScope 中啟動某個協(xié)程,您也有方法將其取消。
注意:協(xié)程被掛起時,系統(tǒng)會以拋出 CancellationException 的方式協(xié)作取消協(xié)程。捕獲頂級異常 (如Throwable) 的異常處理程序將捕獲此異常。如果您做異常處理時消費了這個異常,或從未進行 suspend 操作,那么協(xié)程將會徘徊于半取消 (semi-canceled) 狀態(tài)下。
所以,當您需要將一個協(xié)程同 ViewModel 的生命周期保持一致時,使用 viewModelScope 來從常規(guī)函數(shù)切換到協(xié)程中。然后,viewModelScope 會自動為您取消協(xié)程,因此在這里哪怕是寫了死循環(huán)也是完全不會產(chǎn)生泄漏。如下示例:
- fun runForever() {
- // 在 ViewModel 中啟動新的協(xié)程
- viewModelScope.launch {
- // 當 ViewModel 被清除后,下列代碼也會被取消
- while(true) {
- delay(1_000)
- // 每過 1 秒做點什么
- }
- }
- }
通過使用 viewModelScope,可以確保所有的任務,包含死循環(huán)在內(nèi),都可以在不需要的時候被取消掉。
協(xié)作取消:
https://kotlinlang.org/docs/reference/coroutines/cancellation-and-timeouts.html#cancellation-and-timeouts
任務追蹤
使用協(xié)程來處理任務對于很多代碼來說真的很方便。啟動協(xié)程,進行網(wǎng)絡請求,將結果寫入數(shù)據(jù)庫,一切都很自然流暢。
但有時候,可能會遇到稍微復雜點的問題,例如您需要在一個協(xié)程中同時處理兩個網(wǎng)絡請求,這種情況下需要啟動更多協(xié)程。
想要創(chuàng)建多個協(xié)程,可以在 suspend function 中使用名為 coroutineScope 或 supervisorScope 這樣的構造器來啟動多個協(xié)程。但是這個 API 說實話,有點令人困惑。coroutineScope 構造器和 CoroutineScope 這兩個的區(qū)別只是一個字符之差,但它們卻是完全不同的東西。
另外,如果隨意啟動新協(xié)程,可能會導致潛在的任務泄漏 (work leak)。調用方可能感知不到啟用了新的協(xié)程,也就意味著無法對其進行追蹤。
為了解決這個問題,結構化并發(fā)發(fā)揮了作用,它保證了當 suspend 函數(shù)返回時,就意味著它所處理的任務也都已完成。
結構化并發(fā)保證了當 suspend 函數(shù)返回時,它所處理任務也都已完成。
示例使用 coroutineScope 來獲取兩個文檔內(nèi)容:
- suspend fun fetchTwoDocs() {
- coroutineScope {
- launch { fetchDoc(1) }
- async { fetchDoc(2) }
- }
- }
在這個示例中,同時從網(wǎng)絡中獲取兩個文檔數(shù)據(jù),第一個是通過 launch 這樣 "一勞永逸" 的方式啟動協(xié)程,這意味著它不會返回任何結果給調用方。
第二個是通過 async 的方式獲取文檔,所以是會有返回值返回的。不過上面示例有一點奇怪,因為通常來講兩個文檔的獲取都應該使用 async,但這里我僅僅是想舉例來說明可以根據(jù)需要來選擇使用 launch 還是 async,或者是對兩者進行混用。
coroutineScope 和 supervisorScope 可以讓您安全地從 suspend 函數(shù)中啟動協(xié)程。
但是請注意,這段代碼不會顯式地等待所創(chuàng)建的兩個協(xié)程完成任務后才返回,當 fetchTwoDocs 返回時,協(xié)程還正在運行中。
所以,為了做到結構化并發(fā)并避免泄漏的情況發(fā)生,我們想做到在諸如 fetchTwoDocs 這樣的 suspend 函數(shù)返回時,它們所做的所有任務也都能結束。換個說法就是,fetchTwoDocs 返回之前,它所啟動的所有協(xié)程也都能完成任務。
Kotlin 確保使用 coroutineScope 構造器不會讓 fetchTwoDocs 發(fā)生泄漏,coroutinScope 會先將自身掛起,等待它內(nèi)部啟動的所有協(xié)程完成,然后再返回。因此,只有在 coroutineScope 構建器中啟動的所有協(xié)程完成任務之后,fetchTwoDocs 函數(shù)才會返回。
- coroutineScope:https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/coroutine-scope.html
- supervisorScope:https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/supervisor-scope.html
處理一堆任務
既然我們已經(jīng)做到了追蹤一兩個協(xié)程,那么來個刺激的,追蹤一千個協(xié)程來試試!
先看看下面這個動畫

這個動畫向我們展示了如何同時發(fā)出一千個網(wǎng)絡請求。當然,在真實的 Android 開發(fā)中最好別這么做,太浪費資源了。
這段代碼中,我們在 coroutineScope 構造器中使用 launch 啟動了一千個協(xié)程,您可以看到這一切是如何聯(lián)系到一起的。由于我們使用的是 suspend 函數(shù),因此代碼一定使用了 CoroutineScope 創(chuàng)建了協(xié)程。我們目前對這個 CoroutineScope 一無所知,它可能是viewModelScope 或者是其他地方定義的某個 CoroutineScope,但不管怎樣,coroutineScope 構造器都會使用它作為其創(chuàng)建新的 scope 的父級。
然后,在 coroutineScope 代碼塊內(nèi),launch 將會在新的 scope 中啟動協(xié)程,隨著協(xié)程的啟動完成,scope 會對其進行追蹤。最后,一旦所有在 coroutineScope 內(nèi)啟動的協(xié)程都完成后,loadLots 方法就可以輕松地返回了。
注意:scope 和協(xié)程之間的父子關系是使用 Job 對象進行創(chuàng)建的。但是您不需要深入去了解,只要知道這一點就可以了。
coroutineScope 和 supervisorScope 將會等待所有的子協(xié)程都完成。
以上的重點是,使用 coroutineScope 和 supervisorScope 可以從任何 suspend function 來安全地啟動協(xié)程。即使是啟動一個新的協(xié)程,也不會出現(xiàn)泄漏,因為在新的協(xié)程完成之前,調用方始終處于掛起狀態(tài)。
更厲害的是,coroutineScope 將會創(chuàng)建一個子 scope,所以一旦父 scope 被取消,它會將取消的消息傳遞給所有新的協(xié)程。如果調用方是 viewModelScope,這一千個協(xié)程在用戶離開界面后都會自動被取消掉,非常整潔高效。
在繼續(xù)探討報錯 (error) 相關的問題之前,有必要花點時間來討論一下 supervisorScope 和 coroutineScope,它們的主要區(qū)別是當出現(xiàn)任何一個子 scope 失敗的情況,coroutineScope 將會被取消。如果一個網(wǎng)絡請求失敗了,所有其他的請求都將被立即取消,這種需求選擇 coroutineScope。相反,如果您希望即使一個請求失敗了其他的請求也要繼續(xù),則可以使用 supervisorScope,當一個協(xié)程失敗了,supervisorScope 是不會取消剩余子協(xié)程的。
協(xié)程失敗時發(fā)出報錯信號
在協(xié)程中,報錯信號是通過拋出異常來發(fā)出的,就像我們平常寫的函數(shù)一樣。來自 suspend 函數(shù)的異常將通過 resume 重新拋給調用方來處理。跟常規(guī)函數(shù)一樣,您不僅可以使用 try/catch 這樣的方式來處理錯誤,還可以構建抽象來按照您喜歡的方式進行錯誤處理。
但是,在某些情況下,協(xié)程還是有可能會弄丟獲取到的錯誤的。
- val unrelatedScope = MainScope()
- // 丟失錯誤的例子
- suspend fun lostError() {
- // 未使用結構化并發(fā)的 async
- unrelatedScope.async {
- throw InAsyncNoOneCanHearYou("except")
- }
- }
注意:上述代碼聲明了一個無關聯(lián)協(xié)程作用域,它將不會按照結構化并發(fā)的方式啟動新的協(xié)程。還記得我在一開始說的結構化并發(fā)是一系列編程語言特性和實踐指南的集合,在 suspend 函數(shù)中引入無關聯(lián)協(xié)程作用域違背了結構化并發(fā)規(guī)則。
在這段代碼中錯誤將會丟失,因為 async 假設您最終會調用 await 并且會重新拋出異常,然而您并沒有去調用 await,所以異常就永遠在那等著被調用,那么這個錯誤就永遠不會得到處理。
結構化并發(fā)保證當一個協(xié)程出錯時,它的調用方或作用域會被通知到。
如果您按照結構化并發(fā)的規(guī)范去編寫上述代碼,錯誤就會被正確地拋給調用方處理。
- suspend fun foundError() {
- coroutineScope {
- async {
- throw StructuredConcurrencyWill("throw")
- }
- }
- }
coroutineScope 不僅會等到所有子任務都完成才會結束,當它們出錯時它也會得到通知。如果一個通過 coroutineScope 創(chuàng)建的協(xié)程拋出了異常,coroutineScope 會將其拋給調用方。因為我們用的是coroutineScope 而不是 supervisorScope,所以當拋出異常時,它會立刻取消所有的子任務。
使用結構化并發(fā)
在這篇文章中,我介紹了結構化并發(fā),并展示了如何讓我們的代碼配合 Android 中的 ViewModel 來避免出現(xiàn)任務泄漏。
同樣,我還幫助您更深入去理解和使用 suspend 函數(shù),通過確保它們在函數(shù)返回之前完成任務,或者是通過暴露異常來確保它們正確發(fā)出錯誤信號。
如果我們使用了不符合結構化并發(fā)的代碼,將會很容易出現(xiàn)協(xié)程泄漏,即調用方不知如何追蹤任務的情況。這種情況下,任務是無法取消的,同樣也不能保證異常會被重新拋出來。這樣會使得我們的代碼很難理解,并可能會導致一些難以追蹤的 bug 出現(xiàn)。
您可以通過引入一個新的不相關的 CoroutineScope (注意是大寫的 C),或者是使用 GlobalScope 創(chuàng)建的全局作用域,但是這種方式的代碼不符合結構化并發(fā)要求的方式。
但是當出現(xiàn)需要協(xié)程比調用方的生命周期更長的情況時,就可能需要考慮非結構化并發(fā)的編碼方式了,只是這種情況比較罕見。因此,使用結構化編程來追蹤非結構化的協(xié)程,并進行錯誤處理和任務取消,將是非常不錯的做法。
如果您之前一直未按照結構化并發(fā)的方法編碼,一開始確實一段時間去適應。這種結構確實保證與 suspend 函數(shù)交互更安全,使用起來更簡單。在編碼過程中,盡可能多地使用結構化并發(fā),這樣讓代碼更易于維護和理解。
在本文的開始列舉了結構化并發(fā)為我們解決的三個問題:
- 取消任務 —— 當某項任務不再需要時取消它;
- 追蹤任務 —— 當任務正在執(zhí)行時,追蹤它;
- 發(fā)出錯誤信號 —— 當協(xié)程失敗時,發(fā)出錯誤信號表明有錯誤發(fā)生。
實現(xiàn)這種結構化并發(fā),會為我們的代碼提供一些保障:
- 作用域取消時,它內(nèi)部所有的協(xié)程也會被取消;
- suspend 函數(shù)返回時,意味著它的所有任務都已完成;
- 協(xié)程報錯時,它所在的作用域或調用方會收到報錯通知。
總結來說,結構化并發(fā)讓我們的代碼更安全,更容易理解,還避免了出現(xiàn)任務泄漏的情況。
下一步
本篇文章,我們探討了如何在 Android 的 ViewModel 中啟動協(xié)程,以及如何在代碼中運用結構化并發(fā),來讓我們的代碼更易于維護和理解。
在下一篇文章中,我們將探討如何在實際編碼過程中使用協(xié)程,感興趣的讀者請繼續(xù)關注我們的更新。
【本文是51CTO專欄機構“谷歌開發(fā)者”的原創(chuàng)稿件,轉載請聯(lián)系原作者(微信公眾號:Google_Developers)】