協程中的取消和異常 | 駐留任務詳解

在本系列第二篇文章 協程中的取消和異常 | 取消操做詳解 中,咱們學到,當一個任務再也不被須要時,正確地退出十分的重要。在 Android 中,您可使用 Jetpack 提供的兩個 CoroutineScopes: viewModelScope.viewModelScope:kotlinx.coroutines.CoroutineScope) 和 lifecycleScope,它們能夠在 Activity、Fragment、Lifecycle 完成時退出正在運行的任務。若是您正在建立本身的 CoroutineScope,記得將它綁定到某個任務中,並在須要的時候取消它。html

然而,在有些狀況下,您會但願即便用戶離開了當前界面,操做依然可以執行完成。所以,您就不會但願任務被取消,例如,向數據庫寫入數據或者向您的服務器發送特定類型的請求。java

下面咱們就來介紹實現此類狀況的模式。android

協程仍是 WorkManager?

協程會在您的應用進程活動期間執行。若是您須要執行一個可以在應用進程以外活躍的操做 (好比向遠程服務器發送日誌),在 Android 平臺上建議使用 WorkManager。WorkManager 是一個擴展庫,用於那些預期會在未來的某個時間點執行的重要操做。ios

請針對那些在當前進程中有效的操做使用協程,同時保證能夠在用戶關閉應用時取消操做 (例如,進行一個您但願緩存的網絡請求)。那麼,實現這類操做的最佳實踐是什麼呢?git

協程的最佳實踐

因爲本文所介紹的模式是在協程的其它最佳實踐的基礎之上實現的,咱們能夠藉此機會回顧一下:github

1. 將調度器注入到類中

不要在建立協程或調用 withContext 時硬編碼調度器。數據庫

✅ 好處: 便於測試。您能夠在進行單元測試或儀器測試時輕鬆替換掉它們。緩存

2. 應當在 ViewModel 或 Presenter 層建立協程

若是是僅與 UI 相關的操做,則能夠在 UI 層執行。若是您認爲這條最佳實踐在您的工程中不可行,則頗有多是您沒有遵循第一條最佳實踐 (測試沒有注入調度器的 ViewModel 會變得更加困難;這種狀況下,暴露出掛起函數會使測試變得可行)。服務器

✅ 好處: UI 層應該儘可能簡潔,而且不直接觸發任何業務邏輯。做爲代替,應當將響應能力轉移到 ViewModel 或 Presenter 層實現。在 Android 中,測試 UI 層須要執行插樁測試,而執行插樁測試須要運行一個模擬器。網絡

3. ViewModel 或 Presenter 如下的層級,應當暴露掛起函數與 Flow

若是您須要建立協程,請使用 coroutineScope 或 supervisorScope。而若是您想要將協程限定在其餘做用域,請繼續閱讀,接下來本文將對此進行討論。

✅ 好處: 調用者 (一般是 ViewModel 層) 能夠控制這些層級中任務的執行和生命週期,也能夠在須要時取消這些任務。

協程中那些不該當被取消的操做

假設咱們的應用中有一個 ViewModel 和一個 Repository,它們的相關邏輯以下:

class MyViewModel(private val repo: Repository) : ViewModel() {
  fun callRepo() {
    viewModelScope.launch {
      repo.doWork()
    }
  }
}

class Repository(private val ioDispatcher: CoroutineDispatcher) {
  suspend fun doWork() {
    withContext(ioDispatcher) {
      doSomeOtherWork()
     veryImportantOperation() // 它不該當被取消
    }
  }
}

咱們不但願用 viewModelScope 來控制 veryImportantOperation(),由於 viewModelScope 隨時均可能被取消。咱們想要此操做的運行時長超過 viewModelScope,這個目的要如何達成呢?

咱們須要在 Application 類中建立本身的做用域,並在由它啓動的協程中調用這些操做。這個做用域應當被注入到那些須要它的類中。

與稍後將在本文中看到的其餘解決方案 (如 GlobalScope) 相比,建立本身的 CoroutineScope 的好處是您能夠根據本身的想法對其進行配置。不管您是須要 CoroutineExceptionHandler,仍是想使用本身的線程池做爲調度器,這些常見的配置均可以放在本身的 CoroutineScope 的 CoroutineContext 中。

