1. 引言
如果學(xué)習(xí)使用了協(xié)程的取消和結(jié)構(gòu)化并發(fā)部分的內(nèi)容,那么協(xié)程的異常將是不得不說(shuō)的內(nèi)容。
2. 協(xié)程的取消異常
協(xié)程的取消篇當(dāng)中,涉及過(guò)的ensureActive,withContext這兩個(gè)函數(shù)可以作為取消的協(xié)作點(diǎn),其實(shí)這兩個(gè)函數(shù)的本質(zhì)是,在協(xié)程調(diào)被取消后再觸及這兩個(gè)函數(shù)調(diào)用之時(shí),會(huì)拋出異常CancellationException,沒(méi)錯(cuò),協(xié)程取消的協(xié)作點(diǎn),便是以異常CancellationException的拋出來(lái)作為中斷點(diǎn)的!
應(yīng)該是常識(shí)的內(nèi)容:異常拋出以后的代碼邏輯將不會(huì)獲得執(zhí)行,直到異常被捕獲;如果異常在應(yīng)用內(nèi)沒(méi)有被捕獲,那么最終將引起應(yīng)用的閃退。
現(xiàn)在不妨再回頭想想,在協(xié)程取消后,取消協(xié)作點(diǎn)以后的邏輯都不再執(zhí)行,其實(shí)便是因?yàn)閰f(xié)作點(diǎn)拋出了異常所以終止了執(zhí)行邏輯,而用協(xié)程的地方并沒(méi)有因?yàn)楫惓6罎?,說(shuō)明協(xié)程內(nèi)部已經(jīng)對(duì)異常進(jìn)行了捕獲處理。
取消與異常緊密相關(guān)。協(xié)程內(nèi)部使用
CancellationException來(lái)進(jìn)行取消,這個(gè)異常會(huì)被所有的處理者忽略,所以那些可以被catch代碼塊捕獲的異常僅僅應(yīng)該被用來(lái)作為額外調(diào)試信息的資源。
上面的內(nèi)容,引用自Kotlin中文文檔的”取消與異?!?/strong>部分:https://www.kotlincn.net/docs/reference/coroutines/exception-handling.html
從這里可以得到一個(gè)信息,在協(xié)程執(zhí)行的過(guò)程中,任意地方拋出CancellationException異常是安全,所謂的協(xié)程取消協(xié)作點(diǎn),便是以該異常的拋出作為約定!
那么除去這個(gè)異常以外的其他異常呢?至少,在Android平臺(tái)上的實(shí)踐中,除去CancellationException以及其子異常,其他所有異常在協(xié)程中拋出,沒(méi)有進(jìn)行捕獲或進(jìn)行相關(guān)處理,最終會(huì)拋出到應(yīng)用層面造成閃退。
3. 協(xié)程中的異常處理
有些情況下,開(kāi)發(fā)代碼所調(diào)用的API當(dāng)中,不可避免地可能會(huì)遇到一些異常的拋出,在Java中會(huì)強(qiáng)制我們捕獲所有非運(yùn)行時(shí)異常(RuntimException)的異常,而在Kotlin當(dāng)中,則沒(méi)有在編譯時(shí)強(qiáng)制捕獲非運(yùn)行時(shí)異常的限制,比如,前面一直使用的Thread#sleep()函數(shù),如果在Java中調(diào)用,要么在函數(shù)聲明異常的拋出,要么用try-catch包裹并捕獲InterruptedException才能通過(guò)編譯;但在Kotlin中可以隨意調(diào)用,萬(wàn)一出現(xiàn)異常便是崩潰。
這里主要是引出Kotlin對(duì)于非運(yùn)行時(shí)異常編譯時(shí)的處理差異,用作調(diào)用API時(shí)某些場(chǎng)景下會(huì)拋出異常的示例,至于兩種語(yǔ)言對(duì)于異常的詳細(xì)設(shè)計(jì),這里不作展開(kāi)。
為更好地說(shuō)明,這里設(shè)計(jì)一個(gè)函數(shù),這個(gè)函數(shù)在某些場(chǎng)景下可能拋出非法狀態(tài)異常(IllegalStateException):
private fun someAPIMayThrowException(scope: CoroutineScope) {
val randomTime = randomMilli
Thread.sleep(randomTime)
scope.ensureActive()
if (randomTime < TEN_SECONDS) {
throw IllegalStateException("Throw Exception by code")
}
}
這里在產(chǎn)生的隨機(jī)休眠時(shí)間,如果小于10秒,則會(huì)拋出異常IllegalStateException,由于實(shí)踐代碼中,隨機(jī)時(shí)間的產(chǎn)生范圍是5000毫秒到10000毫秒,所以這個(gè)異常的拋出是絕大概率的(99.98%),這個(gè)異常拋出的概率大小可以由實(shí)踐代碼隨意的控制,此處的概率僅為更方便地實(shí)踐。
在通過(guò)launch啟動(dòng)一個(gè)協(xié)程調(diào)用這個(gè)方法:
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)
}
}
}
然后,便會(huì)喜獲異常并引起閃退:
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開(kāi)發(fā)中最不能忍受的內(nèi)容,所以,既然知道這里可能會(huì)觸發(fā)異常,那么,像Java中一樣,對(duì)可能拋出的異常的地方進(jìn)行捕獲處理(為方便對(duì)比,將新增異常捕獲部分的代碼封裝為新的方法):
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)
}
}
}
與前面閃退代碼中,核心邏輯的差別便是對(duì)someAPIMayThrowException(this)進(jìn)行了try-catch包裹,且僅捕獲IllegalStateException異常。這樣的話,便不再有崩潰問(wèn)題,最終所有的日志都能正常輸入:
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]
可以看到,異常還是有拋出,不過(guò)被catch部分處理了。
4. 協(xié)程中try-catch的問(wèn)題
上面補(bǔ)充了try-catch后,似乎已經(jīng)解決了問(wèn)題,但是事實(shí)上,上面實(shí)踐代碼的try-catch的方式,會(huì)帶來(lái)新的問(wèn)題——協(xié)程的取消不再起作用了,也就是說(shuō),在線程休眠喚醒之前,對(duì)協(xié)程進(jìn)行了取消,協(xié)程后面的log輸出仍會(huì)執(zhí)行。執(zhí)行l(wèi)og如下:
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的時(shí)候,把協(xié)程取消異常也捕獲到了,進(jìn)而地,將協(xié)程取消異常處理的內(nèi)部機(jī)制不察覺(jué)間給“破壞”了。
關(guān)于取消異常,有以下繼承關(guān)系:
Throwable - Exception - RuntimeException - IllegalStateException - CancellationException - JobCancellationException
所以,在捕獲IllegalStateException異常的時(shí)候,也會(huì)捕獲到協(xié)程取消異常,當(dāng)異常被捕獲以后,便不會(huì)往外傳遞,所以協(xié)程取消的功能便失效了。
當(dāng)然,根據(jù)上面異常的繼承關(guān)系,想要捕獲目標(biāo)異常又不想破壞協(xié)程的取消功能,根據(jù)try-catch的特性來(lái)解決也很簡(jiǎn)單,那就是在捕獲部分捕獲到取消異常的時(shí)候繼續(xù)往外拋出:
try {
someAPIMayThrowException(this)
} catch (e: CancellationException) {
throw e
} catch (e: IllegalStateException) {
myLog("catch IllegalStateException:$e")
}
這時(shí)候,不妨再回頭看看文檔中的陳述:
取消與異常緊密相關(guān)。協(xié)程內(nèi)部使用
CancellationException來(lái)進(jìn)行取消,這個(gè)異常會(huì)被所有的處理者忽略,所以那些可以被catch代碼塊捕獲的異常僅僅應(yīng)該被用來(lái)作為額外調(diào)試信息的資源。
看完上面的try-catch使用的實(shí)踐代碼后,文檔的最后半句,”僅僅應(yīng)該被用來(lái)“這幾個(gè)字的表述不妨結(jié)合這小節(jié)的實(shí)踐代碼再品品?
5. 協(xié)程異常處理者
誠(chéng)然,調(diào)用可能拋出異常的API時(shí),如果不希望app在拋出異常時(shí)閃退(哪家會(huì)容忍閃退的發(fā)生呢?),異常還是要捕獲的,但是由于try-catch的設(shè)計(jì)和協(xié)程取消功能的設(shè)計(jì),這兩個(gè)一不小心就容易互相干擾,即使很細(xì)心處理了這種異常的層級(jí)關(guān)系,每處可能拋出異常的地方都要寫try-catch,是不是比較麻煩?所以,這時(shí)候,便是引出協(xié)程中一個(gè)很實(shí)用的設(shè)計(jì)——
CoroutineExceptionHandler,協(xié)程異常處理者
為解決上面的API調(diào)用異常捕獲的問(wèn)題,現(xiàn)在用協(xié)程異常處理者來(lái)處理:
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)
}
}
}
這樣的話,正常執(zhí)行(沒(méi)有提前取消協(xié)程)時(shí),輸出如下:
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]
這時(shí)候應(yīng)用不會(huì)再發(fā)生閃退,異常拋出時(shí)會(huì)走到了CoroutineExceptionHandler的lambda表達(dá)式函數(shù)體部分。
同時(shí),在someAPIMayThrowException(this)后的代碼也將不再執(zhí)行。
那么,再來(lái)看看協(xié)程取消時(shí)的表現(xiàn):
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]
協(xié)程取消的時(shí)候,由于拋出的異常是CancellationException(更準(zhǔn)確地說(shuō)是其子類JobCancellationException),所以并不會(huì)走到CoroutineExceptionHandler的lambda表達(dá)式函數(shù)體部分。
所以,這里可以看到,異常處理者的作用,僅回調(diào)非取消異常的其他異常,這時(shí)候便可以方便的對(duì)非協(xié)程取消產(chǎn)生的異常進(jìn)行處理了!
6. 從實(shí)用角度看待協(xié)程異常處理者
如同實(shí)踐代碼,協(xié)協(xié)程異常處理者,可以很方便地捕獲到非取消異常的其他異常,避免異常傳遞造成應(yīng)用的閃退,也不會(huì)對(duì)協(xié)程實(shí)用異常實(shí)現(xiàn)的取消功能進(jìn)行了干擾。
協(xié)程異常處理者非常實(shí)用,比如在處理異常的時(shí)候,協(xié)程取消導(dǎo)致的異常是用來(lái)取消協(xié)程剩下執(zhí)行邏輯的,應(yīng)該視為正常的處理邏輯,但是其他異常的時(shí)候,最好還是需要提示或打印相關(guān)信息進(jìn)行排查!
再細(xì)化一點(diǎn),網(wǎng)絡(luò)請(qǐng)求和數(shù)據(jù)返回的過(guò)程中,出現(xiàn)異常的情況可能非常多但概率都很小(接口返回異常,IO異常等),這時(shí)候希望異常出現(xiàn)時(shí)進(jìn)行捕獲并輸出信息(不處理導(dǎo)致的閃退一般是不可接受的)。
如果不用協(xié)程,那么try-catch捕獲Exception或者Throwable便是夠用了。
如果用協(xié)程,try-catch的代碼容易對(duì)協(xié)程本身的取消作用造成破壞,即使處理了異常層級(jí),但是每次可能發(fā)生異常的地方都寫try-catch,造成代碼層級(jí)以及邏輯的復(fù)雜程度加深,一定層度上也不優(yōu)雅。
所以協(xié)程異常處理者便是個(gè)很實(shí)用的內(nèi)容了,不管在協(xié)程執(zhí)行的任意邏輯當(dāng)中,只要出現(xiàn)了異常,都有一個(gè)統(tǒng)一的異?;卣{(diào),而寫法也不會(huì)對(duì)協(xié)程本身的執(zhí)行邏輯進(jìn)行任何干擾(不理解?不妨回頭看看異常處理者是怎么傳入的?對(duì)比下異常處理者與try-catch對(duì)應(yīng)代碼邏輯寫法的影響?)
現(xiàn)在,再來(lái)看看文檔中的這句話
取消與異常緊密相關(guān)。協(xié)程內(nèi)部使用
CancellationException來(lái)進(jìn)行取消,這個(gè)異常會(huì)被所有的處理者忽略,所以那些可以被catch代碼塊捕獲的異常僅僅應(yīng)該被用來(lái)作為額外調(diào)試信息的資源。
這段短短的描述話語(yǔ)中的,“這個(gè)異常會(huì)被所有的處理者忽略”,處理者說(shuō)的便是CoroutineExceptionHandler了,異常會(huì)被忽略?再回頭看下地5節(jié)的實(shí)踐代碼和各種情況的執(zhí)行結(jié)果,再回頭品品這個(gè)“忽略”?
再者,CoroutineExceptionHandler不是萬(wàn)能的,因?yàn)閷惓5牟东@處理邏輯與實(shí)際拋出異常的地方完全分開(kāi),如果不僅想捕獲異常還想根據(jù)異常在不同執(zhí)行位置的拋出作不同的細(xì)節(jié)處理,還是得寫try-catch。
按菜下飯,按需使用!
7. 樣例工程代碼
代碼樣例Demo,見(jiàn)Github:https://github.com/TeaCChen/CoroutineStudy
本文示例代碼,如覺(jué)奇怪或啰嗦,其實(shí)為CoroutineExceptionActivity.kt中的代碼摘取主要部分說(shuō)明,在demo代碼當(dāng)中,為提升細(xì)節(jié)內(nèi)容,有更加多的封裝和輸出內(nèi)容。
本文的頁(yè)面截圖示例如下:

