在 Android 上使用協程(二):Getting started

秉心說TM發表於2019-05-30

原文作者 :Sean McQuillan

原文地址: Coroutines on Android (part II): Getting started

譯者 : 秉心說

這是關於在 Android 中使用協程的一系列文章。本篇的重點是開始任務以及追蹤已經開始的任務。

上一篇 :

在 Android 上使用協程(一):Getting The Background

追蹤協程

在上篇文章中,我們探索了協程擅長解決的問題。通常,協程對於下面兩個常見的程式設計問題來說都是不錯的解決方案:

  1. 耗時任務,執行時間過長阻塞主執行緒
  2. 主執行緒安全,允許你在主執行緒中呼叫任意 suspend(掛起) 函式

為了解決這些問題,協程基於基礎函式新增了 suspendresume。當特定執行緒上的所有協程都被掛起,該執行緒就可以做其他工作了。

但是,協程本身並不能幫助你追蹤正在進行的任務。同時擁有並掛起數百甚至上千的協程是不可能的。儘管協程是輕量的,但它們執行的任務並不是,例如檔案讀寫,網路請求等。

使用程式碼手動追蹤一千個協程的確是很困難的。你可以嘗試去追蹤它們,並且手動保證它們最後會完成或者取消,但是這樣的程式碼冗餘,而且容易出錯。如果你的程式碼不夠完美,你將失去對一個協程的追蹤,我把它稱之為任務洩露。

任務洩露就像記憶體洩露一樣,而且更加糟糕。對於已經丟失洩露的協程,除了記憶體消耗之外,它還會恢復自己來消耗 CPU,磁碟,甚至啟動一個網路請求。

洩露的協程會浪費記憶體,CPU,磁碟,甚至傳送一個不需要的網路請求。

為了避免洩露協程,Kotlin 引入了 structured concurrency(結構化併發)。結構化並集合了語言特性和最佳實踐,遵循這個原則將幫助你追蹤協程中的所有任務。

在 Android 中,我們使用結構化併發可以做三件事:

  1. 取消不再需要的任務
  2. 追蹤所有正在進行的任務
  3. 協程失敗時的錯誤訊號

讓我們深入探討這幾點,來看看結構化併發是如何幫助我們避免丟失對協程的追蹤以及任務洩露。

通過作用域取消任務

在 Kotlin 中,協程必須執行在 CoroutineScope 中。CoroutineScope 會追蹤你的協程,即使協程已經被掛起。不同於上一篇文章中說過的 Dispatchers,它實際上並不執行協程,它僅僅只是保證你不會丟失對協程的追蹤。

為了保證所有的協程都被追蹤到,Kotlin 不允許你在沒有 CoroutineScope 的情況下開啟新的協程。你可以把 CoroutineScope 想象成具有特殊能力的輕量級的 ExecutorServicce。它賦予你建立新協程的能力,這些協程都具備我們在上篇文章中討論過的掛起和恢復的能力。

CoroutineScope 會追蹤所有的協程,並且它也可以取消所有由他開啟的協程。這很適合 Android 開發者,當使用者離開當前頁面後,可以保證清理掉所有已經開啟的東西。

CoroutineScope 會追蹤所有的協程,並且它也可以取消所有由他開啟的協程。

啟動新的協程

有一點需要注意的是,你不是在任何地方都可以呼叫掛起函式。掛起和恢復機制要求你從普通函式切換到協程。

啟動協程有兩種方法,且有不同的用法:

  1. 使用 launch 協程構建器啟動一個新的協程,這個協程是沒返回值的
  2. 使用 async 協程構建器啟動一個新的協程,它允許你返回一個結果,通過掛起函式 await 來獲取。

在大多數情況下,如何從一個普通函式啟動協程的答案都是使用 launch。因為普通函式是不能呼叫 await 的(記住,普通函式不能直接呼叫掛起函式)。稍後我們會討論什麼時候應該使用 async

你應該呼叫 launch 來使用協程作用域啟動一個新的協程。

