[譯] 如何優雅的處理協程的異常?

原文做者:Manuel Vivo html

原文地址:Exceptions in Coroutinesandroid

譯者:秉心說git

本文是 協程的取消和異常 系列的第三篇,往期目錄以下:github

Coroutines: First things firstweb

如何優雅的處理協程的取消?app

在閱讀本文以前,強烈建議回顧一下以前兩篇文章。實在沒有時間的話,至少讀一下第一篇文章。async

下面開始正文。編輯器


做爲開發者,咱們一般會花費大量時間來完善咱們的應用。可是,當發生異常致使應用不按預期執行時儘量的提供良好的用戶體驗也是一樣重要的。一方面,應用 Crash 對用戶來講是很糟糕的體驗;另外一方面,當用戶操做失敗時,提供正確的信息也是必不可少的。函數

優雅的異常處理對用戶來講是很重要的。在這篇文章中,我會介紹在協程中異常是怎麼傳播的,以及如何使用各類方式控制異常的傳播。測試

若是你更喜歡視頻,能夠觀看 Florina Muntenescu 和我 在 KotlinConf'19 上的演講,地址以下:

https://www.youtube.com/watch?v=w0kfnydnFWI&feature=emb_logo

爲了幫你更好的理解本文的剩餘內容,建議首先閱讀該系列的第一篇文章 Coroutines: First things first

協程忽然失敗了?怎麼辦?😱

當一個協程發生了異常,它將把異常傳播給它的父協程,父協程會作如下幾件事:

  1. 取消其餘子協程
  2. 取消本身
  3. 將異常傳播給本身的父協程

異常最終將傳播至繼承結構的根部。經過該 CoroutineScope 建立的全部協程都將被取消。

在某些場景下,這樣的異常傳播是適用的。可是,也有一些場景並不合適。

想象一個 UI 相關的 CoroutineScope ,它負責處理用戶交互。若是它的一個子協程拋出了異常,那麼這個 UI Scope 將被取消。因爲被取消的做用域沒法啓動更多協程,整個 UI 組件將沒法響應用戶交互。

若是你不想要這樣怎麼辦?或許,在建立協程做用域的 CoroutineContext 時,你能夠選擇不同的 Job 實現 —— SupervisorJob

讓 SupervisorJob 拯救你

經過 SupervisorJob,子協程的失敗不會影響其餘的子協程。此外,SupervisorJob 也不會傳播異常,而是讓子協程本身處理。

你能夠這樣建立協程做用域 val uiScope = CoroutineScope(SupervisorJob()) ,來保證不傳播異常。

若是異常沒有被處理,CoroutineContext 也沒有提供異常處理器 CoroutineExceptionHandler (稍後會介紹),將會使用默認的異常處理器。在 JVM 上,異常會被打印到控制檯;在 Android 上,不管發生在什麼調度器上,你的應用都會崩潰。

💥 不管你使用哪一種類型的 Job,未捕獲異常最終都會被拋出。

一樣的行爲準則也適用於協程做用域構建器 coroutineScopesupervisorScope 。它們都會建立一個子做用域(以 Job 或者 SupervisorJob 做爲 Parent),來幫助你給協程從邏輯上分組(若是你想進行並行計算,或者它們是否會相互影響)。

警告:SupervisorJob 僅在屬於下面兩種做用域時才起做用:使用 supervisorScope 或者 CoroutineScope(SupervisorJob()) 建立的做用域。

Job 仍是 SupervisorJob ?🤔

何時使用 Job ?何時使用 SupervisorJob

當你不想讓異常致使父協程和兄弟協程被取消時,使用 SupervisorJob 或者 supervisorScope

看看下面這個示例:

