[譯] 在 Android 使用協程(part II) - 入門指南

這篇文章是「怎麼在 Android 上使用協程」系列文章的第二篇。這篇文章的重點是啓動工做和跟蹤已經啓動的工做。html

上一篇內容 :協程的背景知識android

跟蹤協程(Keeping track of coroutines)

在上一篇中,咱們探討了協程擅長解決的問題。總結一下協程是解決兩個常見編程問題的好方法:git

  1. 防止耗時任務在主線程運行太久,阻塞主線程
  2. 能夠從主線程上安全地去調用網絡或磁盤操做

爲了解決這些問題,協程在常規函數的基礎上添加了 suspendresume。當一個特定線程上的全部協程被掛起時,該線程能夠自由地執行其餘工做。github

然而,協程自己並不能幫你跟蹤正在進行的工做。建立大量協程(數百個甚至數千個)並同時掛起它們是徹底沒問題的。並且,雖然協程很成本很低,可是它們一般執行的都是花費比較大的工做,像讀取文件或者發出網絡請求。數據庫

若是用代碼手動管理一千個協程是至關困難的。你能夠嘗試跟蹤它們,而且手動確保它們完成或取消,可是像這樣的代碼很單調,並且容易出錯。若是代碼不完美,它將失去對協程的追蹤,這就是我所說的工做泄露(a work leak)編程

工做泄露像內存泄露,可是更糟糕。這是一個丟失的協程。除了使用內存外,工做泄露還能夠恢復自身以使用 CPU、磁盤甚至網絡請求。api

一個協程泄露會消耗內存、CPU、硬盤或發送一個不須要的網絡請求。安全

A leaked coroutine can waste memory, CPU, disk, or even launch a network request that’s not needed.網絡

Kotlin 引入了 結構化併發來幫助避免協程泄露。結構化併發是語言特性和最佳實踐的結合,遵循這些特性和最佳實踐能夠幫助你跟蹤程序中運行的全部工做。架構

在 Android上,咱們可使用結構化併發作三件事:

  1. 當再也不須要的時候 取消工做
  2. 在工做運行的時候 跟蹤
  3. 協程失敗的時候 發出錯誤

讓咱們深刻研究它們,看看結構化併發如何幫助咱們永遠不會漏掉協程。

使用做用域取消工做

在 Kotlin 中,協程必須運行在一些叫作協程做用域 (CoroutineScope) 的東西里。一個協程做用域會跟蹤你的協程,即便協程是被掛起的。和第一篇文章裏講的 Dispatchers 不同的是,它實際上不會執行你的協程——它只是確保你不會把協程搞丟。

爲了確保全部的協程都能追蹤到,Kotlin 不容許你在協程做用域以外建立新的協程。你能夠把協程做用域想象成一個有超能力(superpowers?)的輕量版線程池。它賦予你啓動新協程的能力,這些協程具備暫停和恢復的能力,咱們在第一篇的時候講過。

協程做用域會跟蹤全部的協程,它能夠把在裏面運行的全部協程都取消。這很是適合 Android 開發,當你須要確保用戶在離開時清除由打開界面而啓動的全部東西。

協程做用域會跟蹤全部的協程,它能夠把在裏面運行的全部協程都取消。

A CoroutineScope keeps track of all your coroutines, and it can cancel all of the coroutines started in it.

啓動新協程

須要注意的是,你不能在任何地方調用掛起函數。掛起和恢復機制要求你從一個普通方法切換到一個協程。

下面有兩種不一樣的啓動協程的方式:

  1. launch 將啓動一個新的"發射後無論(fire-and-forget)"的協程——也就是說它不會返回結果給調用者
  2. async 將啓動一個新的協程,它容許你用 await 來返回一個掛起函數的結果

幾乎全部狀況下,在常規函數裏都是用 launch 啓動協程。因爲常規函數沒法調用 awiat(記住它不能直接調用 掛起函數),因此使用 async 做爲協程的入口沒什麼意義,我接下來會講何時用 async 有意義。

調用 launch 建立一個做用域用來啓動一個協程。

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

