一学就会的协程使用——基础篇(八)初识协程异常

2021-09-11  本文已影响0人  TeaCChen

1. 引言

如果学习使用了协程的取消和结构化并发部分的内容,那么协程的异常将是不得不说的内容。

2. 协程的取消异常

协程的取消篇当中,涉及过的ensureActivewithContext这两个函数可以作为取消的协作点,其实这两个函数的本质是,在协程调被取消后再触及这两个函数调用之时,会抛出异常CancellationException,没错,协程取消的协作点,便是以异常CancellationException的抛出来作为中断点的!

应该是常识的内容:异常抛出以后的代码逻辑将不会获得执行,直到异常被捕获;如果异常在应用内没有被捕获,那么最终将引起应用的闪退。

现在不妨再回头想想,在协程取消后,取消协作点以后的逻辑都不再执行,其实便是因为协作点抛出了异常所以终止了执行逻辑,而用协程的地方并没有因为异常而崩溃,说明协程内部已经对异常进行了捕获处理。

取消与异常紧密相关。协程内部使用 CancellationException 来进行取消,这个异常会被所有的处理者忽略,所以那些可以被 catch 代码块捕获的异常仅仅应该被用来作为额外调试信息的资源。

上面的内容,引用自Kotlin中文文档的”取消与异常“部分:https://www.kotlincn.net/docs/reference/coroutines/exception-handling.html

从这里可以得到一个信息,在协程执行的过程中,任意地方抛出CancellationException异常是安全,所谓的协程取消协作点,便是以该异常的抛出作为约定!

那么除去这个异常以外的其他异常呢?至少,在Android平台上的实践中,除去CancellationException以及其子异常,其他所有异常在协程中抛出,没有进行捕获或进行相关处理,最终会抛出到应用层面造成闪退。

3. 协程中的异常处理

有些情况下,开发代码所调用的API当中,不可避免地可能会遇到一些异常的抛出,在Java中会强制我们捕获所有非运行时异常(RuntimException)的异常,而在Kotlin当中,则没有在编译时强制捕获非运行时异常的限制,比如,前面一直使用的Thread#sleep()函数,如果在Java中调用,要么在函数声明异常的抛出,要么用try-catch包裹并捕获InterruptedException才能通过编译;但在Kotlin中可以随意调用,万一出现异常便是崩溃。

这里主要是引出Kotlin对于非运行时异常编译时的处理差异,用作调用API时某些场景下会抛出异常的示例,至于两种语言对于异常的详细设计,这里不作展开。

为更好地说明,这里设计一个函数,这个函数在某些场景下可能抛出非法状态异常(IllegalStateException):

private fun someAPIMayThrowException(scope: CoroutineScope) {
    val randomTime = randomMilli
    Thread.sleep(randomTime)
    scope.ensureActive()
    if (randomTime < TEN_SECONDS) {
        throw IllegalStateException("Throw Exception by code")
    }
}

这里在产生的随机休眠时间,如果小于10秒,则会抛出异常IllegalStateException,由于实践代码中,随机时间的产生范围是5000毫秒到10000毫秒,所以这个异常的抛出是绝大概率的(99.98%),这个异常抛出的概率大小可以由实践代码随意的控制,此处的概率仅为更方便地实践。

在通过launch启动一个协程调用这个方法:

private fun launchThrowExceptionClicked() {
    "launchThrowExceptionClicked".let {
        myLog(it)
    }
    scope.launch(Dispatchers.IO) {
        "Coroutine IO runs (launchThrowExceptionClicked)".let {
            myLog(it)
        }
        someAPIMayThrowException(this)
        "Coroutine IO runs after thread sleep (launchThrowExceptionClicked)".let {
            myLog(it)
        }
    }
}

然后,便会喜获异常并引起闪退:

java.lang.IllegalStateException: Throw Exception by code
        at pers.teacchen.coroutineusagedemo.activity.CoroutineExceptionActivity.someAPIMayThrowException(CoroutineExceptionActivity.kt:131)
        at pers.teacchen.coroutineusagedemo.activity.CoroutineExceptionActivity.access$someAPIMayThrowException(CoroutineExceptionActivity.kt:11)
        at pers.teacchen.coroutineusagedemo.activity.CoroutineExceptionActivity$launchThrowExceptionClicked$2.invokeSuspend(CoroutineExceptionActivity.kt:50)
        at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
        at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:56)
        at kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely(CoroutineScheduler.kt:571)
        at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.executeTask(CoroutineScheduler.kt:738)
        at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.runWorker(CoroutineScheduler.kt:678)
        at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:665)

闪退是app开发中最不能忍受的内容,所以,既然知道这里可能会触发异常,那么,像Java中一样,对可能抛出的异常的地方进行捕获处理(为方便对比,将新增异常捕获部分的代码封装为新的方法):

