協(xié)程中的取消和異常 | 駐留任務(wù)詳解

image

在本系列第二篇文章 協(xié)程中的取消和異常 | 取消操作詳解 中,我們學(xué)到,當(dāng)一個任務(wù)不再被需要時,正確地退出十分的重要。在 Android 中,您可以使用 Jetpack 提供的兩個 CoroutineScopes: viewModelScopelifecycleScope,它們可以在 Activity、Fragment、Lifecycle 完成時退出正在運(yùn)行的任務(wù)。如果您正在創(chuàng)建自己的 CoroutineScope,記得將它綁定到某個任務(wù)中,并在需要的時候取消它。

然而,在有些情況下,您會希望即使用戶離開了當(dāng)前界面,操作依然能夠執(zhí)行完成。因此,您就不會希望任務(wù)被取消,例如,向數(shù)據(jù)庫寫入數(shù)據(jù)或者向您的服務(wù)器發(fā)送特定類型的請求。

下面我們就來介紹實現(xiàn)此類情況的模式。

協(xié)程還是 WorkManager?

協(xié)程會在您的應(yīng)用進(jìn)程活動期間執(zhí)行。如果您需要執(zhí)行一個能夠在應(yīng)用進(jìn)程之外活躍的操作 (比如向遠(yuǎn)程服務(wù)器發(fā)送日志),在 Android 平臺上建議使用 WorkManager。WorkManager 是一個擴(kuò)展庫,用于那些預(yù)期會在將來的某個時間點(diǎn)執(zhí)行的重要操作。

請針對那些在當(dāng)前進(jìn)程中有效的操作使用協(xié)程,同時保證可以在用戶關(guān)閉應(yīng)用時取消操作 (例如,進(jìn)行一個您希望緩存的網(wǎng)絡(luò)請求)。那么,實現(xiàn)這類操作的最佳實踐是什么呢?

協(xié)程的最佳實踐

由于本文所介紹的模式是在協(xié)程的其它最佳實踐的基礎(chǔ)之上實現(xiàn)的,我們可以借此機(jī)會回顧一下:

1. 將調(diào)度器注入到類中

不要在創(chuàng)建協(xié)程或調(diào)用 withContext 時硬編碼調(diào)度器。

? 好處: 便于測試。您可以在進(jìn)行單元測試或儀器測試時輕松替換掉它們。

2. 應(yīng)當(dāng)在 ViewModel 或 Presenter 層創(chuàng)建協(xié)程

如果是僅與 UI 相關(guān)的操作,則可以在 UI 層執(zhí)行。如果您認(rèn)為這條最佳實踐在您的工程中不可行,則很有可能是您沒有遵循第一條最佳實踐 (測試沒有注入調(diào)度器的 ViewModel 會變得更加困難;這種情況下,暴露出掛起函數(shù)會使測試變得可行)。

? 好處: UI 層應(yīng)該盡量簡潔,并且不直接觸發(fā)任何業(yè)務(wù)邏輯。作為代替,應(yīng)當(dāng)將響應(yīng)能力轉(zhuǎn)移到 ViewModel 或 Presenter 層實現(xiàn)。在 Android 中,測試 UI 層需要執(zhí)行插樁測試,而執(zhí)行插樁測試需要運(yùn)行一個模擬器。

3. ViewModel 或 Presenter 以下的層級,應(yīng)當(dāng)暴露掛起函數(shù)與 Flow

如果您需要創(chuàng)建協(xié)程,請使用 coroutineScopesupervisorScope。而如果您想要將協(xié)程限定在其他作用域,請繼續(xù)閱讀,接下來本文將對此進(jìn)行討論。

? 好處: 調(diào)用者 (通常是 ViewModel 層) 可以控制這些層級中任務(wù)的執(zhí)行和生命周期,也可以在需要時取消這些任務(wù)。

協(xié)程中那些不應(yīng)當(dāng)被取消的操作

