Kotlin协程异常机制与优雅封装

cd21a7754cc257460670dd7404929b7d.jpeg

/   今日科技快讯   /

近日,尽管美国电动汽车制造商特斯拉的首席执行官埃隆·马斯克可能很担心全球经济形势,但公司招聘网站上列出的职位数表明,特斯拉正在加大招聘力度,几乎涵盖所有类型的工作岗位。

/   作者简介   /

本篇文章转自程序员江同学的博客,文章主要分享了协程的异常机制以及相关的封装,相信会对大家有所帮助!

原文地址:

https://juejin.cn/post/6935472332735512606

/   前言   /

本文主要包括以下内容:


1.协程的3种作用域以及异常的传播方式
2.协程异常的两种捕获方式及对比
3.协程异常的优雅封装

/   协程的异常是怎么传播的   /

首先了解下协程作用域

协程作用域分为顶级作用域,协同作用域与主从作用域,分别对应GlobalScope,coroutineScope,supervisorScope。

339da1b28a131226874de7b77e94dfde.jpeg

作用分析:

c27a24bac78a025343a0373ab7c84152.png

说明

  • C2-1发生异常的时候,C2-1->C2->C2-2->C2->C1->C3(包括里面的子协程)->C4

  • C3-1-1发生异常的时候,C3-1-1->C3-1-1-1,其他不受影响

  • C3-1-1-1发生异常的时候,C3-1-1-1->C3-1-1,其他不受影响


举个例子


C1和C2没有关系
GlobalScope.launch { //协程C1
    GlobalScope.launch {//协程C2
        //...
    }
}

C1,C2不会互相影响,完全独立。

C2和C3是C1的子协程,C2和C3异常会取消C1
GlobalScope.launch { //协程C1
    coroutineScoope {
         launch{}//协程C2
         launch{}//协程C3
    }
}

C2和C3是C1的子协程,C2和C3异常不会取消C1

GlobalScope.launch { //协程C1
    supervisorScope {
         launch{}//协程C2
         launch{}//协程C3
    }
}

/   如何捕获异常   /

直接用Try,Catch会有什么问题?

在java与Kotlin中,我们一般直接try catch捕获异常。

fun main() {
    val topLevelScope = CoroutineScope(Job())
    topLevelScope.launch {
        try {
            throw RuntimeException("RuntimeException in coroutine")
        } catch (exception: Exception) {
            println("Handle $exception")
        }
    }
    Thread.sleep(100)
}

// 输出
// Handle java.lang.RuntimeException: RuntimeException in coroutine

但是当我们在try模块中launch一个新的协程时,会有一个意外的发现。

fun main() {
    val topLevelScope = CoroutineScope(Job())
    topLevelScope.launch {
        try {
            launch {
                throw RuntimeException("RuntimeException in nested coroutine")
            }
        } catch (exception: Exception) {
            println("Handle $exception")
        }
    }
    Thread.sleep(100)
}

你会发现捕获失效了,并且app crash了。我们发现try catch无法catch住子协程的异常。

发生了什么


在协程中未捕获的异常会发生什么呢?协程最创新的功能之一就是结构化并发。为了使结构化并发的所有功能成为可能,CoroutineScope的Job对象以及Coroutines和Child-Coroutines的Job对象形成了父子关系的层次结构。 

未传播的异常(而不是重新抛出)是“在工作层次结构中传播”。这种异常传播会导致父Job的失败,进而导致其子级所有Job的取消。

上面示例代码的job层次大概如下所示:

316d1cb7083569b8fe54fea8bd056bd8.png

子协程的异常传播到协程(1)的Job,然后传播到topLevelScope(2)的Job

b744eac740e953e056c18cbced15ed93.png

传播的异常可以通过CoroutineExceptionHandler来捕获,如果没有设置,则将调用线程的未捕获异常处理程序,可能会导致退出应用。


我们看出,协程有两种异常处理机制,这也是协程的异常处理比较复杂的原因。

小结

如果协程本身不使用try-catch子句自行处理异常,则不会重新抛出该异常,因此无法通过外部try-catch子句进行处理。


异常会在“Job层次结构中传播”,可以由已设置的CoroutineExceptionHandler处理。如果未设置,则调用该线程的未捕获异常处理程序。

CoroutineExceptionHandler

现在我们知道,如果我们在try块中launch失败的协程,try-catch是没有用的。因此,我们需要配置一个CoroutineExceptionHandler,我们可以将context传递给启动协程生成器。


由于CoroutineExceptionHandler是一个ContextElement,因此我们可以通过在启动子协程时将其传递给launch:

fun main() {

    val coroutineExceptionHandler = CoroutineExceptionHandler { coroutineContext, exception ->
        println("Handle $exception in CoroutineExceptionHandler")
    }

    val topLevelScope = CoroutineScope(Job())

    topLevelScope.launch {
        launch(coroutineExceptionHandler) {
            throw RuntimeException("RuntimeException in nested coroutine")
        }
    }

    Thread.sleep(100)
}

// 输出
// Exception in thread "DefaultDispatcher-worker-2" java.lang.RuntimeException: RuntimeException in nested coroutine