8. 補(bǔ)充說(shuō)明
協(xié)程異常部分的內(nèi)容遠(yuǎn)不止本文探討的內(nèi)容,千萬(wàn)千萬(wàn)不用認(rèn)為使用了協(xié)程異常處理者處理異常便是萬(wàn)事大吉,事實(shí)上協(xié)程結(jié)構(gòu)化并發(fā)當(dāng)中,協(xié)程異常處理者部分會(huì)跟協(xié)程上下文傳遞以及協(xié)程結(jié)構(gòu)化后的異常傳遞相關(guān),具體點(diǎn)便是子協(xié)程拋出的異常用協(xié)程異常處理者去處理,仍可能會(huì)傳遞到父協(xié)程當(dāng)中取消父協(xié)程,然后父協(xié)程的取消將導(dǎo)致所有子協(xié)程的取消。
所以,當(dāng)前內(nèi)容,僅僅是初識(shí)協(xié)程異常,看到這部分內(nèi)容的時(shí)候強(qiáng)烈建議接著看下部分的“異常與supervisor“。
一學(xué)就會(huì)的協(xié)程使用——基礎(chǔ)篇
一學(xué)就會(huì)的協(xié)程使用——基礎(chǔ)篇(一)協(xié)程啟動(dòng)
一學(xué)就會(huì)的協(xié)程使用——基礎(chǔ)篇(二)線程切換
一學(xué)就會(huì)的協(xié)程使用——基礎(chǔ)篇(三)初遇協(xié)程取消
一學(xué)就會(huì)的協(xié)程使用——基礎(chǔ)篇(四)協(xié)程作用域
一學(xué)就會(huì)的協(xié)程使用——基礎(chǔ)篇(五)再遇協(xié)程取消
一學(xué)就會(huì)的協(xié)程使用——基礎(chǔ)篇(六)初識(shí)掛起
一學(xué)就會(huì)的協(xié)程使用——基礎(chǔ)篇(七)初識(shí)結(jié)構(gòu)化
一學(xué)就會(huì)的協(xié)程使用——基礎(chǔ)篇(八)初識(shí)協(xié)程異常(本文)