private fun launchWithTryCatchClicked() {
    "launchWithTryCatchClicked".let {
        myLog(it)
    }
    scope.launch(Dispatchers.IO) {
        "Coroutine IO runs (launchWithTryCatchClicked)".let {
            myLog(it)
        }
        try {
            someAPIMayThrowException(this)
        } catch (e: IllegalStateException) {
            myLog("catch IllegalStateException:$e")
        }
        "Coroutine IO runs after thread sleep (launchWithTryCatchClicked)".let {
            myLog(it)
        }
    }
}

与前面闪退代码中,核心逻辑的差别便是对someAPIMayThrowException(this)进行了try-catch包裹,且仅捕获IllegalStateException异常。这样的话,便不再有崩溃问题,最终所有的日志都能正常输入:

D/chenhj: launchWithTryCatchClicked ::running in Thread:[id:2][name:main]
D/chenhj: Coroutine IO runs (launchWithTryCatchClicked) ::running in Thread:[id:3090][name:DefaultDispatcher-worker-2]
D/chenhj: catch IllegalStateException:java.lang.IllegalStateException: Throw Exception by code ::running in Thread:[id:3090][name:DefaultDispatcher-worker-2]
D/chenhj: Coroutine IO runs after thread sleep (launchWithTryCatchClicked) ::running in Thread:[id:3090][name:DefaultDispatcher-worker-2]

可以看到,异常还是有抛出,不过被catch部分处理了。

4. 协程中try-catch的问题

上面补充了try-catch后,似乎已经解决了问题,但是事实上,上面实践代码的try-catch的方式,会带来新的问题——协程的取消不再起作用了,也就是说,在线程休眠唤醒之前,对协程进行了取消,协程后面的log输出仍会执行。执行log如下:

D/chenhj: launchWithTryCatchClicked ::running in Thread:[id:2][name:main]
D/chenhj: Coroutine IO runs (launchWithTryCatchClicked) ::running in Thread:[id:3090][name:DefaultDispatcher-worker-2]
D/chenhj: cancelBtnClicked ::running in Thread:[id:2][name:main]
D/chenhj: catch IllegalStateException:kotlinx.coroutines.JobCancellationException: StandaloneCoroutine was cancelled; job=StandaloneCoroutine{Cancelling}@7afd9ac ::running in Thread:[id:3090][name:DefaultDispatcher-worker-2]
D/chenhj: Coroutine IO runs after thread sleep (launchWithTryCatchClicked) ::running in Thread:[id:3090][name:DefaultDispatcher-worker-2]

这里可以看到,在捕获IllegalStateException的时候,把协程取消异常也捕获到了,进而地,将协程取消异常处理的内部机制不察觉间给“破坏”了。

关于取消异常,有以下继承关系:

Throwable - Exception - RuntimeException - IllegalStateException - CancellationException - JobCancellationException

所以,在捕获IllegalStateException异常的时候,也会捕获到协程取消异常,当异常被捕获以后,便不会往外传递,所以协程取消的功能便失效了。

当然,根据上面异常的继承关系,想要捕获目标异常又不想破坏协程的取消功能,根据try-catch的特性来解决也很简单,那就是在捕获部分捕获到取消异常的时候继续往外抛出:

try {
    someAPIMayThrowException(this)
} catch (e: CancellationException) {
    throw e
} catch (e: IllegalStateException) {
    myLog("catch IllegalStateException:$e")
}

这时候,不妨再回头看看文档中的陈述:

取消与异常紧密相关。协程内部使用 CancellationException 来进行取消,这个异常会被所有的处理者忽略,所以那些可以被 catch 代码块捕获的异常仅仅应该被用来作为额外调试信息的资源。

看完上面的try-catch使用的实践代码后,文档的最后半句,”仅仅应该被用来“这几个字的表述不妨结合这小节的实践代码再品品?

5. 协程异常处理者

诚然,调用可能抛出异常的API时,如果不希望app在抛出异常时闪退(哪家会容忍闪退的发生呢?),异常还是要捕获的,但是由于try-catch的设计和协程取消功能的设计,这两个一不小心就容易互相干扰,即使很细心处理了这种异常的层级关系,每处可能抛出异常的地方都要写try-catch,是不是比较麻烦?所以,这时候,便是引出协程中一个很实用的设计——

CoroutineExceptionHandler,协程异常处理者

为解决上面的API调用异常捕获的问题,现在用协程异常处理者来处理:

private fun launchWithExceptionHandlerClicked() {
    "launchWithExceptionHandlerClicked".let {
        myLog(it)
    }
    val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable ->
        myLog("exceptionHandler throwable:$throwable")
    }

    scope.launch(Dispatchers.IO + exceptionHandler) {
        "Coroutine IO runs (launchWithExceptionHandlerClicked)".let {
            myLog(it)
        }
        someAPIMayThrowException(this)
        "Coroutine IO runs after thread sleep (launchWithExceptionHandlerClicked)".let {
            myLog(it)
        }
    }
}

这样的话,正常执行(没有提前取消协程)时,输出如下:

