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

原文做者 :Sean McQuillanhtml

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

譯者 : 秉心說git

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

上一篇 :數據庫

在 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 源碼解析,掃碼關注我吧!

相關文章
相關標籤/搜索