在 Android 開發中使用協程 | 上手指南

本文是介紹 Android 協程系列中的第二部分,這篇文章主要會介紹如何使用協程來處理任務,而且能在任務開始執行後保持對它的追蹤。

保持對協程的追蹤

本系列文章的第一篇,咱們探討了協程適合用來解決哪些問題。這裏再簡單回顧一下,協程適合解決如下兩個常見的編程問題:html

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

協程經過在常規函數之上增長 suspend 和 resume 兩個操做來解決上述問題。當某個特定的線程上的全部協程被 suspend 後,該線程即可騰出資源去處理其餘任務。android

協程自身並不可以追蹤正在處理的任務,可是有成百上千個協程並對它們同時執行掛起操做並無太大問題。協程是輕量級的,但處理的任務卻不必定是輕量的,好比讀取文件或者發送網絡請求。git

使用代碼來手動追蹤上千個協程是很是困難的,您能夠嘗試對全部協程進行跟蹤,手動確保它們都完成了或者都被取消了,那麼代碼會臃腫且易出錯。若是代碼不是很完美,就會失去對協程的追蹤,也就是所謂 "work leak" 的狀況。github

任務泄漏 (work leak) 是指某個協程丟失沒法追蹤,它相似於內存泄漏,但比它更加糟糕,這樣丟失的協程能夠恢復本身,從而佔用內存、CPU、磁盤資源,甚至會發起一個網絡請求,而這也意味着它所佔用的這些資源都沒法獲得重用。數據庫

泄漏協程會浪費內存、CPU、磁盤資源,甚至發送一個無用的網絡請求。編程

爲了可以避免協程泄漏,Kotlin 引入了結構化併發 (structured concurrency) 機制,它是一系列編程語言特性和實踐指南的結合,遵循它能幫助您追蹤到全部運行於協程中的任務。安全

在 Android 平臺上,咱們可使用結構化併發來作到如下三件事:bash

  1. 取消任務 —— 當某項任務再也不須要時取消它;
  2. 追蹤任務 —— 當任務正在執行時,追蹤它;
  3. 發出錯誤信號 —— 當協程失敗時,發出錯誤信號代表有錯誤發生。

接下來咱們對以上幾點一一進行探討,看看結構化併發是如何幫助可以追蹤全部協程,而不會致使泄漏出現的。網絡

藉助 scope 來取消任務

在 Kotlin 中,定義協程必須指定其 CoroutineScope 。CoroutineScope 能夠對協程進行追蹤,即便協程被掛起也是如此。同第一篇文章中講到的調度程序 (Dispatcher) 不一樣,CoroutineScope 並不運行協程,它只是確保您不會失去對協程的追蹤。架構

爲了確保全部的協程都會被追蹤,Kotlin 不容許在沒有使用 CoroutineScope 的狀況下啓動新的協程。CoroutineScope 可被看做是一個具備超能力的 ExecutorService 的輕量級版本。它能啓動新的協程,同時這個協程還具有咱們在第一部分所說的 suspend 和 resume 的優點。

CoroutineScope 會跟蹤全部協程,一樣它還能夠取消由它所啓動的全部協程。這在 Android 開發中很是有用,好比它可以在用戶離開界面時中止執行協程。

CoroutineScope 會跟蹤全部協程,而且能夠取消由它所啓動的全部協程。

啓動新的協程

須要特別注意的是,您不能隨便就在某個地方調用 suspend 函數,suspend 和 resume 機制要求您從常規函數中切換到協程。

有兩種方式可以啓動協程,它們分別適用於不一樣的場景:

  1. launch 構建器適合執行 "一勞永逸" 的工做,意思就是說它能夠啓動新協程而不將結果返回給調用方;
  2. async 構建器可啓動新協程並容許您使用一個名爲 await 的掛起函數返回 result。

一般,您應使用 launch 從常規函數中啓動新協程。由於常規函數沒法調用 await (記住,它沒法直接調用 suspend 函數),因此將 async 做爲協程的主要啓動方法沒有多大意義。稍後咱們會討論應該如何使用 async。

您應該改成使用 coroutine scope 調用 launch 方法來啓動協程。

scope.launch {
    // 這段代碼在做用域裏啓動了一個新協程
   // 它能夠調用掛起函數
   fetchDocs()
}
複製代碼

您能夠將 launch 看做是將代碼從常規函數送往協程世界的橋樑。在 launch 函數體內,您能夠調用 suspend 函數並可以像咱們上一篇介紹的那樣保證主線程安全。