您能夠稱其爲 applicationScope。applicationScope 必須包含一個 SupervisorJob(),這樣協程中的故障便不會在層級間傳播 (見本系列第三篇文章: 協程中的取消和異常 | 異常處理詳解):

class MyApplication : Application() {
  // 不須要取消這個做用域,由於它會隨着進程結束而結束
   val applicationScope = CoroutineScope(SupervisorJob() + otherConfig)
}

因爲咱們但願它在應用進程存活期間始終保持活動狀態,因此咱們不須要取消 applicationScope,進而也不須要保持 SupervisorJob 的引用。當協程所需的生存期比調用處做用域的生存期更長時,咱們可使用 applicationScope 來運行協程。

從 application CoroutineScope 建立的協程中調用那些不該當被取消的操做

 

每當您建立一個新的 Repository 實例時,請傳入上面建立的 applicationScope。對於測試,能夠參考後文的 Testing 部分。

應該使用哪一種協程構造器?

您須要基於 veryImportantOperation 的行爲來使用 launch 或 async 啓動新的協程:

  • 若是須要返回結果,請使用 async 並調用 await 來等待其完成;
  • 若是不是,請使用 launch 並調用 join 來等待其完成。請注意,如 本系列第三部分所述,您必須在 launch 塊內部手動處理異常。

下面是使用 launch 啓動協程的方式:

class Repository(
  private val externalScope: CoroutineScope,
  private val ioDispatcher: CoroutineDispatcher
) {
  suspend fun doWork() {
    withContext(ioDispatcher) {
      doSomeOtherWork()
      externalScope.launch {
        //若是這裏會拋出異常,那麼要將其包裹進 try/catch 中;
        //或者依賴 externalScope 的 CoroutineScope 中的 CoroutineExceptionHandler 
        veryImportantOperation()
      }.join()
    }
  }
}

或使用 async:

class Repository(
  private val externalScope: CoroutineScope,
  private val ioDispatcher: CoroutineDispatcher
) {
  suspend fun doWork(): Any { // 在結果中使用特定類型
    withContext(ioDispatcher) {
      doSomeOtherWork()
      return externalScope.async {
        // 異常會在調用 await 時暴露,它們會在調用了 doWork 的協程中傳播。
        // 注意,若是正在調用的上下文被取消,那麼異常將會被忽略。
        veryImportantOperation()
    }.await()
    }
  }
}

在任何狀況下,都無需改動上面的 ViewModel 的代碼。就算 ViewModelScope 被銷燬,使用 externalScope 的任務也會持續運行。就像其餘掛起函數同樣,只有在 veryImportantOperation() 完成以後,doWork() 纔會返回。

有沒有更簡單的解決方案呢?

另外一種能夠在一些用例中使用的方案 (多是任何人都會首先想到的方案),即是將 veryImportantOperation 像下面這樣用 withContext 封裝進 externalScope 的上下文中:

class Repository(
  private val externalScope: CoroutineScope,
  private val ioDispatcher: CoroutineDispatcher
) {
  suspend fun doWork() {
    withContext(ioDispatcher) {
      doSomeOtherWork()
      withContext(externalScope.coroutineContext) {
        veryImportantOperation()
      }
    }
  }
}

可是,此方法有下面幾個注意事項,使用的時候須要注意:

  • 若是調用 doWork() 的協程在 veryImportantOperation 開始執行時被退出,它將繼續執行直到下一個退出節點,而不是在 veryImportantOperation 結束後退出;
  • CoroutineExceptionHandler 不會如您預期般工做,這是由於在 withContext 中使用上下文時,異常會被從新拋出。

測試

因爲咱們可能須要同時注入調度器和 CoroutineScop,那麼這些場景裏分別須要注入什麼呢?

測試時要注入什麼

測試時要注入什麼

🔖 說明文檔: 

替代方案