你能夠將 launch 當作是將代碼從常規函數帶到協程世界的橋樑。在 launch 代碼塊內,你能夠調用掛起函數,咱們在上篇內容中講過。

Launch 是一個將常規函數化作協程的橋樑。

Launch is a bridge from regular functions into coroutines.

注意:launchasync 最大的不一樣是它們處理異常的方式。async 會在你最終調用 await 的時候來得到一個結果(或異常),調用以前不會拋出異常。這意味着,若是你使用async啓動一個新的協程,它不會直接拋出異常。

因爲luanchasync 只能在協程做用域上使用,因此,你懂得,你常見的全部協程都是中由一個做用域來跟蹤。Kotlin 不容許你建立一個沒被跟蹤的協程,這對於避免泄露有很大幫助。

在 ViewModel 啓動

因此,若是協程做用域能夠跟蹤全部在它裏面啓動的協程,而且launch 建立了一個新的協程,那麼應該在哪裏調用launch 而且放置做用域呢?再者,在何時取消一個做用域中全部已經啓動的協程纔是有意義的呢?

在 Android 上,將協程做用域與用戶界面關聯起來一般是有意義的。這可讓你避免泄露協程,或者爲已經不在前臺的 Activity 或 Fragment 繼續打工。當用戶從界面離開時,與界面相關的協程做用域就能夠取消所有工做。

結構化併發保證當前做用域取消時,它的全部協程都將取消。

Structured concurrency guarantees when a scope cancels , all of its coroutines cancel .

當將協程和 Android 架構體系組件集成時,你一般但願在 ViewModel 中啓動協程。把工做放在這裏是一個比較合適的地方——你不用擔憂轉屏會殺死全部的協程。

你能夠用 lifecycle-viewmodel-ktx:2.1.0-alpha04.viewModelScope 裏面的擴展屬性,在ViewModel 中使用協程。viewModelScope 即將在 AndroidX Lifecycle(v2.1.0) 中發佈,目前處於 alpha 版。你能夠在 @manuelvicnt 這篇博客閱讀更多關於它是怎麼工做的。因爲該庫目前處於 alpha 版,可能會有一些 bug。而且 api 可能會在最終的 release 發佈以前發生改變。若是發現任何 bug,能夠在這反饋

來看這個例子:

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

viewModelScope 會在對應的 ViewModel 清除的時候(onCleared() 被回調時)自動的清空全部的已啓動的協程。這是一個典型的好習慣——咱們尚未獲取到文檔的時候,而用戶已經把應用關了了,這時候還等待請求完成就是在浪費他們的電量(wasting their battery)。

爲了更加安全,協程做用域會自動傳遞。因此,若是你先開啓了一個協程,而後又開啓另外一個協程,它們最終會在同一個範圍裏。這意味着,即便你所依賴的庫從 viewModelScop 啓動了一個協程,你也有辦法取消它們!

注意:協程在被掛起時取消會拋出 CancellationException 異常。這個異常能夠被捕獲頂級異常(如 Throwable)的操做捕獲。若是你在捕獲後消費了異常,或者協程歷來沒掛起,則協程將處於半取消狀態。

所以,當你須要一個協程與 ViewModel 生命週期同樣長時,可使用viewModelScope 從常規函數切換到協程。而後,因爲viewModelScope 將自動爲你取消協程,因此在這裏寫一個死循環,而不會形成協程泄露。

fun runForever() {
    // 在 ViewModel 中開啓一個新協程
    viewModelScope.launch {
				// 當 ViewModel 清除時取消
        while(true) {
            delay(1_000)
            // do something every second
        }
    }
}
複製代碼

經過使用 viewModelScope ,你能夠確保在不須要時取消全部工做,即便是這個死循環。

跟蹤工做(Keep track of work)

對於不少代碼來講,啓動一個協程來處理是個好辦法。啓動協程,發送網絡請求,而且將結果寫入數據庫。

不過,有時候你的需求會更復雜一些。假如你想在一個協程中同時執行兩個網絡請求——這須要你啓動更多的協程來完成。