// Scope handling coroutines for a particular layer of my app
val scope = CoroutineScope(SupervisorJob()) scope.launch {  // Child 1 } scope.launch {  // Child 2 } 複製代碼

在這樣的狀況下,child#1 失敗了,scopechild#2 都不會被取消。

另外一個示例:

// Scope handling coroutines for a particular layer of my app
val scope = CoroutineScope(Job()) scope.launch {  supervisorScope {  launch {  // Child 1  }  launch {  // Child 2  }  } } 複製代碼

在這種狀況下,supervisorScope 建立了一個攜帶 SupervisorJob 的子做用域。若是 child#1 失敗,child#2 也不會被取消。可是若是使用 coroutineScope 來代替 supervisorScope 的話,異常將會傳播並取消做用域。

測試!誰是個人父親 ?🎯

經過下面的代碼段,你能肯定 child#1 的父級是哪種 Job 嗎?

val scope = CoroutineScope(Job())
scope.launch(SupervisorJob()) {  // new coroutine -> can suspend  launch {  // Child 1  }  launch {  // Child 2  } } 複製代碼

child#1 的父 Job 是 Job 類型 !但願你回答正確!儘管第一眼看上去,你可能認爲是 SupervisorJob,但並非。由於在這種狀況下,每一個新的協程老是被分配一個新的 Job,這個新的 Job 覆蓋了 SupervisorJobSupervisorJob 是父協程經過 scope.launch 建立的。也就是說,在上面的例子中,SupervisorJob 沒有發揮任何做用。

The parent of child#1 and child#2 is of type Job, not SupervisorJob
The parent of child#1 and child#2 is of type Job, not SupervisorJob

因此,不管是 child#1 仍是 child#2 發生了異常,都將傳播到 scope,並致使全部由其啓動的協程被取消。

記住 SupervisorJob 僅在屬於下面兩種做用域時才起做用:使用 supervisorScope 或者 CoroutineScope(SupervisorJob()) 建立的做用域。SupervisorJob 做爲參數傳遞給協程構建器並不會產生你所預期的效果。

關於異常,若是子協程拋出了異常,SupervisorJob 不會進行傳播並讓子協程本身去處理。

原理

若是你好奇 Job 的工做原理,能夠在 JobSupport.kt 文件中查看 childCancellednotifyCancelling 這兩個函數的實現。

對於 SupervisorJob 的實現,childCancelled() 方法僅僅只是返回 false ,表示它不會傳播異常,同時也不會處理異常。

異常的處理 👩‍🚒

在協程中,可使用常規語法來處理異常:try/catch 或者內置的函數 runCatching (內部使用了 try/catch) 。

咱們以前說過 未捕獲的異常始終會被拋出 。可是不一樣的協程構建器對於異常有不一樣的處理方式。

Launch

在 launch 中,異常一旦發生就會立馬被拋出 。所以,你可使用 try/catch 包裹會發生異常的代碼。以下所示:

scope.launch {
 try {  codeThatCanThrowExceptions()  } catch(e: Exception) {  // Handle exception  } } 複製代碼

在 launch 中,異常一旦發生就會立馬被拋出 。

Async

async 在根協程 (CoroutineScope 實例或者 supervisorJob 的直接子協程) 使用時,異常不會被自動拋出,而是直到你調用 .await() 時才拋出。

爲了處理 async 拋出的異常,你能夠在 try/catch 中調用 await

supervisorScope {
 val deferred = async {  codeThatCanThrowExceptions()  }  try {  deferred.await()  } catch(e: Exception) {  // Handle exception thrown in async  } } 複製代碼

在上面的例子中,async 的調用處永遠不會拋出異常,因此這裏並不須要包裹 try/catchawait() 方法將會拋出 async 內部發生的異常。

注意上面的代碼中咱們使用的是 supervisorScope 來調用 asyncawait 。就像以前說過的那樣,SupervisorJob 讓協程本身處理異常。與之相反的,Job 會傳播異常,因此 catch 代碼塊不會被調用。

coroutineScope {
 try {  val deferred = async {  codeThatCanThrowExceptions()  }  deferred.await()  } catch(e: Exception) {  // Exception thrown in async WILL NOT be caught here   // but propagated up to the scope  } } 複製代碼

此外,由其餘協程建立的協程若是發生了異常,也將會自動傳播,不管你的協程構建器是什麼。

舉個例子:

val scope = CoroutineScope(Job())
scope.launch {  async {  // If async throws, launch throws without calling .await()  } } 複製代碼

在上面的例子中,若是 async 發生了異常,會當即被拋出。由於 scope 的直接子協程是由 scope.launch 啓動的,async 繼承了協程上下文中的 Job ,致使它會自動向父級傳播異常。

⚠️ 經過 coroutineScope 構建器或者由其餘協程啓動的協程拋出的異常,不會被 try/catch 捕獲!

SupervisorJob 那一節,咱們提到了 CoroutineExceptionHandler 。如今讓咱們來深刻了解它。

CoroutineExceptionHandler

協程異常處理器 CoroutineExceptionHandler 是 CoroutineContext 中的一個可選元素,它能夠幫助你 處理未捕獲異常

下面的代碼展現瞭如何定義一個 CoroutineExceptionHandler 。不管異常什麼時候被捕獲,你都會獲得關於發生異常的 CoroutineContext 的信息,和異常自己的信息。

val handler = CoroutineExceptionHandler {
 context, exception -> println("Caught $exception") } 複製代碼

若是知足如下要求,異常將會被捕獲:

  • 什麼時候⏰ :是被能夠自動拋異常的協程拋出的( launch,而不是 async
  • 何地🌍 :在 CoroutineScope 或者根協程的協程上下文中( CoroutineScope 的直接子協程或者 supervisorScope

讓咱們看兩個 CoroutineExceptionHandler 的使用例子。

在下面的例子中,異常會被 handler 捕獲:

val scope = CoroutineScope(Job())
scope.launch(handler) {  launch {  throw Exception("Failed coroutine")  } } 複製代碼

下面的另外一個例子中,handler 在一個內部協程中使用,它不會捕獲異常:

val scope = CoroutineScope(Job())
scope.launch {  launch(handler) {  throw Exception("Failed coroutine")  } } 複製代碼

因爲 handler 沒有在正確的協程上下文中使用,因此異常沒有被捕獲。內部 launch 啓動的協程一旦發生異常會自動傳播到父協程,而父協程並不知道 handler 的存在,因此異常會被直接拋出。


即便你的應用由於異常沒有按照預期執行,優雅的異常處理對於良好的用戶體驗也是很重要的。

當你要避免因異常自動傳播形成的協程取消時,記住使用 SupervisorJob ,不然請使用 Job

未捕獲異常將會被傳播,捕獲它們,提供良好的用戶體驗!


這篇文章就到這裏了,這個系列還剩最後一篇了。

在以前提到協程的取消時,介紹了 viewModelScope 等跟隨生命週期自動取消的協程做用域。可是不想取消時,應該怎麼作?下一篇將會爲你解答。

我是秉心說,關注我,不迷路!

本文使用 mdnice 排版

相關文章
相關標籤/搜索