可以发现程序还是crash了。

为什么不生效?

这是因为给子协程设置CoroutineExceptionHandler是没有效果的,我们必须给顶级协程设置,或者初始化Scope时设置才有效。

// ...
val topLevelScope = CoroutineScope(Job() + coroutineExceptionHandler)
// ...

// ...
topLevelScope.launch(coroutineExceptionHandler) {
// ...
小结

为了使CoroutineExceptionHandler起作用,必须将其设置在CoroutineScope或顶级协程中。

Try Catch与CoroutineExceptionHandler对比

如上面介绍的,协程支持两种异常处理机制,那么我们应该选择哪种呢?

CoroutineExceptionHandler的官方文档提供了一些很好的答案:

“ CoroutineExceptionHandler是用于全局“全部捕获”行为的最后手段。您无法从CoroutineExceptionHandler中的异常中恢复。当调用处理程序时,协程已经完成,并带有相应的异常。通常,处理程序用于记录异常,显示某种错误消息,终止和/或重新启动应用程序。

如果需要在代码的特定部分处理异常,建议在协程内部的相应代码周围使用try / catch。这样,您可以防止协程异常完成(现在已捕获异常),重试该操作和/或采取其他任意操作:”


小结

如果要在协程完成之前重试该操作或执行其他操作,请使用try / catch。


请记住,通过直接在协同程序中捕获异常,该异常不会在Job层次结构中传播,也不会利用结构化并发的取消功能。


而使用CoroutineExceptionHandler处理应该在协程完成后发生的逻辑。
可以看出,我们绝大多数时候应该使用CoroutineExceptionHandler。

launch{} vs async{}

我们上面的例子都是使用launch启动协程的异常,但是launch与async的协常处理是完全不同的。


下面看个例子:

fun main() {

    val topLevelScope = CoroutineScope(SupervisorJob())

    topLevelScope.async {
        throw RuntimeException("RuntimeException in async coroutine")
    }

    Thread.sleep(100)
}

// 没有输出

为什么这里不会抛出异常?


我们先要了解下launch与async的区别。从launch开始的协程的返回类型是Job,它只是协程的一种表示形式,没有返回值。如果我们需要协程的某些结果,则必须使用async,它返回Deferred,这是一种特殊的Job,另外还保存一个结果值。如果异步协程失败,则将该异常封装在Deferred返回类型中,并在我们调用suspend函数.await()来检索其结果值时将其重新抛出。因此,我们可以使用try-catch子句将对.await()的调用括起来。

fun main() {

    val topLevelScope = CoroutineScope(SupervisorJob())

    val deferredResult = topLevelScope.async {
        throw RuntimeException("RuntimeException in async coroutine")
    }

    topLevelScope.launch {
        try {
            deferredResult.await()
        } catch (exception: Exception) {
            println("Handle $exception in try/catch")
        }
    }

    Thread.sleep(100)
}

// 输出
// Handle java.lang.RuntimeException: RuntimeException in async coroutine in try/catch

注意:如果async协程是顶级协程,则会将异常封装在Deferred中,等待调用await才会抛出异常。


否则,该异常将立即传播到Job层次结构中,并由CoroutineExceptionHandler处理,甚至传递给线程的未捕获异常处理程序,即使不对其调用.await(),如以下示例所示:

fun main() {

    val coroutineExceptionHandler = CoroutineExceptionHandler { coroutineContext, exception ->
        println("Handle $exception in CoroutineExceptionHandler")
    }

    val topLevelScope = CoroutineScope(SupervisorJob() + coroutineExceptionHandler)
    topLevelScope.launch {
        async {
            throw RuntimeException("RuntimeException in async coroutine")
        }
    }
    Thread.sleep(100)
}

// 输出
// Handle java.lang.RuntimeException: RuntimeException in async coroutine in CoroutineExceptionHandler
小结

launch和async协程中未捕获的异常会立即在作业层次结构中传播。
但是,如果顶层Coroutine是从launch启动的,则异常将由CoroutineExceptionHandler处理或传递给线程的未捕获异常处理程序。

如果顶级协程以async方式启动,则异常封装在Deferred返回类型中,并在调用.await()时重新抛出。

coroutineScope异常处理特性

文章开头我们举了个例子,失败的协程将其异常传播到Job层次结构中,而不是重新抛出该异常,因此,外部try-catch无效。

但是,当我们用coroutineScope {}作用域函数将失败的协程包围起来时,会发生一些有趣的事情:

fun main() {

  val topLevelScope = CoroutineScope(Job())

  topLevelScope.launch {
        try {
            coroutineScope {
                launch {
                    throw RuntimeException("RuntimeException in nested coroutine")
                }
            }
        } catch (exception: Exception) {
            println("Handle $exception in try/catch")
        }
    }

    Thread.sleep(100)
}

// 输出
// Handle java.lang.RuntimeException: RuntimeException in nested coroutine in try/catch

现在,我们可以使用try-catch子句处理异常。

可以看出,范围函数coroutineScope {}重新抛出其失败子项的异常,而不是将其传播到Job层次结构中。

coroutineScope {}主要用于suspend函数中以实现“并行分解”。这些suspend函数将重新抛出其失败的协程的异常,因此我们可以相应地设置异常处理逻辑。

小结

范围函数coroutineScope {}重新抛出其失败的子协程的异常,而不是将其传播到Job层次结构中,这使我们能够使用try-catch处理失败的协程的异常。

supervisorScope异常处理特性

通过使用作用域函数supervisorScope {},我们将在Job层次结构中添加一个新的,独立的嵌套作用域,并将SupervisorJob作为其Job。

代码如下:

fun main() {

    val topLevelScope = CoroutineScope(Job())

    topLevelScope.launch {
        val job1 = launch {
            println("starting Coroutine 1")
        }

        supervisorScope {
            val job2 = launch {
                println("starting Coroutine 2")
            }

            val job3 = launch {
                println("starting Coroutine 3")
            }
        }
    }

    Thread.sleep(100)
}

现在,在这里了解异常处理至关重要的一点是,supervisorScope是一个必须独立处理异常的新的独立子域。它不会像coroutineScope那样重新抛出失败的协程的异常,也不会将异常传播到其父级– topLevelScope作业。

要理解的另一件至关重要的事情是,异常只会向上传播,直到它们到达顶级范围或SupervisorJob。这意味着job2和job3现在是顶级协程。这也意味着我们可以为它们添加CoroutineExceptionHandler。

fun main() {

    val coroutineExceptionHandler = CoroutineExceptionHandler { coroutineContext, exception ->
        println("Handle $exception in CoroutineExceptionHandler")
    }

    val topLevelScope = CoroutineScope(Job())

    topLevelScope.launch {
        val job1 = launch {
            println("starting Coroutine 1")
        }

        supervisorScope {
            val job2 = launch(coroutineExceptionHandler) {
                println("starting Coroutine 2")
                throw RuntimeException("Exception in Coroutine 2")
            }

            val job3 = launch {
                println("starting Coroutine 3")
            }
        }
    }

    Thread.sleep(100)
}

// 输出
// starting Coroutine 1
// starting Coroutine 2
// Handle java.lang.RuntimeException: Exception in Coroutine 2 in CoroutineExceptionHandler
// starting Coroutine 3

直接在supervisorScope中启动的协程是顶级协程,这也意味着async协程现在将其异常封装在其Deferred对象中,并且仅在调用.await()时被重新抛出。


这也是为什么viewModelScope中的async需要调用await才会抛出异常的原因

小结

范围函数supervisorScope {}在Job层次结构中添加了一个新的独立子范围,并将SupervisorJob作为这个scope的'job'。

这个新作用域不会在“Job层次结构”中传播其异常,因此它必须自行处理其异常。直接从supervisorScope启动的协程是顶级协程。


顶级协程与子协程在使用launch()或async()启动时的行为有所不同,此外,还可以在它们中安装CoroutineExceptionHandlers。

/   协程异常处理封装   /

如上文所说,在大多数时候,CoroutineExceptionHandler是一个更好的选择。

如我们所知,协程最大的优点是可以使用同步的方法写异步代码,CoroutineExceptionHandler有以下缺点:

  • 将异常处理代码与协程代码分隔开了,看上去不是同步代码。

  • 每次使用都要新建局部变量,不够优雅。

我们可以对CoroutineExceptionHandler进行封装,利用kotlin扩展函数,实现类似RxJava的调用效果。最后调用效果如下:

fun fetch() {
        viewModelScope.rxLaunch<String> {
            onRequest = {
                //网络请求
                resposity.getData()
            }
            onSuccess = {
                //成功回调
            }
            onError = {
                //失败回调
            }
        }
    }

代码实现

主要利用kotlin扩展函数及DSL语法,封装协程异常处理,达到类似RxJava调用的效果

fun <T> CoroutineScope.rxLaunch(init: CoroutineBuilder<T>.() -> Unit) {
    val result = CoroutineBuilder<T>().apply(init)
    val coroutineExceptionHandler = CoroutineExceptionHandler { _, exception ->
        result.onError?.invoke(exception)
    }
    launch(coroutineExceptionHandler) {
        val res: T? = result.onRequest?.invoke()
        res?.let {
            result.onSuccess?.invoke(it)
        }
    }
}

class CoroutineBuilder<T> {
    var onRequest: (suspend () -> T)? = null
    var onSuccess: ((T) -> Unit)? = null
    var onError: ((Throwable) -> Unit)? = null
}

如上即是一个简单封装,可实现上面演示的目标效果将请示,成功,失败分类展示,结构更加清晰,同时不需要写CoroutineExceptionHandler局部变量,更为优雅简洁。

推荐阅读:

我的新书,《第一行代码 第3版》已出版!

PermissionX 1.7发布,全面支持Android 13运行时权限

协程简史,一文讲清楚协程的起源、发展和实现

欢迎关注我的公众号

学习技术或投稿

3b8d12ad4ee1b19356fd1cbeabd72986.png

a858cefee21d6dc71ce3d603ca92bbd7.jpeg

长按上图,识别图中二维码即可关注

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值