Launch 是將代碼從常規函數送往協程世界的橋樑。

注意: launch 和 async 之間的很大差別是它們對異常的處理方式不一樣。async 指望最終是經過調用 await 來獲取結果 (或者異常),因此默認狀況下它不會拋出異常。這意味着若是使用 async 啓動新的協程,它會靜默地將異常丟棄。

因爲 launch 和 async 僅可以在 CouroutineScope 中使用,因此任何您所建立的協程都會被該 scope 追蹤。Kotlin 禁止您建立不可以被追蹤的協程,從而避免協程泄漏。

在 ViewModel 中啓動協程

既然 CoroutineScope 會追蹤由它啓動的全部協程,而 launch 會建立一個新的協程,那麼您應該在什麼地方調用 launch 並將其放在 scope 中呢? 又該在何時取消在 scope 中啓動的全部協程呢?

在 Android 平臺上,您能夠將 CoroutineScope 實現與用戶界面相關聯。這樣可以讓您避免泄漏內存或者對再也不與用戶相關的 Activities 或 Fragments 執行額外的工做。當用戶經過導航離開某界面時,與該界面相關的 CoroutineScope 能夠取消掉全部不須要的任務。

結構化併發可以保證當某個做用域被取消後,它內部所建立的全部協程也都被取消。

當將協程同 Android 架構組件 (Android Architecture Components) 集成起來時,您每每會須要在 ViewModel 中啓動協程。由於大部分的任務都是在這裏開始進行處理的,因此在這個地方啓動是一個很合理的作法,您也不用擔憂旋轉屏幕方向會終止您所建立的協程。

從生命週期感知型組件 (AndroidX Lifecycle) 的 2.1.0 版本開始 (發佈於 2019 年 9 月),咱們經過添加擴展屬性 ViewModel.viewModelScope 在 ViewModel 中加入了協程的支持。

推薦您閱讀 Android 開發者文檔 "將 Kotlin 協程與架構組件一塊兒使用" 瞭解更多。

看看以下示例:

class MyViewModel(): ViewModel() {
    fun userNeedsDocs() {
       // 在 ViewModel 中啓動新的協程
        viewModelScope.launch {
            fetchDocs()
        }
    }
}
複製代碼

當 viewModelScope 被清除 (當 onCleared() 回調被調用時) 以後,它將自動取消它所啓動的全部協程。這是一個標準作法,若是一個用戶在還沒有獲取到數據時就關閉了應用,這時讓請求繼續完成就純粹是在浪費電量。

爲了提升安全性,CoroutineScope 會進行自行傳播。也就是說,若是某個協程啓動了另外一個新的協程,它們都會在同一個 scope 中終止運行。這意味着,即便當某個您所依賴的代碼庫從您建立的 viewModelScope 中啓動某個協程,您也有方法將其取消。

注意: 協程被掛起時,系統會以拋出 CancellationException 的方式協做取消協程。捕獲頂級異常 (如Throwable) 的異常處理程序將捕獲此異常。若是您作異常處理時消費了這個異常,或從未進行 suspend 操做,那麼協程將會徘徊於半取消 (semi-canceled) 狀態下。

因此,當您須要將一個協程同 ViewModel 的生命週期保持一致時,使用 viewModelScope 來從常規函數切換到協程中。而後,viewModelScope 會自動爲您取消協程,所以在這裏哪怕是寫了死循環也是徹底不會產生泄漏。以下示例:

fun runForever() {
    // 在 ViewModel 中啓動新的協程
    viewModelScope.launch {
        // 當 ViewModel 被清除後,下列代碼也會被取消
        while(true) {
            delay(1_000)
           // 每過 1 秒作點什麼
        }
    }
}
複製代碼

經過使用 viewModelScope,能夠確保全部的任務,包含死循環在內,均可以在不須要的時候被取消掉。

任務追蹤

使用協程來處理任務對於不少代碼來講真的很方便。啓動協程,進行網絡請求,將結果寫入數據庫,一切都很天然流暢。

但有時候,可能會遇到稍微複雜點的問題,例如您須要在一個協程中同時處理兩個網絡請求,這種狀況下須要啓動更多協程。

想要建立多個協程,能夠在 suspend function 中使用名爲 coroutineScopesupervisorScope 這樣的構造器來啓動多個協程。可是這個 API 說實話,有點使人困惑。coroutineScope 構造器和 CoroutineScope 這兩個的區別只是一個字符之差,但它們倒是徹底不一樣的東西。