假設(shè)我們的應(yīng)用中有一個 ViewModel 和一個 Repository,它們的相關(guān)邏輯如下:

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() // 它不應(yīng)當(dāng)被取消
    }
  }
}

我們不希望用 viewModelScope 來控制 veryImportantOperation(),因為 viewModelScope 隨時都可能被取消。我們想要此操作的運(yùn)行時長超過 viewModelScope,這個目的要如何達(dá)成呢?

我們需要在 Application 類中創(chuàng)建自己的作用域,并在由它啟動的協(xié)程中調(diào)用這些操作。這個作用域應(yīng)當(dāng)被注入到那些需要它的類中。

與稍后將在本文中看到的其他解決方案 (如 GlobalScope) 相比,創(chuàng)建自己的 CoroutineScope 的好處是您可以根據(jù)自己的想法對其進(jìn)行配置。無論您是需要 CoroutineExceptionHandler,還是想使用自己的線程池作為調(diào)度器,這些常見的配置都可以放在自己的 CoroutineScope 的 CoroutineContext 中。

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

class MyApplication : Application() {
  // 不需要取消這個作用域,因為它會隨著進(jìn)程結(jié)束而結(jié)束
   val applicationScope = CoroutineScope(SupervisorJob() + otherConfig)
}

由于我們希望它在應(yīng)用進(jìn)程存活期間始終保持活動狀態(tài),所以我們不需要取消 applicationScope,進(jìn)而也不需要保持 SupervisorJob 的引用。當(dāng)協(xié)程所需的生存期比調(diào)用處作用域的生存期更長時,我們可以使用 applicationScope 來運(yùn)行協(xié)程。

從 application CoroutineScope 創(chuàng)建的協(xié)程中調(diào)用那些不應(yīng)當(dāng)被取消的操作

每當(dāng)您創(chuàng)建一個新的 Repository 實例時,請傳入上面創(chuàng)建的 applicationScope。對于測試,可以參考后文的 Testing 部分。

應(yīng)該使用哪種協(xié)程構(gòu)造器?

您需要基于 veryImportantOperation 的行為來使用 launch 或 async 啟動新的協(xié)程:

  • 如果需要返回結(jié)果,請使用 async 并調(diào)用 await 來等待其完成;
  • 如果不是,請使用 launch 并調(diào)用 join 來等待其完成。請注意,如 本系列第三部分所述,您必須在 launch 塊內(nèi)部手動處理異常。

下面是使用 launch 啟動協(xié)程的方式:

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

或使用 async:

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

在任何情況下,都無需改動上面的 ViewModel 的代碼。就算 ViewModelScope 被銷毀,使用 externalScope 的任務(wù)也會持續(xù)運(yùn)行。就像其他掛起函數(shù)一樣,只有在 veryImportantOperation() 完成之后,doWork() 才會返回。

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

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

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

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

  • 如果調(diào)用 doWork() 的協(xié)程在 veryImportantOperation 開始執(zhí)行時被退出,它將繼續(xù)執(zhí)行直到下一個退出節(jié)點(diǎn),而不是在 veryImportantOperation 結(jié)束后退出;
  • CoroutineExceptionHandler 不會如您預(yù)期般工作,這是因為在 withContext 中使用上下文時,異常會被重新拋出。

測試

由于我們可能需要同時注入調(diào)度器和 CoroutineScop,那么這些場景里分別需要注入什么呢?

測試時要注入什么

測試時要注入什么

?? 說明文檔:

替代方案

其實還有一些其他的方式可以讓我們使用協(xié)程來實現(xiàn)這一行為。不過,這些解決方案不是在任何條件下都能有條理地實現(xiàn)。下面就讓我們看看一些替代方案,以及為何適用或者不適用,何時使用或者不使用它們。

? GlobalScope

