相关文章:
Android进阶宝典 — Kotlin协程分析(创建、取消、超时)
1 组合挂起函数 – async
组合挂起函数,其实就是将多个挂起函数组合,然后输出一个最终的结果,例如有下面这个场景:
有两个接口,需要从服务端拉取结果,然后将两个结果进行整合
我们通过定义两个挂起函数,模拟从服务端获取结果,为什么是挂起函数,是因为一定需要扔到协程中执行,再者就是在进行网络请求时,可以被取消,因为只有挂起函数才能被取消。
* 第一个接口,1s后返回一个结果
*/
suspend fun httpOne():Int{
delay(1000)
return 25
}
/**
* 第二个接口,1.5s后返回一个结果
*/
suspend fun httpTwo():Int{
delay(1500)
return 20
}
那么当执行两个挂起函数时,下面这种方式其实是串行执行的,当httpOne执行完成之后,再执行httpTwo,为什么呢?其实这有点类似于通过coroutineScope构建了一个协程作用域,当执行httpOne时,runBlocking会被挂起,等到执行结束之后再恢复执行httpTwo。
fun testHttp() = runBlocking {
val time = measureTimeMillis {
val httpOne = httpOne()
val httpTwo = httpTwo()
Log.e("TAG","httpOne $httpOne httpTwo $httpTwo")
}
Log.e("TAG","cost time $time")
}
如果前后有依赖关系,例如httpTwo需要httpOne结果,那么这样做无可厚非该花的时间还是要继续花,如果两者没有前后依赖关系,只需要取到各自的执行结果再做处理,显然上面的方式是比较耗时,这时我们可以考虑开启子协程。
var httpOne = 0
var httpTwo = 0
fun testHttp() = runBlocking {
launch {
httpOne = httpOne()
}
launch {
httpTwo = httpTwo()
}
}
那么当runBlocking中的子协程全部执行完毕之后,runBlocking就会退出,此时按照耗时最长的方法httpTwo,此时拿到全部的结果只需要1.5s。
1.1 async实现协程并发
既然我们目的就是为了拿到两个接口的返回值,其实上面的处理方式并不太优雅,标准的处理方式应该是通过async创建协程,此时调用await就可以挂起等待结果返回, 最终拿到两个接口的返回值。
fun testHttp() = runBlocking {
val time = measureTimeMillis {
val one = async { httpOne() }
val two = async { httpTwo() }
Log.e("TAG", "one ${one.await()} two ${two.await()}")
}
Log.e("TAG", "cost time $time")
}
2023-06-22 17:05:09.917 31690-31690/com.lay.nowinandroid E/TAG: one 25 two 20
2023-06-22 17:05:09.917 31690-31690/com.lay.nowinandroid E/TAG: cost time 1516
通过结果我们发现,这个时候实现了协程的并发,最终耗时为1.5s,我们看下await函数,
public suspend fun await(): T
它也是一个挂起函数,当调用await时会挂起当前协程,但不会阻塞当前线程,因此使用async能够实现不阻塞线程的并发任务。
通过async创建协程之后,会立刻执行协程作用域中的代码,如果在某些场景中,我们可能不会立刻请求接口,在做一些计算之后,才会启动协程,那么这个时候可以通过”懒汉“的方式创建协程,将其启动模式设置为CoroutineStart.LAZY。
fun testHttp() = runBlocking {
val time = measureTimeMillis {
val one = async(start = CoroutineStart.LAZY) { httpOne() }
val two = async(start = CoroutineStart.LAZY) { httpTwo() }
Log.e("TAG", "one ${one.await()} two ${two.await()}")
}
Log.e("TAG", "cost time $time")
}
那么这种方式创建的协程,只有在调用await或者start的时候,才会启动协程执行作用域内的代码。但是是否调用start方法,对于协程执行的顺序有着直接的影响。
当仅仅执行await时,httpOne和httpTwo两个函数执行的顺序为串行的,其实就跟没有async一样;所以如果想要实现结构化的并发,必须要在调用await之前,调用start。
fun testHttp() = runBlocking {
val time = measureTimeMillis {
val one = async(start = CoroutineStart.LAZY) { httpOne() }
val two = async(start = CoroutineStart.LAZY) { httpTwo() }
val result = awaitAll(one,two)
Log.e("TAG", "one ${result[0]} two ${result[1]}")
}
Log.e("TAG", "cost time $time")
}
当然如果我们不想显示地调用start方法,可以直接调用awaitAll函数, 这里可以传入一组Deferred对象,在其内部就会调用start和await,最终返回一组结果,与传入的Deferred对象顺序一致。
1.2 async结构化并发异常
当我们使用async创建异步任务时,看下面的代码,
suspend fun testHttp2(): Int = coroutineScope {
val job1 = async { httpOne() }
val job2 = async { httpTwo() }
job1.await() + job2.await()
}
其实和之前写的类似,其实都是有问题的,是因为当某个async协程出现问题之后,协程作用域内的全部协程都会被取消,例如httpOne函数内部发生异常,那么httpTwo就不会正常执行了。
我们来模拟一下这个场景,httpTwo在执行时抛出了IllegalArgumentException异常:
/**
* 第一个接口,1s后返回一个结果
*/
suspend fun httpOne(): Int {
try {
delay(Long.MAX_VALUE)
return 25
} finally {
Log.e("TAG", "httpOne was cancelled")
}
}
/**
* 第二个接口,1.5s后返回一个结果
*/
suspend fun httpTwo(): Int {
delay(1500)
throw IllegalArgumentException("模拟抛出异常")
}
那么此时httpOne还没有返回结果,但是delay挂起函数会检测到协程被取消从而停止任务。
MainScope().launch {
try {
val result = testHttp2()
}catch (e:Exception){
Log.e("TAG","testHttp2 error $e")
}
}
当执行testHttp2函数时,我们发现httpOne被取消了。
2023-06-22 20:44:05.460 6123-6123/com.lay.nowinandroid E/TAG: httpOne was cancelled
2023-06-22 20:44:05.462 6123-6123/com.lay.nowinandroid E/TAG: testHttp2 error java.lang.IllegalArgumentException: 模拟抛出异常
所以当我们采用async实现结构化并发,要注意这种情况的发生,那么如何保证协程之间不受干扰呢?会在4.2小节中介绍,感兴趣的伙伴可以往下翻,这里我们需要记住一点就是,当一个协程出现异常时,作用域内的其他协程也会被取消。
1.3 launch和async的区别比较
对于launch和async的区别比较,我们从下面几个方面看异同;
- 返回值
通过源码我们可以看出来,launch返回的是Job对象,而async返回的是Deferred对象,其中Deferred是继承自Job,包含Job一切的属性,因此如果想取消协程时可以调用cancel;多出来的一个方法就是await,能够延迟返回结果。
- 是否阻塞线程
launch和async都不会阻塞线程,当launch协程作用域内执行挂起函数时,协程会被挂起,当async执行await函数时,也会挂起协程。当协程被挂起时,可释放底层线程干其他的事。
- 是否需要等待结果返回
launch不会阻塞到拿到结果之后才返回,而async如果不调用await和launch一样,只有调用await之后,才会挂起协程,一直阻塞到结果返回。
- 异常传播
对于launch和async异常传播的不同,会在4.1节中介绍。
所以当需要执行一个无结果的耗时任务时,例如删除文件,可以使用launch;而如果执行一个需要结果返回的耗时任务,例如网络请求,可以使用async。而且使用async能够替代传统的回调函数,避免出现回调地狱。
2 协程的上下文
前面我们通过介绍协程的基本用法,想必伙伴们对于协程已经有了一定的概念,接下来将会详细介绍协程的上下文,这部分就会涉及到线程的调度,能够更加深入理解协程的概念。
2.1 什么是协程上下文
在Android中,我们对于上下文并不陌生,像Activity、Service等四大组件都存在上下文Context,而且在组件中都可以直接拿到上下文使用,那么Kotlin协程中的上下文CoroutineContext,与Android中的上下文有异曲同工之妙。
既然我们能在Activity中拿到上下文,那么在协程中当然也可以拿到上下文,当我们创建一个协程之后,返回的Job对象就是协程的上下文主元素,除此之外,协程的上下文还包含其他的元素,例如协程调度器Dispatchers。当我们拿到上下文之后,还能够操作协程,例如执行取消操作cancel,说明从上下文中能够拿到协程,类似于在Context中能够拿到Activity。
2.2 协程调度器
在构建协程的时候,协程构建器launch可接收一个context参数,也就是CoroutineContext协程上下文,其中可以声明当前协程的调度器。
MainScope().launch(context = Dispatchers.Main + Job()) {
try {
val result = testHttp2()
}catch (e:Exception){
Log.e("TAG","testHttp2 error $e")
}
}
协程调度器可以确定当前协程在哪个线程上执行,例如主线程Main,子线程IO,或者默认Default,也可以分配在某个线程池中执行,也可以设置其不受限制地运行。
接下来通过代码看下协程调度器的作用:
lifecycleScope.launch {
Log.e("TAG","no params work on ${Thread.currentThread().name}")
}
lifecycleScope.launch(Dispatchers.Main) {
Log.e("TAG","Main work on ${Thread.currentThread().name}")
}
lifecycleScope.launch(Dispatchers.IO) {
Log.e("TAG","IO work on ${Thread.currentThread().name}")
}
lifecycleScope.launch(Dispatchers.Default) {
Log.e("TAG","Default work on ${Thread.currentThread().name}")
}
lifecycleScope.launch(Dispatchers.Unconfined) {
Log.e("TAG","Unconfined work on ${Thread.currentThread().name}")
}
这里使用了Activity或者Fragment中需要使用的协程作用域构建器lifecycleScope创建协程,最终拿到的结果我们看:
2023-06-23 12:58:39.596 9111-9111/com.lay.nowinandroid E/TAG: Unconfined work on main
2023-06-23 12:58:39.596 9111-9147/com.lay.nowinandroid E/TAG: Default work on DefaultDispatcher-worker-3
2023-06-23 12:58:39.596 9111-9144/com.lay.nowinandroid E/TAG: IO work on DefaultDispatcher-worker-1
2023-06-23 12:58:39.699 9111-9111/com.lay.nowinandroid E/TAG: no params work on main
2023-06-23 12:58:39.701 9111-9111/com.lay.nowinandroid E/TAG: Main work on main
接下来我们挨个分析
- 不传参数构建协程
public fun CoroutineScope.launch(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit
): Job {
val newContext = newCoroutineContext(context)
val coroutine = if (start.isLazy)
LazyStandaloneCoroutine(newContext, block) else
StandaloneCoroutine(newContext, active = true)
coroutine.start(start, coroutine, block)
return coroutine
}
通过源码我们可以看到,当不传参数时,默认的协程上下文为EmptyCoroutineContext,此时它会从启动它的CoroutineScope中继承上下文,也就是lifecycleScope的上下文。
val LifecycleOwner.lifecycleScope: LifecycleCoroutineScope
get() = lifecycle.coroutineScope
val Lifecycle.coroutineScope: LifecycleCoroutineScope
get() {
while (true) {
val existing = mInternalScopeRef.get() as LifecycleCoroutineScopeImpl?
if (existing != null) {
return existing
}
val newScope = LifecycleCoroutineScopeImpl(
this,
SupervisorJob() + Dispatchers.Main
)
if (mInternalScopeRef.compareAndSet(null, newScope)) {
newScope.register()
return newScope
}
}
}
通过源码我们可以发现,其实lifecycleScope作用域的上下文是Dispatchers.Main,跟主线程绑定的,因此通过lifecycleScope启动的协程,其上下文也是在主线程中。
- Dispatchers.Main + Dispatchers.IO
这两个就不需要过多赘述了,从命名来看就是协程运行在主线程或者子线程
- Dispatchers.Default
Default采用的是系统的默认调度器,也是工作在子线程,属于共享的线程池,当使用GlobalScope创建协程时,使用的调度器也是Dispatchers.Default。
- Dispatchers.Unconfined
从字面意思上来看,是散漫、自由、不受限制,因此它会工作在主线程中,但是仅仅维持到第一次挂起,恢复之后具体工作在哪个线程,完全由挂起函数来决定。
lifecycleScope.launch(Dispatchers.Unconfined) {
Log.e("TAG","Unconfined work on ${Thread.currentThread().name}")
delay(500)
Log.e("TAG","After Unconfined work on ${Thread.currentThread().name}")
}
2023-06-23 13:39:38.058 16249-16249/com.lay.nowinandroid E/TAG: Unconfined work on main
2023-06-23 13:39:38.578 16249-16281/com.lay.nowinandroid E/TAG: After Unconfined work on kotlinx.coroutines.DefaultExecutor
看到上面这个例子就能看到,在delay之前是工作在主线程,但是调用delay挂起之后,就会工作在一个线程池中,所以一会儿在主线程,一会儿不在主线程,显得极为散漫,所以这种调度器一般适用于不占用CPU,或者不需要刷新UI的任务。
除此之外,还可以通过newSingleThreadContext上下文,确切地创建一个新的线程,但这种方式并不推荐使用,涉及到了新建线程势必会消耗系统资源。
2.3 协程上下文中的Job
其实对于Job我们并不陌生,前面我们在讲协程创建的时候,无论是launch还是async,最终的返回值都是Job或者Job的子类,在任意协程作用域中,都可以通过上下文来获取对应的Job,所以除了协程调度器之外,Job也是上下文中的一部分元素。
val job = lifecycleScope.launch {
val job = coroutineContext[Job]
Log.e("TAG","no params work on ${Thread.currentThread().name} job $job")
}
Log.e("TAG","launch job $job")
2023-06-23 14:51:02.347 28175-28175/com.lay.nowinandroid E/TAG: launch job StandaloneCoroutine{Active}@69e0010
2023-06-23 14:47:00.149 27410-27410/com.lay.nowinandroid E/TAG: no params work on main job StandaloneCoroutine{Active}@69e0010
这里我们发现job对象为StandaloneCoroutine,而且当前协程是Active活跃的,如果看过launch的源码,应该可以看到默认创建的Job对象就是StandaloneCoroutine,而且与launch创建返回的job是一致的。
@Suppress("EXTENSION_SHADOWED_BY_MEMBER")
public val CoroutineScope.isActive: Boolean
get() = coroutineContext[Job]?.isActive ?: true
所以之前我们在讲协程取消的时候,在协程作用域中直接调用isActive扩展属性,其实拿到的就是Job的isActive属性。
2.3.1 Job的世袭机制
当我们通过父协程作用域创建子协程时,通过前面的知识,我们知道子协程会继承自父协程的上下文,那么此时子协程的Job就会成为父协程Job的子Job,那么在取消父协程的时候,会递归取消子协程。
首先我们先看下对于父协程上下文的继承,我们看下面的例子:
fun testCoroutineContext() = runBlocking {
launch(Dispatchers.IO + CoroutineName("parent")) {
Log.e("TAG", "testCoroutineContext: parent context $coroutineContext")
launch {
Log.e("TAG", "testCoroutineContext: child context $coroutineContext")
}
}
}
2023-06-23 18:15:03.357 31109-31143/com.lay.nowinandroid E/TAG: testCoroutineContext: parent context [CoroutineName(parent), StandaloneCoroutine{Active}@72b055a, Dispatchers.IO]
2023-06-23 18:15:03.358 31109-31143/com.lay.nowinandroid E/TAG: testCoroutineContext: child context [CoroutineName(parent), StandaloneCoroutine{Active}@68ee38b, Dispatchers.IO]
我们看到,当创建一个新的协程之后,会创建新的Job对象,但是CoroutineName和Dispatcher都是继承自父协程,如果子协程想要覆盖父协程上下文,那么就需要手动声明。
当需要取消父协程时,例如:
fun testJob() = runBlocking {
val job = launch {
Log.e("TAG", "parent current thread ${Thread.currentThread().name}")
// JOB1
launch {
delay(100)
Log.e("TAG", "child current thread ${Thread.currentThread().name}")
delay(500)
Log.e("TAG", "may be cancelled----")
}
// JOB2
GlobalScope.launch {
Log.e("TAG", "GlobalScope start")
delay(1000)
Log.e("TAG", "do it always")
}
}
delay(200)
job.cancel()
}
例如JOB1是通过父协程作用域创建的,那么JOB1协程的job就属于父协程Job的子Job;而JOB2则是通过GlobalScope创建一个协程,此协程与应用程序生命周期绑定,与父协程无关,因此当父协程取消之后,只有JOB1被取消,而JOB2正常执行。
2023-06-23 15:39:19.994 5109-5109/com.lay.nowinandroid E/TAG: parent current thread main
2023-06-23 15:39:20.001 5109-5143/com.lay.nowinandroid E/TAG: GlobalScope start
2023-06-23 15:39:20.118 5109-5141/com.lay.nowinandroid E/TAG: child current thread DefaultDispatcher-worker-1
2023-06-23 15:39:21.040 5109-5141/com.lay.nowinandroid E/TAG: do it always
所以我们可以这么理解,一个父协程总是等待所有子协程JOB完成之后,才会结束;一旦父协程结束,那么内部全部子协程(通过父协程作用域创建的)都会结束。
2.4 组合上下文
前面我们提到,协程的上下文其实是一组元素,那么对于这一组元素例如:调度器、Job、协程名等,如果组合在一起,作为CoroutineContext传到launch参数中呢,是可以使用+运算符。
launch(Dispatchers.IO + Job() + CoroutineName("子协程Job1")) {
delay(100)
Log.e("TAG", "child current thread ${Thread.currentThread().name}")
delay(500)
Log.e("TAG", "may be cancelled----")
}
当时不是随便一个对象都可以扔到里面,我们看下每个上下文元素的源码。
public interface Element : CoroutineContext {
/**
* A key of this coroutine context element.
*/
public val key: Key<*>
public override operator fun <E : Element> get(key: Key<E>): E? =
@Suppress("UNCHECKED_CAST")
if (this.key == key) this as E else null
public override fun <R> fold(initial: R, operation: (R, Element) -> R): R =
operation(initial, this)
public override fun minusKey(key: Key<*>): CoroutineContext =
if (this.key == key) EmptyCoroutineContext else this
}
所有上下文元素的基类,都是Element,它有一个抽象实现类AbstractCoroutineContextElement,像Dispatchers、CoroutineName都是实现了这个类,而Job则是实现了Element。
3 协程的启动模式
在第二小节中,我们分析了协程的上下文,再回头看看launch函数的第一个参数我们就分析完成了,接下来就是第二个参数CoroutineStart。
public fun CoroutineScope.launch(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit
): Job {
val newContext = newCoroutineContext(context)
val coroutine = if (start.isLazy)
LazyStandaloneCoroutine(newContext, block) else
StandaloneCoroutine(newContext, active = true)
coroutine.start(start, coroutine, block)
return coroutine
}
CoroutineStart,从字面意思来看就是启动模式,这块在官方文档中其实没有讲到,但这却是一个比较重要的知识点,当我们创建协程的时候,默认就是DEFAULT启动模式,除此之外,CoroutineStart还有以下几种启动模式:LAZY、ATOMIC、UNDISPATCHED,我们逐一分析。
3.1 CoroutineStart.DEFAULT启动模式
val job = scope.launch {
Log.e("TAG","协程创建了---->准备执行代码了")
delay(2000)
Log.e("TAG","协程执行完成了!")
}
//取消协程
Log.e("TAG","我要取消协程了---->")
job.cancel()
2023-06-23 21:51:40.471 5781-5781/com.lay.nowinandroid E/TAG: 我要取消协程了---->
end
这是我们经常会使用的创建协程的方式,此时启动模式为DEFAULT,当协程创建完成之后,就会立刻进入调度状态,那么只要这个协程没有执行完成,在任意时刻都可以被取消,哪怕协程还没有创建完成。
3.2 CoroutineStart.ATOMIC启动模式
val job = scope.launch(start = CoroutineStart.ATOMIC) {
Log.e("TAG","协程创建了---->准备执行代码了")
delay(2000)
Log.e("TAG","协程执行完成了!")
}
//取消协程
Log.e("TAG","我要取消协程了---->")
job.cancel()
2023-06-23 21:55:53.483 6431-6431/com.lay.nowinandroid E/TAG: 我要取消协程了---->
2023-06-23 21:55:53.587 6431-6431/com.lay.nowinandroid E/TAG: 协程创建了---->准备执行代码了
end
这种启动模式与DEFAULT大致一样,同样也是在协程创建完成之后立刻进行调度,但是在第一个挂起点之前,不会响应取消操作。
我们看协程第一个挂起点为执行delay函数,所以在此之前即便我们执行了cancel方法,但是delay之前的代码依然可以正常执行,只有当协程被挂起之后,才会响应取消操作。
3.3 CoroutineStart.LAZY启动模式
val job = scope.launch(start = CoroutineStart.LAZY) {
Log.e("TAG","协程创建了---->准备执行代码了")
delay(2000)
Log.e("TAG","协程执行完成了!")
}
//取消协程
Log.e("TAG","我要取消协程了---->")
job.cancel()
job.start()
2023-06-23 22:00:25.723 7129-7129/com.lay.nowinandroid E/TAG: 我要取消协程了---->
end
这种启动模式,我们在async这小节中介绍过,LAZY其实就相当于懒加载,当协程创建完成之后,不会立刻调度,而是当调用launch的start或者async的await时,才会进入调度。 所以如果在此之前执行取消操作,那么协程作用域内的代码便不会执行。
3.4 CoroutineStart.UNDISPATCHED启动模式
val job = scope.launch(context = Dispatchers.IO, start = CoroutineStart.UNDISPATCHED) {
Log.e("TAG","协程创建了---->准备执行代码了 ${Thread.currentThread().name}")
delay(2000)
Log.e("TAG","协程执行完成了!")
}
//取消协程
Log.e("TAG","我要取消协程了---->")
job.start()
2023-06-23 22:04:59.349 7942-7942/com.lay.nowinandroid E/TAG: 协程创建了---->准备执行代码了 main
2023-06-23 22:04:59.357 7942-7942/com.lay.nowinandroid E/TAG: 我要取消协程了---->
2023-06-23 22:05:01.400 7942-7979/com.lay.nowinandroid E/TAG: 协程执行完成了!
end
这种启动模式,一句话“爱谁谁”。虽然我们在上下文中指定了运行在IO线程,但是启动模式设置为UNDISPATCHED之后,此时Dispatcher将不再进行线程切换,继承了创建协程作用域的上下文,在main线程执行,所以这种启动模式一般很少用,风险比较大。
4 协程的异常处理
前面我们在介绍协程取消时,提到过当协程被取消之后,会抛出CancellationException,但是由于协程的内部异常处理机制将其静默处理掉了,但是如果在协程中抛出其他的异常,是没有这种兜底策略的,所以我们需要关注一下对于协程异常的处理。
4.1 launch和async异常处理
我们知道,launch和async都会创建一个协程,那么假设现在全部都在根协程中发生了异常,
- 顶层协程发生异常
launch {
delay(200)
throw IllegalArgumentException("发生异常了~")
}
假设在父协程中抛出了IllegalArgumentException一场,此时在控制台会打印异常信息,app发生了crash。
Caused by: java.lang.IllegalArgumentException: 发生异常了~
像这种场景下,我们想到在Java中处理异常的方式,我们可以使用try-catch捕获异常,于是我们采用了下面的这种方式去捕获异常。
try {
launch {
delay(200)
throw IllegalArgumentException("发生异常了~")
}
} catch (e: Exception) {
Log.e("TAG", "捕获到了异常 $e")
}
结果发现还是崩溃了,这是为什么呢?明明这里已经加了catch结果还是崩溃了,所以这里需要记住一个知识点,通过launch启动的顶层协程(父协程)是不会传播异常的。
什么是传播异常?就是在协程中出现异常之后,是否会将异常抛给协程所在的线程中,如果不会,那么我们加try-catch的意义就没有了,会直接导致应用崩溃。
那么,如果是采用async创建协程发生异常时,我们发现在调用await时,是可以捕获异常的。
val scope = MainScope()
val job = scope.async {
delay(200)
throw IllegalArgumentException("协程抛出异常~")
}
scope.launch {
try {
job.await()
}catch (e:Exception){
Log.e("TAG","await 捕获了异常")
}
}
也就是说,当使用async创建顶层协程的时候,异常是传播的,而且是可以通过try-catch捕获到对应的异常。
- 子协程发生异常
这里我们不再讨论launch创建子协程的异常,因为无法进行异常传播,只要子协程发生了异常,所有的协程都会被取消,这里我们看下async创建子协程之后,是否还会进行异常传播。
val scope = MainScope()
scope.launch {
//async创建一个子协程
val job = async {
delay(200)
throw IllegalArgumentException("协程抛出异常~")
}
try {
job.await()
}catch (e:Exception){
Log.e("TAG","await 捕获了异常")
}
}
2023-06-23 20:50:32.096 27028-27028/com.lay.nowinandroid E/TAG: await 捕获了异常
这里我们在scope创建的协程作用域下通过async又创建一个子协程,此时通过try-catch捕获异常,发现异常已经捕获到了,但是app发生了crash,而且即便是不执行await,依然发生了异常。
难道说异常传播不再起作用了吗?其实无论通过async创建顶层协程还是子协程,异常都会传播,而且均可被try-catch捕获到,但是app发生了crash,这种情况下,就需要请出另一个上下文元素来帮助解决。
4.2 CoroutineExceptionHandler全局异常捕获
前面我们提到,通过launch创建的协程发生异常,或者通过async创建的子协程发生异常,都会导致app发生crash,此时解决方案就是通过CoroutineExceptionHandler来解决。
val handler = CoroutineExceptionHandler { _, exp ->
Log.e("TAG", "get exp $exp")
}
val scope = MainScope()
scope.launch(handler) {
//async创建一个子协程
val job = async {
delay(200)
throw IllegalArgumentException("协程抛出异常~")
}
job.await()
}
2023-06-23 20:59:58.806 28727-28727/com.lay.nowinandroid E/TAG: get exp java.lang.IllegalArgumentException: 协程抛出异常~
当通过MainScope创建一个顶层协程时,将CoroutineExceptionHandler作为上下文传递到launch中,此时就可以捕获当前协程和子协程的全部异常,并且保证app不发生crash。
如果熟悉java异常机制的伙伴应该了解,Java中有一个Thread.UncautchExceptionHandler全局异常捕获,其实CoroutineExceptionHandler与其有些类似,但是Java中只能收集异常信息,但是app还是会crash。
val handler = CoroutineExceptionHandler { _, exp ->
Log.e("TAG", "get exp $exp")
}
val scope = MainScope()
scope.launch(handler) {
launch {
delay(2000)
Log.e("TAG","协程1执行完成")
}
launch {
delay(500)
throw IOException()
}
}
对于launch协程不传播异常的问题,我们通过CoroutineExceptionHandler也可以捕获到异常,但是这里就会有一个问题,虽然app不会crash,但是因为一个协程发生了异常,导致了全部的协程都被取消了,有什么办法能够保证协程之间互不干扰呢?
4.2.1 supervisorScope 和 SupervisorJob
要保证协程之间互相不干扰,第一种方案就是通过supervisorScope构建一个协程作用域,在此作用域内协程之间执行不受干扰,即便某个协程发生了异常,其他协程正常执行。
val handler = CoroutineExceptionHandler { _, exp ->
Log.e("TAG", "get exp $exp")
}
val scope = MainScope()
scope.launch(handler) {
supervisorScope {
launch {
delay(2000)
Log.e("TAG", "协程1执行完成")
}
launch {
delay(500)
throw IOException()
}
}
}
2023-06-23 21:10:48.073 30496-30496/com.lay.nowinandroid E/TAG: get exp java.io.IOException
2023-06-23 21:10:49.557 30496-30496/com.lay.nowinandroid E/TAG: 协程1执行完成
第二种方式就是通过指定SupervisorJob上下文,例如子协程2就指定了SupervisorJob上下文,此时发生异常时,协程1并没有影响。
val handler = CoroutineExceptionHandler { _, exp ->
Log.e("TAG", "get exp $exp")
}
val scope = MainScope()
scope.launch(handler) {
launch {
delay(2000)
Log.e("TAG", "协程1执行完成")
}
launch(SupervisorJob()) {
delay(500)
throw IOException()
}
}
其实通过指定SupervisorJob上下文,作用就是阻止了异常向外传播,或者说向父协程传播,以此起到了协程间互不影响的作用。
4.2.2 异常聚合
前面我们介绍了当异常发生时,所有子协程都会被取消,所以一般情况下只会有一个异常交由顶层协程处理,假设在取消协程时,又抛出一个异常,此时会怎么处理呢?
val handler = CoroutineExceptionHandler { _, exp ->
Log.e("TAG", "get exp $exp suppressed ${exp.suppressed.contentToString()}")
}
val scope = MainScope()
scope.launch(handler) {
launch {
try {
delay(2000)
}catch (e:Exception){
//协程取消时,再抛出异常
throw IllegalArgumentException()
}
Log.e("TAG", "协程1执行完成")
}
launch {
delay(500)
throw IOException()
}
}
2023-06-23 21:30:34.190 2391-2391/com.lay.nowinandroid E/TAG: get exp java.io.IOException suppressed [java.lang.IllegalArgumentException]
其实这里对于多个异常处理是有一套规则的:
⼀般规则是“取第⼀个异常”,因此将处理第⼀个异常。在第⼀个异常之后发⽣的所有其他异常都作为被抑制的异常绑定⾄第⼀个异常。
所以在获取异常的suppressed属性时,会发现它会存在在一组异常,例如抛出的第二个异常IllegalArgumentException,就是按照异常聚合的规则来完成的。