關于Kotlin協(xié)程的文章特別多色难,多數(shù)是按照官方教程翻譯一遍沿癞,很多概念理解起來比較困惑,特別是協(xié)程的異常處理部分吮铭,看的是一頭霧水时迫。所以打算跟著官方文檔及優(yōu)秀的Kotlin協(xié)程文章,來系統(tǒng)學習一下谓晌。
首先來看Android官方對協(xié)程的定義:協(xié)程是一種并發(fā)設計模式掠拳,您可以在 Android 平臺上使用它來簡化異步執(zhí)行的代碼。協(xié)程是在版本 1.3 中添加到 Kotlin 的纸肉,它基于來自其他語言的既定概念溺欧。
特點
協(xié)程是我們在 Android 上進行異步編程的推薦解決方案。值得關注的特點包括:
- 輕量:您可以在單個線程上運行多個協(xié)程柏肪,因為協(xié)程支持掛起姐刁,不會使正在運行協(xié)程的線程阻塞。掛起比阻塞節(jié)省內(nèi)存烦味,且支持多個并行操作聂使。
- 內(nèi)存泄漏更少:使用結(jié)構(gòu)化并發(fā)機制在一個作用域內(nèi)執(zhí)行多項操作。
- 內(nèi)置取消支持:取消操作會自動在運行中的整個協(xié)程層次結(jié)構(gòu)內(nèi)傳播。
- Jetpack 集成:許多 Jetpack 庫都包含提供全面協(xié)程支持的擴展柏靶。某些庫還提供自己的協(xié)程作用域弃理,可供您用于結(jié)構(gòu)化并發(fā)。
上面是關于協(xié)程的概念和特點屎蜓,概念很簡單痘昌,理解起來卻有些生澀,那我們我們帶著兩個簡單的問題開始學習
協(xié)程是什么
協(xié)程并不是Kotlin創(chuàng)造的概念炬转, 在其他語言層面看到協(xié)程的實現(xiàn)控汉,協(xié)程是一種編程思想,并不局限于任何語言返吻。
協(xié)程最核心的作用就是用來簡化異步執(zhí)行的代碼,說的直白一點乎婿,協(xié)程將原本復雜的異步線程做了簡化處理测僵,邏輯更清晰,代碼更簡潔
這里谢翎,就必須拿出線程和協(xié)程一起對比捍靠,站在Android開發(fā)者的角度,去理解它們直接的關系:
- 我們的代碼是在線程中運行的森逮,而線程是在進程中運行
- 協(xié)程不是線程榨婆,它也是在線程中運行的,不論是單線程還是多線程
- 單線程中褒侧,使用協(xié)程并不能減少線程的執(zhí)行時間
那么協(xié)程到底是怎么來簡化異步代碼的呢良风?下面從協(xié)程最經(jīng)典的使用場景來切入---線程控制
callback
在Android中,如果要處理異步任務闷供,最常見的就是使用callback
public interface Callback<T> {
void onSucceed(T result);
void onFailed(int errCode, String errMsg);
}
callback的特點很明顯
- 優(yōu)勢:使用簡單
- 缺點:如果業(yè)務多烟央,很容易陷入回調(diào)地獄,嵌套邏輯復雜歪脏,維護成很高
RxJava
那么有什么方法能夠解決呢疑俭?這時候很自然想到大名鼎鼎的RxJava
優(yōu)勢:RxJava使用鏈式調(diào)用,實現(xiàn)線程切換婿失,消除回調(diào)
劣勢:RxJava上手難度較大钞艇,而且各種操作符,很容易濫用豪硅,復雜度較高
而協(xié)程作為Kotlin自身的拓展庫哩照,使用更簡單,更方便
下面使用協(xié)程來進行網(wǎng)絡請求
launch {
val result = get("https://developer.android.com")
print(result)
}
}
suspend fun get(url: String) = withContext(Dispatchers.IO) {
//network request
}
這里展示了代碼片段舟误, launch并不是頂層函數(shù)葡秒,我們先不關注,只關注{}
內(nèi)的具體邏輯
通常做網(wǎng)絡請求,都是使用callback眯牧,回調(diào)結(jié)果后處理抑钟,而上面的兩行代碼革娄,分別執(zhí)行在兩個線程里,但是看起來和單線程一樣。
這里的get("https://developer.android.com")
就是一個掛起函數(shù)项鬼,能保證請求結(jié)束后,才開始打印結(jié)果户誓,這就是協(xié)程中最核心的非阻塞式掛起
協(xié)程怎么用
那么協(xié)程中的掛起尚洽,到底掛起了什么呢?我們先來看看協(xié)程怎么用绒疗,跟著用法來分析
協(xié)程基礎知識
上面提到侵歇,launch不是頂層函數(shù),那么真正創(chuàng)建協(xié)程的方式是什么呢吓蘑?
// 方法一惕虑,使用 runBlocking 頂層函數(shù)
runBlocking {
get(url)
}
// 方法二,自行通過 CoroutineContext 創(chuàng)建一個 CoroutineScope 對象磨镶,通過launch開啟協(xié)程
val coroutineScope = CoroutineScope(context)
coroutineScope.launch {
get(url)
}
// 方法三溃蔫,使用 GlobalScope 單例對象,GlobalScope 實際是CoroutineScope的子類,本質(zhì)是CoroutineScope
GlobalScope.launch {
get(url)
}
//方法四,使用async開啟協(xié)程
GlobalScope.async {
get(url)
}
- 方法一通常適用于單元測試的場景琳猫,而業(yè)務開發(fā)中不會用到這種方法伟叛,因為它是線程阻塞的。
- 方法二是標準用法脐嫂,我們可以通過
context
參數(shù)去管理和控制協(xié)程的生命周期(這里的context
和 Android 里的不是一個東西统刮,是一個更通用的概念,會有一個 Android 平臺的封裝來配合使用)账千,CoroutineScope
來創(chuàng)建協(xié)程的作用域 - 方法三
GlobalScope
是CoroutineScope
的子類网沾,使用場景先不考究。 - 方法四和方法三的區(qū)別蕊爵,就在于
launch
和async
的區(qū)別辉哥,這個稍后再分析
CoroutineScope
CoroutineScope
是協(xié)程的作用域,所有協(xié)程都需要在作用域中啟動
CoroutineContext
協(xié)程的持久上下文攒射, 定義協(xié)程以下的行為:
Job
:控制協(xié)程的生命周期醋旦。CoroutineDispatcher
:將工作分派到適當?shù)木€程。CoroutineName
:協(xié)程的名稱会放,可用于調(diào)試饲齐。CoroutineExceptionHandler
:處理未捕獲的異常。
下面就是一個標準的協(xié)程
val ctxHandler = CoroutineExceptionHandler {context , exception ->
}
val context = Job() + Dispatchers.IO + EmptyCoroutineContext + ctxHandler
CoroutineScope(context).launch {
get(url)
}
suspend fun get(url: String) {
}
這個 launch
函數(shù)咧最,它具體的含義是:我要創(chuàng)建一個新的協(xié)程捂人,并在指定的線程上運行它御雕。這個被創(chuàng)建、被運行的所謂「協(xié)程」是誰滥搭?就是你傳給 launch
的那些代碼酸纲,這一段連續(xù)代碼叫做一個協(xié)程
我們也能換個思路理解,協(xié)程的概念由三方面組成: CoroutineScope
+ CoroutineContext
+ 協(xié)程
協(xié)程是抽象的概念瑟匆, 而協(xié)程 是 launch
或者 async
函數(shù)閉包的代碼塊闽坡,是并發(fā)的具體實現(xiàn),我們提到的協(xié)程就是它
使用協(xié)程
協(xié)程最常用的功能是并發(fā)愁溜,而并發(fā)的典型場景就是多線程疾嗅。可以使用 Dispatchers.IO
參數(shù)把任務切到 IO 線程執(zhí)行:
coroutineScope.launch(Dispatchers.IO) {
...
}
使用Dispatchers.Main
切換到主線程
coroutineScope.launch(Dispatchers.Main) {
...
}
什么時候使用協(xié)程呢冕象?當你需要切線程或者指定線程的時候代承。你要在后臺執(zhí)行任務?切渐扮!
coroutineScope.launch(Dispatchers.IO) {
val result = get(url)
}
然后需要在前臺更新界面次泽?再切!
coroutineScope.launch(Dispatchers.IO) {
val result = get(url)
launch(Dispatchers.Main) {
showToast(result)
}
}
乍一看席爽,還是有嵌套啊
如果只是使用 launch
函數(shù),協(xié)程并不能比線程做更多的事啊片。不過協(xié)程中卻有一個很實用的函數(shù):withContext
只锻。這個函數(shù)可以切換到指定的線程,并在閉包內(nèi)的邏輯執(zhí)行結(jié)束之后紫谷,自動把線程切回去繼續(xù)執(zhí)行齐饮。
coroutineScope.launch(Dispatchers.Main) { //主線程啟動 val result = withContext(Dispatchers.IO) { //切換到IO線程,執(zhí)行完畢自動切回主線程 get(url) //在IO線程執(zhí)行 } showToast(result) //恢復到主線程}
這種寫法看上去好像和剛才那種區(qū)別不大笤昨,但如果你需要頻繁地進行線程切換祖驱,這種寫法的優(yōu)勢就會體現(xiàn)出來÷髦希可以參考下面的對比:
// 第一種寫法coroutineScope.launch(Dispatchers.IO) { ... launch(Dispatchers.Main){ ... launch(Dispatchers.IO) { ... launch(Dispatchers.Main) { ... } } }}// 通過第二種寫法來實現(xiàn)相同的邏輯coroutineScope.launch(Dispatchers.Main) { ... withContext(Dispatchers.IO) { ... } ... withContext(Dispatchers.IO) { ... } ...}
根據(jù)withContext
自動切回的特性捺僻,可以將withContext
抽取到一個單獨的函數(shù)
coroutineScope.launch(Dispatchers.Main) { //主線程啟動 val result = get(url) //在IO線程執(zhí)行 showToast(result) //恢復到主線程}fun get(url: String) = withContext(Dispatchers.IO) { // to do network request url }
這樣代碼邏輯就清晰多了
與基于回調(diào)的等效實現(xiàn)相比,
withContext()
不會增加額外的開銷崇裁。此外匕坯,在某些情況下,還可以優(yōu)化withContext()
調(diào)用拔稳,比使用回調(diào)表現(xiàn)更好葛峻。例如,如果某個函數(shù)對一個網(wǎng)絡調(diào)用十次巴比,您可以使用外部withContext()
讓 Kotlin 只切換一次線程术奖。這樣礁遵,即使網(wǎng)絡庫多次使用withContext()
,它也會留在同一調(diào)度程序上采记,并避免切換線程佣耐。
細心的你會發(fā)現(xiàn),我們上面的示例挺庞,都缺少一個關鍵字suspend
, 真正執(zhí)行時晰赞,會報錯:
fun get(url: String) = withContext(Dispatchers.IO) { // IDE 報錯 Suspend function'withContext' should be called only from a coroutine or another suspend funcion}
意思是說,withContext
是一個 suspend
函數(shù)选侨,調(diào)用 suspend
函數(shù)掖鱼,只能從其他 suspend
函數(shù)進行調(diào)用,或通過使用協(xié)程構(gòu)建器(例如 launch
)來啟動新的協(xié)程
suspend
suspend
是 Kotlin 協(xié)程最核心的關鍵字援制,代碼執(zhí)行到 suspend
函數(shù)的時候會掛起戏挡,并且這個掛起是非阻塞式的,它不會阻塞你當前的線程晨仑。
所以上面代碼: 加上suspend
就能通過編譯:
suspend fun get(url: String) = withContext(Dispatchers.IO) { ...}
suspend
具體是什么褐墅?它又是如何實現(xiàn)非阻塞式掛起的呢?
協(xié)程的掛起
協(xié)程到底掛起的是什么呢洪己?是如何將線程掛起嗎妥凳?
實際上掛起的就是協(xié)程本身,具體一點呢答捕?
前面講過逝钥,協(xié)程其實就是 launch
或者 async
函數(shù)中閉包的代碼塊。
當協(xié)程執(zhí)行到suspend
函數(shù)時拱镐,協(xié)程會被suspend艘款,也就是被掛起。
那協(xié)程從哪里掛起呢沃琅?當前的線程
掛起后做了什么呢哗咆?離開當前運行的線程,在指定的線程開始執(zhí)行益眉,執(zhí)行完畢后再恢復協(xié)程晌柬。
協(xié)程并不是停下來了,是脫離當前線程郭脂,兵分兩路空繁,互不干擾,那么脫離后各自做了什么呢朱庆?
-
線程
當線程中代碼執(zhí)行到協(xié)程的suspend函數(shù)盛泡,暫時不執(zhí)行協(xié)程剩余代碼,跳出協(xié)程代碼塊娱颊,繼續(xù)運行
- 如果線程是后臺線程:
* 如果有其他后臺任務傲诵,則執(zhí)行 * 如果沒有其他任務,則無事可做拴竹,等待被回收
- 如果是主線程:
則繼續(xù)執(zhí)行工作悟衩,刷新界面
-
協(xié)程
線程的代碼在到達
suspend
函數(shù)的時候被掐斷,接下來協(xié)程會從這個suspend
函數(shù)開始繼續(xù)往下執(zhí)行栓拜,不過是在指定的線程座泳。誰指定的?是
suspend
函數(shù)指定的幕与,比如函數(shù)內(nèi)部的withContext
傳入的Dispatchers.IO
所指定的 IO 線程挑势。Dispatchers
調(diào)度器,它可以將協(xié)程限制在一個特定的線程執(zhí)行啦鸣,或者將它分派到一個線程池潮饱,或者讓它不受限制地運行,關于Dispatchers
后續(xù)再詳細講解suspend
函數(shù)執(zhí)行完成之后诫给,協(xié)程為我們做的最爽的事就來了:會自動幫我們把線程再切回來香拉。我們的協(xié)程原本是運行在主線程的,當代碼遇到 suspend 函數(shù)的時候中狂,發(fā)生線程切換凫碌,根據(jù)
Dispatchers
切換到了對應線程執(zhí)行;當這個函數(shù)執(zhí)行完畢后胃榕,線程又切了回來盛险,也就是協(xié)程會幫我再
post
一個Runnable
,讓我剩下的代碼繼續(xù)回到主線程去執(zhí)行勤晚。
協(xié)程掛起的實質(zhì):就是切個線程
不過協(xié)程的掛起,比起我們使用Handler或者Rxjava的區(qū)別在于泉褐, 掛起函數(shù)執(zhí)行完畢后赐写,協(xié)程會自動切回原來的線程。
這個切回來的動作膜赃,也就是協(xié)程中的恢復resume
, 必須在協(xié)程中挺邀,才能實現(xiàn)恢復功能
這也說明為什么掛起函數(shù)需要在協(xié)程或者另一個掛起函數(shù)中調(diào)用,最終都是為了讓suspend
函數(shù)切換線程之后能夠再切回來
協(xié)程怎么掛起
suspend
函數(shù)是怎么被掛起的呢跳座? 是 suspend
指令做到的嗎端铛?下面寫個 suspend
函數(shù)嘗試一下:
suspend fun printThreadInfo() { print(Thread.currentThread().name)}I/System.out:main
顯示在主線程, 有點奇怪疲眷,明明定義了 suspend
函數(shù)禾蚕,為什么協(xié)程沒有掛起呢?
對比之前的例子:
suspend fun get(url: String) = withContext(Dispatchers.IO) { ...}
發(fā)現(xiàn)區(qū)別在于withContext
函數(shù)狂丝。查看 withContext
源碼可以發(fā)現(xiàn)换淆,它本身就是suspend
函數(shù)哗总,它接收一個 Dispatcher
參數(shù),依賴這個 Dispatcher
參數(shù)的指示倍试,你的協(xié)程被掛起讯屈,然后切到別的線程。
所以 suspend
并不能掛起協(xié)程县习,真正掛起協(xié)程的涮母,是協(xié)程框架,要想掛起協(xié)程躁愿,必須要直接或間接使用協(xié)程框架的 suspend
函數(shù)
suspend的作用
suspend
關鍵字叛本,不是真正實現(xiàn)掛起,那它的作用是什么攘已?
它其實是一個提醒炮赦。
對函數(shù)的使用者的提醒:我是一個耗時函數(shù),我被我的創(chuàng)建者用掛起的方式放在后臺運行样勃,所以請在協(xié)程里調(diào)用我吠勘。
為什么 suspend
關鍵字并沒有實際去操作掛起,但 Kotlin 卻把它提供出來峡眶?
因為它本來就不是用來操作掛起的剧防。
掛起的操作 —— 也就是切線程,依賴的是掛起函數(shù)里面的實際代碼辫樱,而不是這個關鍵字峭拘。
所以這個關鍵字,只是一個提醒狮暑。
并且鸡挠, 定義了suspend
函數(shù),但不包含掛起邏輯時搬男,會提醒:redundant suspend modifier
拣展,告訴你這個 suspend
是多余的、
所以缔逛,創(chuàng)建一個 suspend
函數(shù)备埃,為了讓它包含真正掛起的邏輯,要在它內(nèi)部直接或間接調(diào)用 Kotlin 自帶的 suspend
函數(shù)褐奴,你的這個 suspend
才是有意義的按脚。
自定義
suspend
函數(shù)的使用原則: 某個函數(shù)只要是耗時的,就可以寫成suspend
函數(shù)
學習了協(xié)程的掛起敦冬,還有一個概念有疑惑辅搬,那就是協(xié)程的非阻塞式
掛起,其中非阻塞式
到底是什么
非阻塞式掛起
非阻塞式是相對阻塞式來說的
阻塞式很容易理解脖旱,一條馬路堵車了伞辛,前面車輛不開動烂翰,后面車輛全部被阻塞,后面車想開過去蚤氏,要么等前車離開甘耿,要么開辟一條路,從新路開走
這和代碼中線程很相似:
道路被阻塞—耗時任務 等前車離開—耗時任務結(jié)束 開新的道路—切換到其他線程
從語義上理解非阻塞式掛起竿滨,講的是非阻塞式是掛起的一個特點佳恬,協(xié)程的掛起是非阻塞式的,沒有表達其他概念
阻塞的本質(zhì)
首先于游,所有的代碼本質(zhì)上都是阻塞式的毁葱,而只有比較耗時的代碼才會導致人類可感知的等待,比如在主線程上做一個耗時 50 ms 的操作會導致界面卡掉幾幀贰剥,這種是我們?nèi)搜勰苡^察出來的倾剿,而這就是我們通常意義所說的「阻塞」。
舉個例子蚌成,當你開發(fā)的 app 在性能好的手機上很流暢前痘,在性能差的老手機上會卡頓,就是在說同一行代碼執(zhí)行的時間不一樣担忧。
視頻中講了一個網(wǎng)絡 IO 的例子芹缔,IO 阻塞更多是反映在「等」這件事情上,它的性能瓶頸是和網(wǎng)絡的數(shù)據(jù)交換瓶盛,你切多少個線程都沒用最欠,該花的時間一點都少不了。
而這跟協(xié)程半毛錢關系沒有惩猫,切線程解決不了的事情芝硬,協(xié)程也解決不了。
所以轧房,總結(jié)一下協(xié)程
- 協(xié)程就是切線程
- 掛起就是可以自動切回來的切線程
- 非阻塞式是用看起來阻塞的代碼實現(xiàn)非阻塞的操作
協(xié)程并沒有創(chuàng)造新的東西拌阴,只是將多線程開發(fā)變的更簡單,原理依然是切換線程并回調(diào)到原本的線程
協(xié)程的進階用法
launch
與 async
前面講到的 launch
與 async
锯厢,現(xiàn)在來對比一下
用法很相似皮官,都能啟動一個協(xié)程
-
launch
啟動新協(xié)程但不返回結(jié)果脯倒。任何被視為“一勞永逸”的工作都可以使用launch
來啟動 -
async
會啟動一個新的協(xié)程实辑,并使用一個名為await
的掛起函數(shù)并在稍后返回結(jié)果
舉例:例如我們要顯示一個列表,數(shù)據(jù)源從兩個接口獲取藻丢,如果用launch
啟動協(xié)程剪撬,我們會啟動兩個請求,在任一請求結(jié)束時悠反,檢查另一個請求的結(jié)果残黑,等兩個請求結(jié)束和馍佑,開始合并數(shù)據(jù)源,進行顯示
如果我們使用async
val listOne = async { fetchList(1) } val listTwo = async { fetchList(2) } mergeList(listOne.await(), listTwo.await())// mergeList 為自定義合并函數(shù)
通過對每個延遲引用調(diào)用 await()
梨水,我們可以保證這兩項 async
完成之后拭荤,開始合并,而不需要考慮任何先后問題
還可以對集合使用 awaitAll()
val deferreds = listOf( async { fetchList(1)}, async { fetchList(2)} ) mergeList(deferreds.awaitAll())
常規(guī)情況疫诽,只需要使用launch
啟動協(xié)程舅世,當使用async
時,需要注意:async
期望您最終會調(diào)用 await
來獲取結(jié)果(或異常)奇徒,因此默認情況下它不會拋出異常雏亚。
Dispatchers
Kotlin 提供了三個可用于線程調(diào)度的 Dispatcher。
a | b |
---|---|
Dispatchers.Main | Android主線程摩钙,用于和用戶交互 |
Dispatchers.IO | 適合 IO 密集型的任務罢低,比如:讀寫文件,操作數(shù)據(jù)庫以及網(wǎng)絡請求 |
Dispatchers.Default | 針對 CPU 密集型工作進行了優(yōu)化胖笛,比如計算/JSON解析等 |
異常傳播及處理
Job
和SupervisorJob
通常情況网持,我們使用launch
或者async
創(chuàng)建協(xié)程,會默認創(chuàng)建使用Job
來處理匀钧,一個任務失敗翎碑,會影響他的子協(xié)程和父協(xié)程。
異常會到達層級的根部之斯,而且當前 CoroutineScope 啟動的所有協(xié)程都會被取消日杈。
如果我們不想因為一個任務的失敗而影響其他任務, 子協(xié)程運行失敗不影響其他子協(xié)程和父協(xié)程佑刷,那么可以在創(chuàng)建協(xié)程時在 CoroutineScope
的 CoroutineContext
中使用 Job
的另一個擴展: SupervisorJob
當子協(xié)程任務出錯或失敗時莉擒,SupervisorJob
不會取消它和它自己的子級,也不會傳播異常并傳遞給它的父級瘫絮,它會讓子協(xié)程自己處理異常
coroutineScope
和supervisorScope
通過 launch
與 async
, 能很輕松啟動一個線程涨冀,請求網(wǎng)絡并獲取數(shù)據(jù)
但是有時候,你的需求比較復雜麦萤,需要在一個協(xié)程中執(zhí)行多個網(wǎng)絡請求鹿鳖,那就意味著你要啟動更多協(xié)程。
在掛起函數(shù)中創(chuàng)建更多的協(xié)程壮莹,可以使用名為 coroutineScope
的構(gòu)建器或 supervisorScope
來啟動更多的協(xié)程翅帜。
suspend fun fetchTwoDocs() { coroutineScope { launch { fetchList(1) } async { fetchList(2) } }}
注意:coroutineScope
和 CoroutineScope
是不同的東西,盡管它們的名字只有一個字符不同命满,CoroutineScope
是協(xié)程作用域涝滴,而coroutineScope
是在掛起函數(shù)中創(chuàng)建新協(xié)程的一個掛起函數(shù),它接受CoroutineScope
作為參數(shù),并在CoroutineScope
中創(chuàng)建協(xié)程
coroutineScope
和supervisorScope
最主要的不同在哪呢歼疮?在于子協(xié)程出錯時的處理
當coroutineScope
是繼承外部Job
的上下文創(chuàng)建作用域杂抽,其內(nèi)部的取消操作是雙向傳播的,子協(xié)程未捕獲的異常也會向上傳遞給父協(xié)程韩脏。任何一個子協(xié)程異常退出,那么整體都將退出赡矢。
supervisorScope
同樣繼承外部作用域的上下文,但其內(nèi)部的取消操作是單向傳播的济竹,父協(xié)程向子協(xié)程傳播,反過來則不然梦谜,這意味著子協(xié)程出了異常并不會影響父協(xié)程以及其他兄弟協(xié)程。
所以唁桩,當處理多并發(fā)任務時耸棒,如果不想因為一個任務的失敗而影響其他任務,就可以使用supervisorScope
創(chuàng)建協(xié)程与殃,反之使用coroutineScope
注意: SupervisorJob
只有作為supervisorScope
或 CoroutineScope(SupervisorJob())
的一部分時,才會按照上面的描述工作幅疼。
協(xié)程異常處理
協(xié)程的異常米奸,一般使用try/catch
或者runCatching
內(nèi)置函數(shù)來處理(內(nèi)部也是使用try/catch
),在try
中編寫請求代碼爽篷,catch
負責捕獲異常悴晰。
例如
GlobalScope.launch { val scope = CoroutineScope(Job()) scope.launch { try { throw Exception("Failed") } catch (e: Exception) { //捕獲到異常 } } }
正常來說,try-catch
塊中只有代碼塊存在異常逐工,都將被捕獲到catch
中铡溪。但是協(xié)程中的異常卻存在特殊情況。
例如在協(xié)程中開啟一個失敗的子協(xié)程泪喊,則無法捕獲棕硫。還是上面的例子:
GlobalScope.launch { val scope = CoroutineScope(Job()) try { //try catch 在launch 作用域之外 scope.launch { throw Exception("Failed") } } catch (e: Exception) { e.printStackTrace() //無法捕獲異常,程序崩潰 } }
在try-catch
塊中創(chuàng)建了一個子協(xié)程窘俺,拋出一個異常饲帅,這個時候我們期望的是能將異常捕獲至catch
中,但是真正運行后卻發(fā)現(xiàn)App崩潰退出了瘤泪。這也驗證了try-catch
作用無效灶泵。
這就涉及到協(xié)程中異常傳播問題
異常傳播
在kotlin的協(xié)程中,每個協(xié)程是一個作用域对途,新建的協(xié)程與它的父作用域存在一個層次結(jié)構(gòu)赦邻。而這級聯(lián)關系主要在于:
協(xié)程中的任務,一旦因為異常而運行失敗实檀,它會立即將這個異常傳遞給它的父級惶洲,由父級來決定處理:
- 取消它自己的子級;
- 取消它自己膳犹;
- 將異常傳播并傳遞給它的父級
這也是為什么我們try-catch
子協(xié)程為什么會失敗恬吕,因為子協(xié)程中異常會向上傳播,但父任務未處理異常须床,導致父任務失敗铐料。
如果將上面例子再次修改:
GlobalScope.launch { val scope = CoroutineScope(Job()) val job = scope.async { //將launch改為async throw Exception("Failed") } try { job.await() } catch (e: Exception) { e.printStackTrace() //成功捕獲異常 } }
為什么async
使用try-catch
能捕獲異常呢豺旬?當 async
被用作根協(xié)程時在調(diào)用 **.await() **時會拋出異常族阅。這里的根協(xié)程指的是CoroutineScope(SupervisorJob())
實例或 supervisorScope
的直接子協(xié)程
所以try-catch
包裹.await()
時可以捕獲異常
如果 async
被不用作根協(xié)程愧沟,例如:
val scope = CoroutineScope(Job()) scope.launch { //根協(xié)程 val job = async { //async 開啟子協(xié)程 throw Exception("Failed") //異常會立即拋出 } try { job.await() } catch (e: Exception) { e.printStackTrace() //無法捕獲異常央渣,程序崩潰 } }
這時候芽丹,try-catch
無法捕獲異常拔第,程序崩潰,因為 launch
用作根協(xié)程逛万,子協(xié)程的異常必定會傳播給父協(xié)程得封,無論子協(xié)程是launch
還是async
忙上,異常都不會拋出疫粥,所以無法捕獲
如果async
創(chuàng)建的子協(xié)程產(chǎn)生的異常不向上傳遞项秉,是不是就可以避免異常影響父協(xié)程伙狐,導致應用崩潰呢贷屎?
val scope = CoroutineScope(Job()) scope.launch { supervisorScope { //在supervisorScope中創(chuàng)建子協(xié)程 val job = async { //async 相當于 throw Exception("Failed") } try { job.await() } catch (e: Exception) { e.printStackTrace() //成功捕獲異常唉侄,程序無崩潰 } } }
或者
val scope = CoroutineScope(Job()) scope.launch { coroutineScope { val job = async(SupervisorJob()) { //async 開啟子協(xié)程 throw Exception("Failed") } try { job.await() } catch (e: Exception) { e.printStackTrace() //成功捕獲異常,程序無崩潰 } } }
實際上同眯,上面兩個例子须蜗,分別使用supervisorScope
和CoroutineScope(SupervisorJob())
明肮,將異常不向上傳遞柿估,由當前協(xié)程拋出秫舌,try-catch
來捕獲
那么如果未使用supervisorScope
或CoroutineScope(SupervisorJob())
,異常未能捕獲嫂粟,一直向上傳遞到根層級的根部,導致父級失敗飒房,該如何處理狠毯?
CoroutineExceptionHandler
協(xié)程處理異常的第二個方法是使用CoroutineExceptionHandler
針對協(xié)程中嚼松,自動拋出的(launch
創(chuàng)建的協(xié)程)未捕獲的異常献酗,我們可以使用CoroutineExceptionHandler
來處理
CoroutineExceptionHandler
是用于全局“捕獲所有”行為的最后一種機制罕偎。您無法在CoroutineExceptionHandler
中從異常中恢復颜及。當處理程序被調(diào)用時俏站,協(xié)程已經(jīng)完成了相應的異常肄扎。通常,該處理程序用于記錄異常赞哗、顯示某種錯誤消息肪笋、終止和/或重新啟動應用程序。
這段話讀起來有點難以理解惭墓,換個思路理解 CoroutineExceptionHandler
是全局捕獲異常的方式腊凶,說明異常經(jīng)子作用域一級級向上傳遞钧萍,到達最頂層的作用域风瘦,說明子作用域都全部取消了万搔,CoroutineExceptionHandler
被調(diào)用時瞬雹,所有子協(xié)程已經(jīng)傳遞了相應異常刽虹,不會有新的異常傳遞了
所以CoroutineExceptionHandler
必須設置在最頂層作用域才能捕獲異常意敛,不然捕獲失敗膛虫。
CoroutineExceptionHandler的使用
下面是如何聲明一個CoroutineExceptionHandler
的例子稍刀。
val exHandler = CoroutineExceptionHandler{context, exception -> println(exception) } val scope = CoroutineScope(Job()) scope.launch { launch(exHandler) { throw Exception("Failed") //異常捕獲失敗 } }
異常不會被捕獲的原因是因為 exHandler 沒有給父級账月。內(nèi)部協(xié)程會在異常出現(xiàn)時傳播異常并傳遞給它的父級剧劝,由于父級并不知道 handler 的存在讥此,異常就沒有被拋出萄喳。
改成下面例子他巨,就能正常捕獲異常
val exHandler = CoroutineExceptionHandler{context, exception -> println(exception) } val scope = CoroutineScope(Job()) scope.launch(exHandler) {//最上層協(xié)程捕獲 launch { throw Exception("Failed") } }
CoroutineExceptionHandler的不足
由于沒有
try-catch
來捕獲住異常捻爷,異常會向上傳播役衡,直到它到達根協(xié)程,根據(jù)協(xié)程的結(jié)構(gòu)化并發(fā)的特性俐芯,異常向上傳播時吧史,父協(xié)程會失敗贸营,同時父協(xié)程所級聯(lián)的子協(xié)程和兄弟協(xié)程也都會失敗捕儒;CoroutineExceptionHandler
的作用在于全局捕獲異常阎毅,CoroutineExceptionHandler
無法在代碼的特定部分處理異常点弯,例如針對某一個失敗接口,無法在異常后進行重試或者其他特定操作肃拜。如果你想在特定部分做異常處理的話士聪,
try-catch
更適合剥悟。
總結(jié)
協(xié)程的異常捕獲機制,主要就是兩點: 局部異常捕獲和全局異常捕獲
異常發(fā)生的作用域:
作用域內(nèi)慈缔,直接
try-catch
藐鹤,則可以直接捕獲異常,進行處理-
作用域外
launch
啟動的的作用域無法捕獲異常肄满,會立即雙向傳遞稠歉,最終拋出-
async
啟動的作用域:- 如果
async
在CoroutineScope(SupervisorJob)
實例或supervisorScope
中啟動協(xié)程,則異常不會向上傳遞瞬测,可以在async.await()
時捕獲異常 - 如果
async
在非SupervisorJob
實例或supervisorScope
的直接子協(xié)程中啟動横媚,則異常雙向傳播,在async.await()
時無法捕獲異常
- 如果
supervisorScope
中異常月趟,不會向上傳遞灯蝴,只會影響自己
coroutineScope
中異常,會向雙向傳遞孝宗,影響自己和父級
CoroutineExceptionHandler
只能捕獲launch
中的異常穷躁,launch
產(chǎn)生的異常會立即傳遞給父級,而且CoroutineExceptionHandler
必須給最上層launch
才會生效