scope.launch {
    // This block starts a new coroutine
    // "in" the scope.
    //
    // It can call suspend functions
    fetchDocs()
}
複製程式碼

你可以把 launch 想象成一座橋樑,連線了普通函式中的程式碼和協程的世界。在 launch 內部,你可以呼叫掛起函式,並且建立主執行緒安全性,就像上篇文章中提到的那樣。

Launch 是把普通函式帶進協程世界的橋樑。

提示:launchasync 很大的一個區別是異常處理。async 期望你通過呼叫 await 來獲取結果(或異常),所以它預設不會丟擲異常。這就意味著使用 async 啟動新的協程,它會悄悄的把異常丟棄。

由於 launchasync 只能在 CoroutineScope 中使用,所以你建立的每一個協程都會被協程作用域追蹤。Kotlin 不允許你建立未被追蹤的協程,這樣可以有效避免任務洩露。

在 ViewModel 中啟動

如果一個 CoroutineScope 追蹤在其中啟動的所有協程,launch 會新建一個協程,那麼你應該在何處呼叫 launch 並將其置於協程作用域中呢?還有,你應該在什麼時候取消在作用域中啟動的所有協程呢?

在 Android 中,通常將 CoroutineScope 和使用者介面相關聯起來。這將幫助你避免協程洩露,並且使得使用者不再需要的 Activity 或者 Fragment 不再做額外的工作。當使用者離開當前頁面,與頁面相關聯的 CoroutineScope 將取消所有工作。

結構化併發保證當協程作用域取消,其中的所有協程都會取消。

當通過 Android Architecture Components 整合協程時,一般都是在 ViewModel 中啟動協程。這裡是許多重要任務開始工作的地方,並且你不必擔心旋轉螢幕會殺死協程。

為了在 ViewModel 中使用協程,你可以來自 lifecycle-viewmodel-ktx:2.1.0- alpha04 這個庫的 viewModelScopeviewModelScope 即將在 Android Lifecycle v2.1.0 釋出,現在仍然是 alpha 版本。關於 viewModelScope 的原理可以閱讀 這篇部落格。既然這個庫目前還是 alpha 版本,就可能會有 bug,API 也可能發生變動。如果你找到了 bug,可以在 這裡 提交。

看一下使用的例子:

class MyViewModel(): ViewModel() {
    fun userNeedsDocs() {
        // Start a new coroutine in a ViewModel
        viewModelScope.launch {
            fetchDocs()
        }
    }
}
複製程式碼

viewModelScope 被清除(即 onCleared() 被呼叫)時,它會自動取消由它啟動的所有協程。這肯定是正確的行為,當我們還沒有讀取到文件,使用者已經關閉了 app,我們還繼續請求的話只是在浪費電量。

為了更高的安全性,協程作用域會自動傳播。如果你啟動的協程中又啟動了另一個協程,它們最終會在同一個作用域中結束。這就意味著你依賴的庫通過你的 viewModelScope 啟動了新的協程,你就有辦法取消它們了!

Warning: Coroutines are cancelled cooperatively by throwing a CancellationException when the coroutine is suspended. Exception handlers that catch a top-level exception like Throwable will catch this exception. If you consume the exception in an exception handler, or never suspend, the coroutine will linger in a semi-canceled state.(這段沒有理解)

所以,當你需要協程和 ViewModel 的生命週期保持一致時,使用 viewModelScope 來從普通函式切換到協程。那麼,由於 viewModelScope 會自動取消協程,編寫下面這樣的無限迴圈是沒有問題的,不會造成洩露。

fun runForever() {
    // start a new coroutine in the ViewModel
    viewModelScope.launch {
        // cancelled when the ViewModel is cleared
        while(true) {
        delay(1_000)
        // do something every second
        }
    }
}
複製程式碼

使用 viewModelScope,你可以確保任何工作,即使是死迴圈,都能在不再需要執行的時候將其取消。

追蹤任務

啟動一個協程是沒問題的,很多時候也正是這樣做的。通過一個協程,進行網路請求,儲存資料到資料庫。