D/chenhj: launchWithExceptionHandlerClicked ::running in Thread:[id:2][name:main]
D/chenhj: Coroutine IO runs (launchWithExceptionHandlerClicked) ::running in Thread:[id:3090][name:DefaultDispatcher-worker-2]
D/chenhj: exceptionHandler throwable:java.lang.IllegalStateException: Throw Exception by code ::running in Thread:[id:3090][name:DefaultDispatcher-worker-2]

这时候应用不会再发生闪退,异常抛出时会走到了CoroutineExceptionHandler的lambda表达式函数体部分。

同时,在someAPIMayThrowException(this)后的代码也将不再执行。

那么,再来看看协程取消时的表现:

D/chenhj: launchWithExceptionHandlerClicked ::running in Thread:[id:2][name:main]
D/chenhj: Coroutine IO runs (launchWithExceptionHandlerClicked) ::running in Thread:[id:3090][name:DefaultDispatcher-worker-2]
D/chenhj: cancelBtnClicked ::running in Thread:[id:2][name:main]

协程取消的时候,由于抛出的异常是CancellationException(更准确地说是其子类JobCancellationException),所以并不会走到CoroutineExceptionHandler的lambda表达式函数体部分。

所以,这里可以看到,异常处理者的作用,仅回调非取消异常的其他异常,这时候便可以方便的对非协程取消产生的异常进行处理了!

6. 从实用角度看待协程异常处理者

如同实践代码,协协程异常处理者,可以很方便地捕获到非取消异常的其他异常,避免异常传递造成应用的闪退,也不会对协程实用异常实现的取消功能进行了干扰。

协程异常处理者非常实用,比如在处理异常的时候,协程取消导致的异常是用来取消协程剩下执行逻辑的,应该视为正常的处理逻辑,但是其他异常的时候,最好还是需要提示或打印相关信息进行排查!

再细化一点,网络请求和数据返回的过程中,出现异常的情况可能非常多但概率都很小(接口返回异常,IO异常等),这时候希望异常出现时进行捕获并输出信息(不处理导致的闪退一般是不可接受的)。

如果不用协程,那么try-catch捕获Exception或者Throwable便是够用了。

如果用协程,try-catch的代码容易对协程本身的取消作用造成破坏,即使处理了异常层级,但是每次可能发生异常的地方都写try-catch,造成代码层级以及逻辑的复杂程度加深,一定层度上也不优雅。

所以协程异常处理者便是个很实用的内容了,不管在协程执行的任意逻辑当中,只要出现了异常,都有一个统一的异常回调,而写法也不会对协程本身的执行逻辑进行任何干扰(不理解?不妨回头看看异常处理者是怎么传入的?对比下异常处理者与try-catch对应代码逻辑写法的影响?)

现在,再来看看文档中的这句话

取消与异常紧密相关。协程内部使用 CancellationException 来进行取消,这个异常会被所有的处理者忽略,所以那些可以被 catch 代码块捕获的异常仅仅应该被用来作为额外调试信息的资源。

这段短短的描述话语中的,“这个异常会被所有的处理者忽略”,处理者说的便是CoroutineExceptionHandler了,异常会被忽略?再回头看下地5节的实践代码和各种情况的执行结果,再回头品品这个“忽略”?

再者,CoroutineExceptionHandler不是万能的,因为将异常的捕获处理逻辑与实际抛出异常的地方完全分开,如果不仅想捕获异常还想根据异常在不同执行位置的抛出作不同的细节处理,还是得写try-catch。

按菜下饭,按需使用!

7. 样例工程代码

代码样例Demo,见Github:https://github.com/TeaCChen/CoroutineStudy

本文示例代码,如觉奇怪或啰嗦,其实为CoroutineExceptionActivity.kt中的代码摘取主要部分说明,在demo代码当中,为提升细节内容,有更加多的封装和输出内容。

本文的页面截图示例如下:

image-8-1.png

8. 补充说明

协程异常部分的内容远不止本文探讨的内容,千万千万不用认为使用了协程异常处理者处理异常便是万事大吉,事实上协程结构化并发当中,协程异常处理者部分会跟协程上下文传递以及协程结构化后的异常传递相关,具体点便是子协程抛出的异常用协程异常处理者去处理,仍可能会传递到父协程当中取消父协程,然后父协程的取消将导致所有子协程的取消。

所以,当前内容,仅仅是初识协程异常,看到这部分内容的时候强烈建议接着看下部分的“异常与supervisor“。

一学就会的协程使用——基础篇

一学就会的协程使用——基础篇(一)协程启动

一学就会的协程使用——基础篇(二)线程切换

一学就会的协程使用——基础篇(三)初遇协程取消

一学就会的协程使用——基础篇(四)协程作用域

一学就会的协程使用——基础篇(五)再遇协程取消

一学就会的协程使用——基础篇(六)初识挂起

一学就会的协程使用——基础篇(七)初识结构化

一学就会的协程使用——基础篇(八)初识协程异常(本文)

一学就会的协程使用——基础篇(九)异常与supervisor

上一篇下一篇

猜你喜欢

热点阅读