另外,若是隨意啓動新協程,可能會致使潛在的任務泄漏 (work leak)。調用方可能感知不到啓用了新的協程,也就意味着沒法對其進行追蹤。

爲了解決這個問題,結構化併發發揮了做用,它保證了當 suspend 函數返回時,就意味着它所處理的任務也都已完成。

結構化併發保證了當 suspend 函數返回時,它所處理任務也都已完成。

示例使用 coroutineScope 來獲取兩個文檔內容:

suspend fun fetchTwoDocs() {
    coroutineScope {
        launch { fetchDoc(1) }
        async { fetchDoc(2) }
    }
}
複製代碼

在這個示例中,同時從網絡中獲取兩個文檔數據,第一個是經過 launch 這樣 "一勞永逸" 的方式啓動協程,這意味着它不會返回任何結果給調用方。

第二個是經過 async 的方式獲取文檔,因此是會有返回值返回的。不過上面示例有一點奇怪,由於一般來說兩個文檔的獲取都應該使用 async,但這裏我僅僅是想舉例來講明能夠根據須要來選擇使用 launch 仍是 async,或者是對二者進行混用。

coroutineScope 和 supervisorScope 可讓您安全地從 suspend 函數中啓動協程。

可是請注意,這段代碼不會顯式地等待所建立的兩個協程完成任務後才返回,當 fetchTwoDocs 返回時,協程還正在運行中。

因此,爲了作到結構化併發並避免泄漏的狀況發生,咱們想作到在諸如 fetchTwoDocs 這樣的 suspend 函數返回時,它們所作的全部任務也都能結束。換個說法就是,fetchTwoDocs 返回以前,它所啓動的全部協程也都能完成任務。

Kotlin 確保使用 coroutineScope 構造器不會讓 fetchTwoDocs 發生泄漏,coroutinScope 會先將自身掛起,等待它內部啓動的全部協程完成,而後再返回。所以,只有在 coroutineScope 構建器中啓動的全部協程完成任務以後,fetchTwoDocs 函數纔會返回。

處理一堆任務

既然咱們已經作到了追蹤一兩個協程,那麼來個刺激的,追蹤一千個協程來試試!

先看看下面這個動畫:

這個動畫展現了 coroutineScope 是如何追蹤一千個協程的。

這個動畫向咱們展現瞭如何同時發出一千個網絡請求。固然,在真實的 Android 開發中最好別這麼作,太浪費資源了。

這段代碼中,咱們在 coroutineScope 構造器中使用 launch 啓動了一千個協程,您能夠看到這一切是如何聯繫到一塊兒的。因爲咱們使用的是 suspend 函數,所以代碼必定使用了 CoroutineScope 建立了協程。咱們目前對這個 CoroutineScope 一無所知,它多是viewModelScope 或者是其餘地方定義的某個 CoroutineScope,但無論怎樣,coroutineScope 構造器都會使用它做爲其建立新的 scope 的父級。

而後,在 coroutineScope 代碼塊內,launch 將會在新的 scope 中啓動協程,隨着協程的啓動完成,scope 會對其進行追蹤。最後,一旦全部在 coroutineScope 內啓動的協程都完成後,loadLots 方法就能夠輕鬆地返回了。

注意: scope 和協程之間的父子關係是使用 Job 對象進行建立的。可是您不須要深刻去了解,只要知道這一點就能夠了。

coroutineScope 和 supervisorScope 將會等待全部的子協程都完成。

以上的重點是,使用 coroutineScope 和 supervisorScope 能夠從任何 suspend function 來安全地啓動協程。即便是啓動一個新的協程,也不會出現泄漏,由於在新的協程完成以前,調用方始終處於掛起狀態。

更厲害的是,coroutineScope 將會建立一個子 scope,因此一旦父 scope 被取消,它會將取消的消息傳遞給全部新的協程。若是調用方是 viewModelScope,這一千個協程在用戶離開界面後都會自動被取消掉,很是整潔高效。

在繼續探討報錯 (error) 相關的問題以前,有必要花點時間來討論一下 supervisorScope 和 coroutineScope,它們的主要區別是當出現任何一個子 scope 失敗的狀況,coroutineScope 將會被取消。若是一個網絡請求失敗了,全部其餘的請求都將被當即取消,這種需求選擇 coroutineScope。相反,若是您但願即便一個請求失敗了其餘的請求也要繼續,則可使用 supervisorScope,當一個協程失敗了,supervisorScope 是不會取消剩餘子協程的。