其實還有一些其餘的方式可讓咱們使用協程來實現這一行爲。不過,這些解決方案不是在任何條件下都能有條理地實現。下面就讓咱們看看一些替代方案,以及爲什麼適用或者不適用,什麼時候使用或者不使用它們。

❌ GlobalScope

下面是幾個不該該使用 GlobalScope 的理由:

  • 誘導咱們寫出硬編碼值 。直接使用 GlobalScope 可能會讓咱們傾向於寫出硬編碼的調度器,這是一種不好的實踐方式。
  • 致使測試很是困難 。因爲您的代碼會在一個不受控制的做用域中執行,您將沒法對從中啓動的任務進行管理。
  • 就如同咱們對 applicationScope 所作的那樣,您沒法爲全部協程都提供一個通用的、內建於做用域中的 CoroutineContext。相反,您必須傳遞一個通用的 CoroutineContext 給 GlobalScope 啓動的全部協程。

建議: 不要直接使用它。

❌ Android 中的 ProcessLifecycleOwner 做用域

在 Android 中的 androidx.lifecycle:lifecycle-process 庫中,有一個 applicationScope,您可使用  ProcessLifecycleOwner.get().lifecycleScope 來調用它。

在使用它時,您須要注入一個 LifecycleOwner 來代替咱們以前注入的 CoroutineScope。在生產環境中,您須要傳入 ProcessLifecycleOwner.get();而在單元測試中,您能夠用 LifecycleRegistry 來建立一個虛擬的 LifecycleOwner。
 
注意,這個做用域的默認 CoroutineContext 是 Dispatchers.Main.immediate,因此它可能不太適合去執行後臺任務。就像使用 GlobalScope 時那樣,您也須要傳遞一個通用的 CoroutineContext 到全部經過 GlobalScope 啓動的協程中。

因爲上述緣由,此替代方案相比起直接在 Application 類中建立一個 CoroutineScope 要麻煩許多。並且,我我的不喜歡在 ViewModel 或 Presenter 層之下與 Android lifecycle 創建關係,我但願這些層級是平臺無關的。

建議: 不要直接使用它。

⚠️  特別說明**

若是您將您的 applicationScope 中的 CoroutineContext 等於 GlobalScope 或 ProcessLifecycleOwner.get().lifecycleScope,您就能夠像下面這樣直接使用它:

class MyApplication : Application() {
  val applicationScope = GlobalScope
}

您仍然能夠得到上文所述的全部優勢,而且未來能夠根據須要輕鬆進行更改。

❌ ✅ 使用 NonCancellable

正如您在本系列第二篇文章 協程中的取消和異常 | 取消操做詳解 中看到的,您可使用 withContext(NonCancellable) 在被取消的協程中調用掛起函數。咱們建議您使用它來進行可掛起的代碼清理,可是,您不該該濫用它。

這樣作的風險很高,由於您將會沒法控制協程的執行。確實,它可使代碼更簡潔,可讀性更強,但與此同時,它也可能在未來引發一些沒法預測的問題。

使用示例以下:

class Repository(
  private val ioDispatcher: CoroutineDispatcher
) {
  suspend fun doWork() {
    withContext(ioDispatcher) {
      doSomeOtherWork()
    withContext(NonCancellable){
        veryImportantOperation()
      }
    }
  }
}

儘管這個方案頗有誘惑力,可是您可能沒法老是知道 someImportantOperation() 背後有什麼邏輯。它多是一個擴展庫;也多是一個接口背後的實現。它可能會致使各類各樣的問題:

  • 您將沒法在測試中結束這些操做;
  • 使用延遲的無限循環將永遠沒法被取消;
  • 從其中收集 Flow 會致使 Flow 也變得沒法從外部取消;
  • …...

而這些問題會致使出現細微且很是難以調試的錯誤。

建議: 僅用它來掛起清理操做相關的代碼。

每當您須要執行一些超出當前做用域範圍的工做時,咱們都建議您在您本身的 Application 類中建立一個自定義做用域,並在此做用域中執行協程。同時要注意,在執行這類任務時,避免使用 GlobalScope、ProcessLifecycleOwner 做用域或 NonCancellable。