爲了生成更多的協程,任何掛起函數均可以經過使用另外一個名爲coroutineScope 的構建器或它的同級的監管做用域(supervisorScope)啓動更多的協程。老實說,這個 API 有點讓人昏惑。coroutineScope 構建器和 CoroutineScope 是不一樣的東西,儘管它們的名稱中只有一個字符不一樣。

處處啓動新的協程有致使工做泄露的隱患。調用者可能不知道會有新的協程,若是不知道,它又怎麼跟蹤工做呢?

結構化併發幫助咱們解決了這個問題。也就是說,它提供了一個保證,當掛起函數返回時,它的全部工做都完成了。

結構化併發保證當一個掛起函數返回時,它的全部工做都已經完成了。

Structured concurrency guarantees that when a suspend function returns, all of its work is done.

下面是一個使用coroutineScope 獲取兩個文檔的例子:

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

在這個例子中,同時從網絡上請求了兩個文檔。第一個請求使用了launch 的方式,它屬於"發射後無論"的——也就是說它不會把返回值給調用者。

第二個請求使用了async,因此把獲得的文檔返回給調用者。這個例子有點奇怪,由於正常狀況你會用async請求全部文檔——可是這是我想演示給你看,你能夠根據需求,混合使用luanchasync

協程做用域和監管做用域讓你能夠安全地調用掛起函數啓動協程。

coroutineScope and supervisorScope let you safely launch coroutines from suspend functions.

可是請注意,這段代碼沒有顯式地等待任何一個新的協程!看起來fetchTwoDocs 會在協程運行時立馬返回!

爲了實現結構化併發而且避免工做泄露,咱們但願確保當fetchTwoDocs 之類的掛起函數返回時,它的全部工做都已完成。這意味着它啓動的兩個協程必須在 fetchTwoDocs 返回以前完成。

Kotlin 使用協程做用域構建器確保工做不會從 fetchTwoDocs泄露。協程做用域構建器將掛起本身,直到它內部啓動的全部協程完成爲止。所以在協程做用域構建器中啓動的全部協程都完成以前,不會從fetchTwoDocs返回。

茫茫多的工做(Lots and lots of work)

限制咱們已經知道了跟蹤一個協程和跟蹤兩個協程,是時候展現真正的技術了,跟蹤一千個協程!

先看一眼下面的動畫:

經過動畫顯示一個協程怎麼跟蹤一千個協程

這個例子展現了同時發出了一千次網絡請求。固然,實際咱們不會在 Android 裏這麼作——這樣會消耗大量資源。

在這段代碼中,咱們在協程做用域構建器中啓動了 1000 個協程。你能夠看到事情是怎麼鏈接起來的。由於咱們在一個掛起函數中,因此某個地方的代碼必定使用了一個協程做用域來建立一個協程。咱們對這個協程做用域一無所知,它能夠是 viewModelScope,也能夠是在其餘地方定義的其餘協程做用域。不管調用的是什麼做用域,協程做用域構建器都將使用它做爲它建立的新做用域的父做用域。

而後在協程做用域塊中,launch將在新的做用域"中"啓動協程。隨着協程的啓動到結束,新的做用域會跟蹤它們。最後一旦協程做用域中全部啓動的協程完成,loadLots 就能夠自由的返回了。

注意:做用域和協程之間的父-子關係是使用 Job 對象建立的。可是一般你不須要深刻到這一層來考慮協程和範圍之間的關係。

協程做用域和監管做用域將等待子協程完成。

coroutineScope and supervisorScope will wait for child coroutines to complete.

底層發生了不少事——但重要的是,使用協程做用域或者監管做用域你能夠安全的從任何掛起函數啓動一個協程。即便它將啓動一個新的協程,也不會意外的產生泄露,由於你老是掛起調用者,知道新的協程完成。

真正酷的是協程做用域將建立子做用域。所以,若是父做用域被取消,它將把取消操做傳遞給全部新的協程。若是調用者是 viewModelScope ,那麼當用戶離開界面時,全部的 1000 個協程都會自動取消,很是簡潔!