有時候,情況會稍微有點複雜。如果你想在一個協程中同時進行兩個網路請求,你就需要啟動更多的協程。

為了啟動更多的協程,任何掛起函式都可以使用 coroutineScope 或者 supervisorScope 構建器來新建協程。這個 API,說實話有點讓人困惑。coroutineScope 構建器和 CoroutineScope 是兩個不同的東西,卻只有一個字母不一樣。

在任何地方啟動新協程,這可能會導致潛在的任務洩露。呼叫者可能都不知道新協程的啟動,它又如何其跟蹤呢?

結構化併發幫助我們解決了這個問題。它給我們提供了一個保障,保證當掛起函式返回時,它的所有工作都已經完成。

結構化併發保證當掛起函式返回時,它的所有任務都已經完成。

下面是使用 coroutineScope 來查詢文件的例子:

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

在這個例子中,同時從網路讀取兩個文件。第一個是在由 launch 啟動的協程中執行,它不會給呼叫者返回任何結果。

第二個使用的是 async,所以文件可以返回給呼叫者。這裡例子有點奇怪,通常兩個文件都會使用 async。但是我只是想向你展示你可以根據你的需求混合使用 launchasync

coroutineScope 和 supervisorScope 讓你可以安全的在掛起函式中啟動協程。

儘管上面的程式碼沒有在任何地方顯示的宣告要等待協程的執行完成,看起來當協程還在執行的時候,fetDocs 方法就會返回。

為了結構化併發和避免任務洩露,我們希望確保當掛起函式(例如 fetchDocs)返回時,它的所有任務都已經完成。這就意味著,由 fetchDocs 啟動的所有協程都會先於它返回之前執行結束。

Kotlin 通過 coroutineScope 構建器確保 fetchDocs 中的任務不會洩露。coroutineScope 構建器直到在其中啟動的所有協程都執行結束時才會掛起自己。正因如此,在 coroutineScope 中的所有協程尚未結束之前就從 fetchDocs 中返回是不可能的。

許多許多工

現在我們已經探索瞭如何追蹤一個和兩個協程,現在是時候來嘗試追蹤一千個協程了!

看一下下面的動畫:

Animation showing how a coroutineScope can keep track of one thousand coroutines.

這個例子展示了同時進行一千次網路請求。這在真實的程式碼中是不建議的,會浪費大量資源。

上面的程式碼中,我們在 coroutineScope 中通過 launch 啟動了一千個協程。你可以看到它們是如何連線起來的。由於我們是在掛起函式中,所以某個地方的程式碼一定是使用了 CoroutineScope 來啟動協程。對於這個 CoroutineScope,我們一無所知,它可能是 viewModelScope 或者定義在其他地方的 CoroutineScope。無論它是什麼作用域,coroutineScope 構建器都會把它當做新建作用域的父親。

coroutineScope 程式碼塊中,launch 將在新的作用域中啟動協程。當協程完成啟動,這個新的作用域將追蹤它。最後,一旦在 coroutineScope 中啟動的所有協程都完成了,loadLots 就可以返回了。

Note: the parent-child relationship between scopes and coroutines is created using Job objects. But you can often think of the relationship between coroutines and scopes without diving into that level.

coroutineScope 和 supervisorScope 會等待所有子協程執行結束。

這裡有很多事情在進行,其中最重要的就是使用 coroutineScope 或者 supervisorScope,你可以在任意掛起函式中安全的啟動協程。儘管這將啟動一個新協程,你也不會意外的洩露任務,因為只有所有新協程都完成了你才可以掛起呼叫者。

很酷的是 coroutineScope 可以建立子作用域。如果父作用域被取消,它會將取消動作傳遞給所有的新協程。如果呼叫者是 viewModelScope,當使用者離開頁面是,所有的一千個協程都會自動取消。多麼的整潔!

