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

原文作者:Manuel Vivo

原文地址:Exceptions in Coroutines

譯者:秉心說

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

Coroutines: First things first

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

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

下面開始正文。


作爲開發者,我們通常會花費大量時間來完善我們的應用。但是,當發生異常導致應用不按預期執行時儘可能的提供良好的用戶體驗也是同樣重要的。一方面,應用 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,未捕獲異常最終都會被拋出。

同樣的行爲準則也適用於協程作用域構建器 coroutineScope 和 supervisorScope 。它們都會創建一個子作用域(以 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

所以,無論是 child#1 還是 child#2 發生了異常,都將傳播到 scope,並導致所有由其啓動的協程被取消。

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

關於異常,如果子協程拋出了異常,SupervisorJob 不會進行傳播並讓子協程自己去處理。

原理

如果你好奇 Job 的工作原理,可以在 JobSupport.kt 文件中查看 childCancelled 和 notifyCancelling 這兩個函數的實現。

對於 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 等跟隨生命週期自動取消的協程作用域。但是不想取消時,應該怎麼做?下一篇將會爲你解答。

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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章