下面是幾個不應(yīng)該使用 GlobalScope 的理由:

  • 誘導(dǎo)我們寫出硬編碼值 。直接使用 GlobalScope 可能會讓我們傾向于寫出硬編碼的調(diào)度器,這是一種很差的實踐方式。
  • 導(dǎo)致測試非常困難 。由于您的代碼會在一個不受控制的作用域中執(zhí)行,您將無法對從中啟動的任務(wù)進(jìn)行管理。
  • 就如同我們對 applicationScope 所做的那樣,您無法為所有協(xié)程都提供一個通用的、內(nèi)建于作用域中的 CoroutineContext。相反,您必須傳遞一個通用的 CoroutineContext 給 GlobalScope 啟動的所有協(xié)程。

建議: 不要直接使用它。

? Android 中的 ProcessLifecycleOwner 作用域

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

在使用它時,您需要注入一個 LifecycleOwner 來代替我們之前注入的 CoroutineScope。在生產(chǎn)環(huán)境中,您需要傳入 ProcessLifecycleOwner.get();而在單元測試中,您可以用 LifecycleRegistry 來創(chuàng)建一個虛擬的 LifecycleOwner。

注意,這個作用域的默認(rèn) CoroutineContext 是 Dispatchers.Main.immediate,所以它可能不太適合去執(zhí)行后臺任務(wù)。就像使用 GlobalScope 時那樣,您也需要傳遞一個通用的 CoroutineContext 到所有通過 GlobalScope 啟動的協(xié)程中。

由于上述原因,此替代方案相比起直接在 Application 類中創(chuàng)建一個 CoroutineScope 要麻煩許多。而且,我個人不喜歡在 ViewModel 或 Presenter 層之下與 Android lifecycle 建立關(guān)系,我希望這些層級是平臺無關(guān)的。

建議: 不要直接使用它。

?? 特別說明**

如果您將您的 applicationScope 中的 CoroutineContext 等于 GlobalScope 或 ProcessLifecycleOwner.get().lifecycleScope,您就可以像下面這樣直接使用它:

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

您仍然可以獲得上文所述的所有優(yōu)點(diǎn),并且將來可以根據(jù)需要輕松進(jìn)行更改。

? ? 使用 NonCancellable

正如您在本系列第二篇文章 協(xié)程中的取消和異常 | 取消操作詳解 中看到的,您可以使用 withContext(NonCancellable) 在被取消的協(xié)程中調(diào)用掛起函數(shù)。我們建議您使用它來進(jìn)行可掛起的代碼清理,但是,您不應(yīng)該濫用它。

這樣做的風(fēng)險很高,因為您將會無法控制協(xié)程的執(zhí)行。確實,它可以使代碼更簡潔,可讀性更強(qiáng),但與此同時,它也可能在將來引起一些無法預(yù)測的問題。

使用示例如下:

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

盡管這個方案很有誘惑力,但是您可能無法總是知道 someImportantOperation() 背后有什么邏輯。它可能是一個擴(kuò)展庫;也可能是一個接口背后的實現(xiàn)。它可能會導(dǎo)致各種各樣的問題:

  • 您將無法在測試中結(jié)束這些操作;
  • 使用延遲的無限循環(huán)將永遠(yuǎn)無法被取消;
  • 從其中收集 Flow 會導(dǎo)致 Flow 也變得無法從外部取消;
  • …...

而這些問題會導(dǎo)致出現(xiàn)細(xì)微且非常難以調(diào)試的錯誤。

建議: 僅用它來掛起清理操作相關(guān)的代碼。

每當(dāng)您需要執(zhí)行一些超出當(dāng)前作用域范圍的工作時,我們都建議您在您自己的 Application 類中創(chuàng)建一個自定義作用域,并在此作用域中執(zhí)行協(xié)程。同時要注意,在執(zhí)行這類任務(wù)時,避免使用 GlobalScope、ProcessLifecycleOwner 作用域或 NonCancellable。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

友情鏈接更多精彩內(nèi)容