关键词:Kotlin 协程 异常处理java
异步代码的异常处理一般都比较让人头疼,而协程则再一次展示了它的威力。git
咱们在前面一篇文章当中提到了这样一个例子:github
typealias Callback = (User) -> Unit
fun getUser(callback: Callback){
...
}
复制代码
咱们一般会定义这样的回调接口来实现异步数据的请求,咱们能够很方便的将它转换成协程的接口:bash
suspend fun getUserCoroutine() = suspendCoroutine<User> {
continuation ->
getUser {
continuation.resume(it)
}
}
复制代码
并最终交给按钮点击事件或者其余事件去触发这个异步请求:并发
getUserBtn.setOnClickListener {
GlobalScope.launch(Dispatchers.Main) {
userNameView.text = getUserCoroutine().name
}
}
复制代码
那么问题来了,既然是请求,总会有失败的情形,而咱们这里并无对错误的处理,接下来咱们就完善这个例子。异步
首先咱们加上异常回调接口函数:jvm
interface Callback<T> {
fun onSuccess(value: T)
fun onError(t: Throwable)
}
复制代码
接下来咱们在改造一下咱们的 getUserCoroutine
:async
suspend fun getUserCoroutine() = suspendCoroutine<User> { continuation ->
getUser(object : Callback<User> {
override fun onSuccess(value: User) {
continuation.resume(value)
}
override fun onError(t: Throwable) {
continuation.resumeWithException(t)
}
})
}
复制代码
你们能够看到,咱们彷佛就是彻底把 Callback
转换成了一个 Continuation
,在调用的时候咱们只须要:ide
GlobalScope.launch(Dispatchers.Main) {
try {
userNameView.text = getUserCoroutine().name
} catch (e: Exception) {
userNameView.text = "Get User Error: $e"
}
}
复制代码
是的,你没看错,一个异步的请求异常,咱们只须要在咱们的代码中捕获就能够了,这样作的好处就是,请求的全流程异常均可以在一个 try ... catch ...
当中捕获,那么咱们能够说真正作到了把异步代码变成了同步的写法。函数
若是你一直在用 RxJava 处理这样的逻辑,那么你的请求接口多是这样的:
fun getUserObservable(): Single<User> {
return Single.create<User> { emitter ->
getUser(object : Callback<User> {
override fun onSuccess(value: User) {
emitter.onSuccess(value)
}
override fun onError(t: Throwable) {
emitter.onError(t)
}
})
}
}
复制代码
调用时大概是这样的:
getUserObservable()
.observeOn(AndroidSchedulers.mainThread())
.subscribe ({ user ->
userNameView.text = user.name
}, {
userNameView.text = "Get User Error: $it"
})
复制代码
其实你很容易就能发如今这里 RxJava 作的事儿跟协程的目的是同样的,只不过协程用了一种更天然的方式。
也许你已经对 RxJava 很熟悉而且感到很天然,但相比之下,RxJava 的代码比协程的复杂度更高,更让人费解,这一点咱们后面的文章中也会持续用例子来讲明这一点。
线程也好、RxJava 也好,都有全局处理异常的方式,例如:
fun main() {
Thread.setDefaultUncaughtExceptionHandler {t: Thread, e: Throwable ->
//handle exception here
println("Thread '${t.name}' throws an exception with message '${e.message}'")
}
throw ArithmeticException("Hey!")
}
复制代码
咱们能够为线程设置全局的异常捕获,固然也能够为 RxJava 来设置全局异常捕获:
RxJavaPlugins.setErrorHandler(e -> {
//handle exception here
println("Throws an exception with message '${e.message}'")
});
复制代码
协程显然也能够作到这一点。相似于经过 Thread.setUncaughtExceptionHandler
为线程设置一个异常捕获器,咱们也能够为每个协程单独设置 CoroutineExceptionHandler
,这样协程内部未捕获的异常就能够经过它来捕获:
private suspend fun main(){
val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable ->
log("Throws an exception with message: ${throwable.message}")
}
log(1)
GlobalScope.launch(exceptionHandler) {
throw ArithmeticException("Hey!")
}.join()
log(2)
}
复制代码
运行结果:
19:06:35:087 [main] 1
19:06:35:208 [DefaultDispatcher-worker-1 @coroutine#1] Throws an exception with message: Hey!
19:06:35:211 [DefaultDispatcher-worker-1 @coroutine#1] 2
复制代码
CoroutineExceptionHandler
居然也是一个上下文,协程的这个上下文可真是灵魂通常的存在,这却是一点儿也不让人感到意外。
固然,这并不算是一个全局的异常捕获,由于它只能捕获对应协程内未捕获的异常,若是你想作到真正的全局捕获,在 Jvm 上咱们能够本身定义一个捕获类实现:
class GlobalCoroutineExceptionHandler: CoroutineExceptionHandler {
override val key: CoroutineContext.Key<*> = CoroutineExceptionHandler
override fun handleException(context: CoroutineContext, exception: Throwable) {
println("Coroutine exception: $exception")
}
}
复制代码
而后在 classpath 中建立 META-INF/services/kotlinx.coroutines.CoroutineExceptionHandler,文件名实际上就是 CoroutineExceptionHandler
的全类名,文件内容就写咱们的实现类的全类名:
com.bennyhuo.coroutines.sample2.exceptions.GlobalCoroutineExceptionHandler
复制代码
这样协程中没有被捕获的异常就会最终交给它处理。
Jvm 上全局
CoroutineExceptionHandler
的配置,本质上是对ServiceLoader
的应用,以前咱们在讲Dispatchers.Main
的时候提到过,Jvm 上它的实现也是经过ServiceLoader
来加载的。
须要明确的一点是,经过 async
启动的协程出现未捕获的异常时会忽略 CoroutineExceptionHandler
,这与 launch
的设计思路是不一样的。
异常传播还涉及到协程做用域的概念,例如咱们启动协程的时候一直都是用的 GlobalScope
,意味着这是一个独立的顶级协程做用域,此外还有 coroutineScope { ... }
以及 supervisorScope { ... }
。
这么说仍是比较抽象,所以咱们拿一些例子来分析一下:
suspend fun main() {
log(1)
try {
coroutineScope { //①
log(2)
launch { // ②
log(3)
launch { // ③
log(4)
delay(100)
throw ArithmeticException("Hey!!")
}
log(5)
}
log(6)
val job = launch { // ④
log(7)
delay(1000)
}
try {
log(8)
job.join()
log("9")
} catch (e: Exception) {
log("10. $e")
}
}
log(11)
} catch (e: Exception) {
log("12. $e")
}
log(13)
}
复制代码
这例子稍微有点儿复杂,但也不难理解,咱们在一个 coroutineScope
当中启动了两个协程 ②④,在 ② 当中启动了一个子协程 ③,做用域直接建立的协程记为①。那么 ③ 当中抛异常会发生什么呢?咱们先来看下输出:
11:37:36:208 [main] 1
11:37:36:255 [main] 2
11:37:36:325 [DefaultDispatcher-worker-1] 3
11:37:36:325 [DefaultDispatcher-worker-1] 5
11:37:36:326 [DefaultDispatcher-worker-3] 4
11:37:36:331 [main] 6
11:37:36:336 [DefaultDispatcher-worker-1] 7
11:37:36:336 [main] 8
11:37:36:441 [DefaultDispatcher-worker-1] 10. kotlinx.coroutines.JobCancellationException: ScopeCoroutine is cancelling; job=ScopeCoroutine{Cancelling}@2bc92d2f
11:37:36:445 [DefaultDispatcher-worker-1] 12. java.lang.ArithmeticException: Hey!!
11:37:36:445 [DefaultDispatcher-worker-1] 13
复制代码
注意两个位置,一个是 10,咱们调用 join
,收到了一个取消异常,在协程当中支持取消的操做的suspend方法在取消时会抛出一个 CancellationException
,这相似于线程中对 InterruptException
的响应,遇到这种状况表示 join
调用所在的协程已经被取消了,那么这个取消到底是怎么回事呢?
原来协程 ③ 抛出了未捕获的异常,进入了异常完成的状态,它与父协程 ② 之间遵循默认的做用域规则,所以 ③ 会通知它的父协程也就是 ② 取消,② 根据做用域规则通知父协程 ① 也就是整个做用域取消,这是一个自下而上的一次传播,这样身处 ① 当中的 job.join
调用就会抛异常,也就是 10 处的结果了。若是不是很理解这个操做,想一下咱们说到的,coroutineScope
内部启动的协程就是“一损俱损”。实际上因为父协程 ① 被取消,协程④ 也不能幸免,若是你们有兴趣的话,也能够对 ④ 当中的 delay
进行捕获,同样会收获一枚取消异常。
还有一个位置就是 12,这个是咱们对 coroutineScope
总体的一个捕获,若是 coroutineScope
内部觉得异常而结束,那么咱们是能够对它直接 try ... catch ...
来捕获这个异常的,这再一次代表协程把异步的异常处理到同步代码逻辑当中。
那么若是咱们把 coroutineScope
换成 supervisorScope
,其余不变,运行结果会是怎样呢?
11:52:48:632 [main] 1
11:52:48:694 [main] 2
11:52:48:875 [main] 6
11:52:48:892 [DefaultDispatcher-worker-1 @coroutine#1] 3
11:52:48:895 [DefaultDispatcher-worker-1 @coroutine#1] 5
11:52:48:900 [DefaultDispatcher-worker-3 @coroutine#3] 4
11:52:48:905 [DefaultDispatcher-worker-2 @coroutine#2] 7
11:52:48:907 [main] 8
Exception in thread "DefaultDispatcher-worker-3 @coroutine#3" java.lang.ArithmeticException: Hey!!
at com.bennyhuo.coroutines.sample2.exceptions.ScopesKt$main$2$1$1.invokeSuspend(Scopes.kt:17)
at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
at kotlinx.coroutines.DispatchedTask.run(Dispatched.kt:238)
at kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely(CoroutineScheduler.kt:594)
at kotlinx.coroutines.scheduling.CoroutineScheduler.access$runSafely(CoroutineScheduler.kt:60)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:742)
11:52:49:915 [DefaultDispatcher-worker-3 @coroutine#2] 9
11:52:49:915 [DefaultDispatcher-worker-3 @coroutine#2] 11
11:52:49:915 [DefaultDispatcher-worker-3 @coroutine#2] 13
复制代码
咱们能够看到,1-8 的输出其实没有本质区别,顺序上的差别是线程调度的先后形成的,并不会影响协程的语义。差异主要在于 9 与 十、11与12的区别,若是把 scope 换成 supervisorScope
,咱们发现 ③ 的异常并无影响做用域以及做用域内的其余子协程的执行,也就是咱们所说的“自食其果”。
这个例子其实咱们再稍作一些改动,为 ② 和 ③ 增长一个 CoroutineExceptionHandler
,就能够证实咱们前面提到的另一个结论:
首先咱们定义一个 CoroutineExceptionHandler
,咱们经过上下文获取一下异常对应的协程的名字:
val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable ->
log("${coroutineContext[CoroutineName]} $throwable")
}
复制代码
接着,基于前面的例子咱们为 ② 和 ③ 添加 CoroutineExceptionHandler
和名字:
...
supervisorScope { //①
log(2)
launch(exceptionHandler + CoroutineName("②")) { // ②
log(3)
launch(exceptionHandler + CoroutineName("③")) { // ③
log(4)
...
复制代码
再运行这段程序,结果就比较有意思了:
...
07:30:11:519 [DefaultDispatcher-worker-1] CoroutineName(②) java.lang.ArithmeticException: Hey!!
...
复制代码
咱们发现触发的 CoroutineExceptionHandler
居然是协程 ② 的,意外吗?不意外,由于咱们前面已经提到,对于 supervisorScope
的子协程 (例如 ②)的子协程(例如 ③),若是没有明确指出,它是遵循默认的做用于规则的,也就是 coroutineScope
的规则了,出现未捕获的异常会尝试传递给父协程并尝试取消父协程。
究竟使用什么 Scope,你们本身根据实际状况来肯定,我给出一些建议:
固然,对于可能出异常的状况,请你们尽可能作好异常处理,不要将问题复杂化。
前面咱们举例子一直用的是 launch
,启动协程其实经常使用的还有 async
、actor
和 produce
,其中 actor
和 launch
的行为相似,在未捕获的异常出现之后,会被当作为处理的异常抛出,就像前面的例子那样。而 async
和 produce
则主要是用来输出结果的,他们内部的异常只在外部消费他们的结果时抛出。这两组协程的启动器,你也能够认为分别是“消费者”和“生产者”,消费者异常当即抛出,生产者只有结果消费时抛出异常。
actor
和produce
这两个 API 目前处于比较微妙的境地,可能会被废弃或者后续提供替代方案,不建议你们使用,咱们在这里就不展开细讲了。
那么消费结果指的是什么呢?对于 async
来说,就是 await
,例如:
suspend fun main() {
val deferred = GlobalScope.async<Int> {
throw ArithmeticException()
}
try {
val value = deferred.await()
log("1. $value")
} catch (e: Exception) {
log("2. $e")
}
}
复制代码
这个从逻辑上很好理解,咱们调用 await
时,指望 deferred
可以给咱们提供一个合适的结果,但它由于出异常,没有办法作到这一点,所以只好给咱们丢出一个异常了。
13:25:14:693 [main] 2. java.lang.ArithmeticException
复制代码
咱们本身实现的 getUserCoroutine
也属于相似的状况,在获取结果时,若是请求出了异常,咱们就只能拿到一个异常,而不是正常的结果。相比之下,join
就有趣的多了,它只关注是否执行完,至因而由于什么完成,它不关心,所以若是咱们在这里替换成 join
:
suspend fun main() {
val deferred = GlobalScope.async<Int> {
throw ArithmeticException()
}
try {
deferred.join()
log(1)
} catch (e: Exception) {
log("2. $e")
}
}
复制代码
咱们就会发现,异常被吞掉了!
13:26:15:034 [main] 1
复制代码
若是例子当中咱们用 launch
替换 async
,join
处仍然不会有任何异常抛出,仍是那句话,它只关心有没有完成,至于怎么完成的它不关心。不一样之处在于, launch
中未捕获的异常与 async
的处理方式不一样,launch
会直接抛出给父协程,若是没有父协程(顶级做用域中)或者处于 supervisorScope
中父协程不响应,那么就交给上下文中指定的 CoroutineExceptionHandler
处理,若是没有指定,那传给全局的 CoroutineExceptionHandler
等等,而 async
则要等 await
来消费。
无论是哪一个启动器,在应用了做用域以后,都会按照做用域的语义进行异常扩散,进而触发相应的取消操做,对于
async
来讲就算不调用await
来获取这个异常,它也会在coroutineScope
当中触发父协程的取消逻辑,这一点请你们注意。
这一篇咱们讲了协程的异常处理。这一起稍微显得有点儿复杂,但仔细理一下主要有三条线:
若是你们能把这三点理解清楚了,那么协程的异常处理能够说就很是清晰了。文中由于异常传播的缘由,咱们提到了取消,但没有展开详细讨论,后面咱们将会专门针对取消输出一篇文章,帮助你们加深理解。
join 在父协程被取消时有一个 bug 会致使不抛出取消异常,我在准备本文时发现该问题,目前已经提交到官方并获得了修复,预计合入到 1.2.1 发版,你们有兴趣能够查看这个 issue:No CancellationException thrown when join on a crashed Job。
固然,这个 bug 对于生成环境的影响很小,你们也不要担忧。
欢迎关注 Kotlin 中文社区!
中文官网:www.kotlincn.net/
中文官方博客:www.kotliner.cn/
公众号:Kotlin
知乎专栏:Kotlin
CSDN:Kotlin中文社区
掘金:Kotlin中文社区
简书:Kotlin中文社区