协程中的取消和异常 | 异常处理详解

协程中的取消和异常 | 异常处理详解

开发者们通常会在打磨应用的正常功能上花费很多时间,但是当应用出现一些意外情况时,给用户提供合适的体验也同样重要。一方面来讲,对用户来说,目睹应用崩溃是个很糟糕的体验;而另一方面,在用户操作失败时,也必须要能给出正确的提示信息。

正确地处理异常,可以很大程度上改进用户对一个应用的看法。接下来,本文将会解释异常是如何在协程间传播的,以及一些处理它们的方法,从而帮您做到一切尽在掌握。

⚠️ 为了能够更好地理解本文所讲的内容,建议您首先阅读本系列中的第一篇文章: 协程中的取消和异常 | 核心概念介绍

某个协程突然运行失败怎么办?😱

当一个协程由于一个异常而运行失败时,它会传播这个异常并传递给它的父级。接下来,父级会进行下面几步操作:

  • 取消它自己的子级;
  • 取消它自己;
  • 将异常传播并传递给它的父级。

异常会到达层级的根部,而且当前 CoroutineScope 所启动的所有协程都会被取消。

△ 协程中的异常会通过协程的层级不断传播

△ 协程中的异常会通过协程的层级不断传播

虽然在一些情况下这种传播逻辑十分合理,但换一种情况您可能就不这么想了。假设您的应用中有一个与 UI 关联的 CoroutineScope,用于处理与用户的交互 。如果它的子协程抛出了一个异常,就会导致 UI 作用域 (UI scope) 被取消,并且由于被取消的作用域无法开启新的协程,所有的 UI 组件都会变得无法响应。

如果您不希望这种事情发生,可以尝试在创建协程时在 CoroutineScope 的 CoroutineContext 中使用 Job 的另一个扩展: SupervisorJob。

使用 SupervisorJob 来解决问题

使用 SupervisorJob 时,一个子协程的运行失败不会影响到其他子协程。SupervisorJob 不会取消它和它自己的子级,也不会传播异常并传递给它的父级,它会让子协程自己处理异常。

您可以使用这样的代码创建一个 CoroutineScope: val uiScope = CoroutineScope(SupervisorJob()),这样就会像下图中展示的那样,在协程运行失败时也不会传播取消操作。

△ SupervisorJob 不会取消它其他的子级

△ SupervisorJob 不会取消它其他的子级

如果异常没有被处理,而且 CoroutineContext 没有一个 CoroutineExceptionHandler (稍后讲到) 时,异常会到达默认线程的 ExceptionHandler。在 JVM 中,异常会被打印在控制台;而在 Android 中,无论异常在那个 Dispatcher 中发生,都会导致您的应用崩溃。

💥 未被捕获的异常一定会被抛出,无论您使用的是哪种 Job

使用 coroutineScopesupervisorScope 也有相同的效果。它们会创建一个子作用域 (使用一个 Job 或 SupervisorJob 作为父级),可以帮助您根据自己的逻辑组织协程 (例如: 您想要进行一组平行计算,并且希望它们之间互相影响或者相安无事的时候)。

注意 : SupervisorJob 只有作为 supervisorScope 或 CoroutineScope(SupervisorJob()) 的一部分时,才会按照上面的描述工作。

**使用 Job 还是 SupervisorJob?**🤔

那么应该在什么时候去使用 Job 或 SupervisorJob 呢?如果您想要在出现错误时不会退出父级和其他平级的协程,那就使用 SupervisorJob 或 supervisorScope。

示例如下:

// Scope 控制我的应用中某一层级的协程
val scope = CoroutineScope(SupervisorJob())

scope.launch {
    // Child 1
}

scope.launch {
    // Child 2
}

在这个示例中如果 Child 1 失败了,无论是 scope 还是 Child 2 都会被取消。

另一个示例如下:

// Scope 控制我的应用中某一层级的协程
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 实例替代 SupervisorJob,所以这里并不是。本例中的 SupervisorJob 是协程的父级通过 scope.launch 创建的,所以真相是,SupervisorJob 在这段代码中完全没用!

△ Child 1 和 Child 2 的父级是 Job 类型,不是 SupervisorJob

这样一来,无论 Child 1 或 Child 2 运行失败,错误都会到达作用域,所有该作用域开启的协程都会被取消。

记住,只有使用 supervisorScope 或 CoroutineScope(SupervisorJob()) 创建 SupervisorJob 时,它才会像前文描述的一样工作。将 SupervisorJob 作为参数传入一个协程的 Builder 不能带来您想要的效果。

工作原理

如果您对 Job 的底层实现感到疑惑,可以查看 JobSupport.kt 文件中对 childCancellednotifyCancelling 方法的扩展。