協程失敗時發出報錯信號

在協程中,報錯信號是經過拋出異常來發出的,就像咱們日常寫的函數同樣。來自 suspend 函數的異常將經過 resume 從新拋給調用方來處理。跟常規函數同樣,您不只可使用 try/catch 這樣的方式來處理錯誤,還能夠構建抽象來按照您喜歡的方式進行錯誤處理。

可是,在某些狀況下,協程仍是有可能會弄丟獲取到的錯誤的。

val unrelatedScope = MainScope()
// 丟失錯誤的例子
suspend fun lostError() {
   // 未使用結構化併發的 async
    unrelatedScope.async {
        throw InAsyncNoOneCanHearYou("except")
    }
}
複製代碼

注意: 上述代碼聲明瞭一個無關聯協程做用域,它將不會按照結構化併發的方式啓動新的協程。還記得我在一開始說的結構化併發是一系列編程語言特性和實踐指南的集合,在 suspend 函數中引入無關聯協程做用域違背告終構化併發規則。

在這段代碼中錯誤將會丟失,由於 async 假設您最終會調用 await 而且會從新拋出異常,然而您並無去調用 await,因此異常就永遠在那等着被調用,那麼這個錯誤就永遠不會獲得處理。

結構化併發保證當一個協程出錯時,它的調用方或做用域會被通知到。

若是您按照結構化併發的規範去編寫上述代碼,錯誤就會被正確地拋給調用方處理。

suspend fun foundError() {
    coroutineScope {
        async { 
            throw StructuredConcurrencyWill("throw")
        }
    }
}
複製代碼

coroutineScope 不只會等到全部子任務都完成纔會結束,當它們出錯時它也會獲得通知。若是一個經過 coroutineScope 建立的協程拋出了異常,coroutineScope 會將其拋給調用方。由於咱們用的是coroutineScope 而不是 supervisorScope,因此當拋出異常時,它會馬上取消全部的子任務。

使用結構化併發

在這篇文章中,我介紹告終構化併發,並展現瞭如何讓咱們的代碼配合 Android 中的 ViewModel 來避免出現任務泄漏。

一樣,我還幫助您更深刻去理解和使用 suspend 函數,經過確保它們在函數返回以前完成任務,或者是經過暴露異常來確保它們正確發出錯誤信號。

若是咱們使用了不符合結構化併發的代碼,將會很容易出現協程泄漏,即調用方不知如何追蹤任務的狀況。這種狀況下,任務是沒法取消的,一樣也不能保證異常會被從新拋出來。這樣會使得咱們的代碼很難理解,並可能會致使一些難以追蹤的 bug 出現。

您能夠經過引入一個新的不相關的 CoroutineScope (注意是大寫的 C),或者是使用 GlobalScope 建立的全局做用域,可是這種方式的代碼不符合結構化併發要求的方式。

可是當出現須要協程比調用方的生命週期更長的狀況時,就可能須要考慮非結構化併發的編碼方式了,只是這種狀況比較罕見。所以,使用結構化編程來追蹤非結構化的協程,並進行錯誤處理和任務取消,將是很是不錯的作法。

若是您以前一直未按照結構化併發的方法編碼,一開始確實一段時間去適應。這種結構確實保證與 suspend 函數交互更安全,使用起來更簡單。在編碼過程當中,儘量多地使用結構化併發,這樣讓代碼更易於維護和理解。

在本文的開始列舉告終構化併發爲咱們解決的三個問題:

  1. 取消任務 —— 當某項任務再也不須要時取消它;
  2. 追蹤任務 —— 當任務正在執行時,追蹤它;
  3. 發出錯誤信號 —— 當協程失敗時,發出錯誤信號代表有錯誤發生。

實現這種結構化併發,會爲咱們的代碼提供一些保障:

  1. 做用域取消時,它內部全部的協程也會被取消
  2. suspend 函數返回時,意味着它的全部任務都已完成
  3. 協程報錯時,它所在的做用域或調用方會收到報錯通知

總結來講,結構化併發讓咱們的代碼更安全,更容易理解,還避免了出現任務泄漏的狀況。

下一步

本篇文章,咱們探討了如何在 Android 的 ViewModel 中啓動協程,以及如何在代碼中運用結構化併發,來讓咱們的代碼更易於維護和理解。

在下一篇文章中,咱們將探討如何在實際編碼過程當中使用協程,感興趣的讀者請繼續關注咱們的更新。

相關文章
相關標籤/搜索