在外面繼續討論錯誤以前,有必要花點時間討論一下監管做用域(supervisorScope) 和協程做用域。主要的區別就是,每當協程做用域的任何子做用域失敗時,它就會取消。所以若是一個網絡請求失敗,全部的請求都會當即被取消。相反,若是你想繼續其餘請求,即其中一個請求失敗了,你也可使用監管做用域。當其中某個子做用域失敗時,監管做用域不會將另外的子做用域也取消。

協程失敗時發送錯誤(Signal errors when a coroutine fails)

在協程中,經過拋出異常來發出錯誤,就像常規函數同樣。掛起函數中的異常將經過 恢復 從新跑出給調用者。就像使用常規函數同樣,你不受限於try/catch 來處理錯誤,若是你願意,還能夠構建抽象來用其餘方式來執行錯誤處理。

然而,在協程中也有可能丟失錯誤的狀況。

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

注意,這段代碼聲明瞭一個不相關的協程做用域,它將啓動一個脫離結構化併發的新協程。請記住,在開始時我說過,結構化併發是語言特性和最佳實踐的結合,在掛起函數中,引入不相關的協程範圍並不符合結構化併發最佳實踐。

這個錯誤在這段代碼中丟失了,由於 async 假設你最終將調用 await ,它將在那從新拋出異常。可是若是你歷來沒有調用await,那麼異常將永遠存儲在 await 中,直到被觸發。

結構化併發確保一個協程發生錯誤時,它的調用者或做用域會獲得通知。

Structured concurrency guarantees that when a coroutine errors, its caller or scope is notified.

若是在上面的代碼使用結構化併發,這個錯誤會正確拋出給調用者。

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

由於協程做用域會等待全部子(協程)完成,因此當它們失敗時,它也能夠獲得通知。若是由協程做用域啓動的協程拋出異常,協程做用域能夠將異常拋出給調用者。由於咱們使用的是協程做用域而不是監管做用域,因此當拋出異常時,它還會當即取消全部其餘子協程。

使用結構化併發

在這篇文章中,我介紹告終構化併發而且展現了它如何讓咱們的代碼與 Android 的 ViewModel 更好的配合,以免泄露。

還討論瞭如何使掛起函數更容易理解。既要確保它們在返回以前完成工做,又要確保它們經過顯式的異常拋出錯誤。

相反,若是咱們使用非結構化併發,協程很容易意外的泄露調用者不知道的工做。該工做不能取消,也不能保證會從新拋出異常。這將讓咱們的代碼更詭異,並可能產生模糊的 Bug。

你能夠經過引入一個新的不相關的協程做用域或使用一個名爲GlobalScope 的全局做用域來建立非結構化併發,可是你應該只在極少數狀況下考慮非結構化併發,由於你須要協程比調用做用域的生命週期更長。而後本身添加結構是個好方法,以確保跟蹤非結構化協程,處理錯誤,而且可以很好的取消。

若是你有非結構化編程的經驗,那麼結構化併發確實須要一些時間來適應。它的結構和保證讓它更安全,更容易與掛起功能交互。儘量多地使用結構化併發是一個好主意,由於它有主語使代碼更容易閱讀,並且更不使人奇怪。

在這篇文章的開頭,我列出告終構化併發爲咱們解決的三件事:

  1. 當再也不須要的時候 取消工做
  2. 在工做運行的時候 跟蹤
  3. 協程失敗的時候 發出錯誤

要完成這種結構化併發,咱們須要對代碼提供一些保證。下面是結構化併發的保證。

  1. 當一個做用域取消時,它全部的協程都被取消
  2. 當一個掛起函數返回時,全部的工做都已經完成
  3. 當一個協程發生錯誤時,它的調用者或做用域會獲得通知

總之,結構化併發的保證使咱們的代碼更安全、更容易理解,並容許咱們避免泄露。

What's next?

在這篇文章中,咱們探討了如何在 Android 的 ViewModel 中啓動協程,以及如何處理結構化併發,以讓咱們的代碼不會很詭異。

在下一篇文章中,咱們將更多地討論如何在實際狀況中使用協程!

相關文章
相關標籤/搜索