在 SupervisorJob 的扩展中,childCancelled 方法只是返回 false,意味着它不会传播取消操作,也不会对理异常做任何处理。

处理异常👩‍🚒

协程使用一般的 Kotlin 语法处理异常: try/catch 或内建的工具方法,比如 runCatching (其内部还是使用了 try/catch)

前面讲到,所有未捕获的异常一定会被抛出。但是,不同的协程 Builder 对异常有不同的处理方式。

Launch

使用 launch 时,异常会在它发生的第一时间被抛出,这样您就可以将抛出异常的代码包裹到 try/catch 中,就像下面的示例这样:

scope.launch {
    try {
        codeThatCanThrowExceptions()
    } catch(e: Exception) {
        // 处理异常
    }
}

使用 launch 时,异常会在它发生的第一时间被抛出

Async

当 async 被用作根协程 (CoroutineScope 实例或 supervisorScope 的直接子协程) 时不会自动抛出异常,而是在您调用 .await() 时才会抛出异常。

当 async 作为根协程时,为了捕获其中抛出的异常,您可以用 try/catch 包裹调用 .await() 的代码:

supervisorScope {
    val deferred = async {
        codeThatCanThrowExceptions()
    }
 
   try {
       deferred.await()
   } catch(e: Exception) {
       // 处理 async 中抛出的异常
   }
}

注意,在本例中,async 永远都不会抛出异常。这就是为什么没有必要将它也包裹进 try/catch 中,await 将会抛出 async 协程中产生的所有异常。

当 async 被用作根协程时,异常将会在您调用 .await 方法时被抛出

另一个需要注意的地方是,这里使用了 supervisorScope 来调用 async 和 await。正如我们之前提到的,SupervisorJob 会让协程自己处理异常;而相对的,Job 则会在层级间自动传播异常,这样一来 catch 部分的代码块就不会被调用:

coroutineScope {
   try {
       val deferred = async {
           codeThatCanThrowExceptions()
       }
       deferred.await()
   } catch(e: Exception) {
       // async 中抛出的异常将不会在这里被捕获
       // 但是异常会被传播和传递到 scope
   }
}

更进一步的,其他协程所创建的协程中产生的异常总是会被传播,无论协程的 Builder 是什么。例如:

val scope = CoroutineScope(Job())
scope.launch {
   async {
       // 如果 async 抛出异常,launch 就会立即抛出异常,而不会调用 .await()
   }
}

本例中,由于 scope 的直接子协程是 launch,如果 async 中产生了一个异常,这个异常将就会被立即抛出。原因是 async (包含一个 Job 在它的 CoroutineContext 中) 会自动传播异常到它的父级 (launch),这会让异常被立即抛出。

⚠️ 在 coroutineScope builder 或在其他协程创建的协程中抛出的异常不会被 try/catch 捕获!

在前面 SupervisorJob 那节中,我们提到了 CoroutineExceptionHandler 的存在。现在让我们来深入探索一下。

CoroutineExceptionHandler

CoroutineExceptionHandler 是 CoroutineContext 的一个可选元素,它让您可以处理未捕获的异常。

下面是如何声明一个 CoroutineExceptionHandler 的例子。无论哪里有异常被捕获,您都可以通过 handler 获得异常所在的 CoroutineContext 的有关信息以及异常本身:

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

以下的条件被满足时,异常就会被捕获:

  • 时机 ⏰: 异常是被自动抛出异常的协程所抛出的 (使用 launch,而不是 async 时);

  • 位置 🌍: 在 CoroutineScope 的 CoroutineContext 中或在一个根协程 (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 没有被安装给正确的 CoroutineContext。内部协程会在异常出现时传播异常并传递给它的父级,由于父级并不知道 handler 的存在,异常就没有被抛出。

优雅地处理程序中的异常是提供良好用户体验的关键,在事情不如预期般发展时尤其如此。

想要避免取消操作在异常发生时被传播,记得使用 SupervisorJob;反之则使用 Job。

没有被捕获的异常会被传播,捕获它们以保证良好的用户体验!

接下来的时间里,我们将继续更新系列文章,感兴趣的读者请继续关注我们的更新。

版权声明

禁止一切形式的转载-禁止商用-禁止衍生 申请授权

脉脉不得语
脉脉不得语
Zhengzhou Website
Android Developer | https://androiddevtools.cn and https://androidweekly.io WebMaster | GDG Zhengzhou Funder & Ex Organizer | http://Toast.show(∞) Podcast Host

你已经成功订阅到 Android 开发技术周报
太棒了!接下来,完成检验以获得全部访问权限 Android 开发技术周报
欢迎回来!你已经成功登录了。
Unable to sign you in. Please try again.
成功!您的帐户已完全激活,您现在可以访问所有内容。
Error! Stripe checkout failed.
Success! Your billing info is updated.
Error! Billing info update failed.