在我們移步談論異常處理之前,有必要來討論一下 coroutineScopesupervisorScope。它們之間最大的不同就是,當其中任意一個子協程失敗時,coroutineScope 會取消。所以,如果一個網路請求失敗了,其他的所有請求都會立刻被取消。如果你想繼續執行其他請求的話,你可以使用 supervisorScope,當一個子協程失敗時,它不會取消其他的子協程。

協程失敗的異常處理

在協程中,錯誤也是用過丟擲異常來發出訊號,和普通函式一樣。掛起函式的異常將在 resume 的時候重新丟擲給呼叫者。和普通函式一樣,你不會被限制使用 try/catch 來處理錯誤,你也可以按你喜歡的方式來處理異常。

但是,有一些情況下,協程中的異常會丟失。

val unrelatedScope = MainScope()
    // example of a lost error
    suspend fun lostError() {
        // async without structured concurrency
        unrelatedScope.async {throw InAsyncNoOneCanHearYou("except")
    }
}
複製程式碼

注意,上面的程式碼中宣告瞭一個未經關聯的協程作用域,並且未通過結構化併發啟動新協程。記住我開始說過的,結構化併發集合了語言特性和最佳實踐,在掛起函式中引入未經關聯的協程作用並不是結構化併發的最佳實踐。

上面程式碼中的錯誤會丟失,因為 async 認為你會呼叫 await,這時候會重新丟擲異常。但是如果你沒有呼叫 await,這個錯誤將永遠被儲存,靜靜的等待被發現。

結構化併發保證當一個協程發生錯誤,它的呼叫者或者作用域可以發現。

如果我們使用結構化併發寫上面的程式碼,異常將會正確的拋給呼叫者。

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

由於 coroutineScope 會等待所有子協程執行完成,所以當子協程失敗時它也會知道。當 coroutineScope 啟動的協程丟擲了異常,coroutineScope 會將異常扔給呼叫者。如果使用 coroutineScope 代替 supervisorScope,當異常丟擲時,會立刻停止所有的子協程。

使用結構化併發

在這篇文章中,我介紹了結構化併發,以及在程式碼中配合 ViewModel 使用來避免任務洩露。我還談論了它是如何讓掛起函式更加簡單。兩者都確保在返回之前完成任務,也可以確保正確的異常處理。

我們使用非結構化併發,很容易造成意外的任務洩露,這對呼叫者來說是未知的。任務將變得不可取消,也不能保證異常被正確的丟擲。這會導致我們的程式碼產生一些模糊的錯誤。

使用未關聯的 CoroutineScope(注意是大寫字母 C),或者使用全域性作用域 GlobalScope ,會導致非結構化併發。只有在少數情況下,你需要協程的生命週期長於呼叫者的作用域時,才考慮使用非結構化併發。通常情況下,你都應該使用結構化併發來追蹤協程,處理異常,擁有良好的取消機制。

如果你有非結構化併發的經驗,那麼結構化併發的確需要一些時間來適應。這種保障使得和掛起函式互動更加安全和簡單。我們應該儘可能的使用結構化併發,因為它使得程式碼更加簡單和易讀。

在文章的開頭,我列舉了結構化併發幫助我們解決的三個問題:

  1. 取消不再需要的任務
  2. 追蹤所有正在進行的任務
  3. 協程失敗時的錯誤訊號

結構化併發給予我們如下保證:

  1. 當作用域取消,其中的協程也會取消
  2. 當掛起函式返回,其中的所有任務都已完成
  3. 當協程發生錯誤,其呼叫者會得到通知

這些加在一起,使得我們的程式碼更加安全,簡潔,並且幫助我們避免任務洩露。

What's Next?

這篇文章中,我們探索瞭如何在 Android 的 ViewModel 中啟動協程,以及如何使用結構化併發來優化程式碼。

下一篇中,我們將更多的討論在特定情況下使用協程。

文章首發微信公眾號: 秉心說 , 專注 Java 、 Android 原創知識分享,LeetCode 題解。

更多 JDK 原始碼解析,掃碼關注我吧!

在 Android 上使用協程(二):Getting started

相關文章