我看來,Kotlin Coroutines(協程) 大大簡化了同步和非同步程式碼。但是,我發現了許多開發者在使用協程時會犯一些通用性的錯誤。
1. 在使用協程時例項化一個新的 Job 例項有時候你會需要一個 job 來對協程進行一些操作,例如,稍後取消。另外由於協程構建器 launch{} 和 async{} 都需要 job 作為入參,你可能會想到建立一個新的 job 例項作為引數來使用。這樣的話,你就擁有了一個 job 引用,稍後你可以呼叫它的 .cancel() 方法。
fun main() = runBlocking { val coroutineJob = Job() launch(coroutineJob) { println("performing some work in Coroutine") delay(100) }.invokeOnCompletion { throwable -> if (throwable is CancellationException) { println("Coroutine was cancelled") } } // cancel job while Coroutine performs work delay(50) coroutineJob.cancel()}
這段程式碼看起來沒有任何問題,協程被成功取消了。
>_ performing some work in CoroutineCoroutine was cancelledProcess finished with exit code 0
但是,讓我們試試在協程作用域 CoroutineScope 中執行這個協程,然後取消協程作用域而不是協程的 job 。
fun main() = runBlocking { val scopeJob = Job() val scope = CoroutineScope(scopeJob) val coroutineJob = Job() scope.launch(coroutineJob) { println("performing some work in Coroutine") delay(100) }.invokeOnCompletion { throwable -> if (throwable is CancellationException) { println("Coroutine was cancelled") } } // cancel scope while Coroutine performs work delay(50) scope.cancel()}
當作用域被取消時,它內部的所有協程都會被取消。但是當我們再次執行修改過的程式碼時,情況並不是這樣。
>_performing some work in CoroutineProcess finished with exit code 0
現在,協程沒有被取消,Coroutine was cancelled 沒有被列印。
為什麼會這樣?
原來,為了讓非同步/同步程式碼更加安全,協程提供了革命性的特性 —— “結構化併發” 。“結構化併發” 的一個機制就是:當作用域被取消時,就取消該作用域中的所有協程。為了保證這一機制正常工作,作用域的 job 和協程的 job 之前的層級結構如下圖所示:
在我們的例子中,發生了一些異常情況。透過向協程構建器 launch() 傳遞我們自己的 job 例項,實際上並沒有把新的 job 例項和協程本身進行繫結,取而代之的是,它成為了新協程的父 job。所以你建立的新協程的父 job 並不是協程作用域的 job,而是新建立的 job 物件。
因此,協程的 job 和協程作用域的 job 此時並沒有什麼關聯。
我們打破了結構化併發,因此當我們取消協程作用域時,協程將不再被取消。
解決方式是直接使用 launch() 返回的 job。
fun main() = runBlocking { val scopeJob = Job() val scope = CoroutineScope(scopeJob) val coroutineJob = scope.launch { println("performing some work in Coroutine") delay(100) }.invokeOnCompletion { throwable -> if (throwable is CancellationException) { println("Coroutine was cancelled") } } // cancel while coroutine performs work delay(50) scope.cancel()}
這樣,協程就可以隨著作用域的取消而取消了。
>_performing some work in CoroutineCoroutine was cancelledProcess finished with exit code 0
2. 錯誤的使用 SupervisorJob
有時候你會使用 SupervisorJob 來達到下面的效果:
在 job 繼承體系中停止異常向上傳播當一個協程失敗時不影響其他的同級協程由於協程構建器 launch{} 和 async{} 都可以傳遞 Job 作為入參,所以你可以考慮向構建器傳遞 SupervisorJob 例項。
launch(SupervisorJob()){ // Coroutine Body}
但是,就像錯誤 1 ,這樣會打破結構化併發的取消機制。正確的解決方式是使用 supervisorScope{} 作用域函式。
supervisorScope { launch { // Coroutine Body }}
3. 不支援取消
當你在自己定義的 suspend 函式中進行一些比較重的操作時,例如計算斐波拉契數列:
// factorial of n (n!) = 1 * 2 * 3 * 4 * ... * nsuspend fun calculateFactorialOf(number: Int): BigInteger = withContext(Dispatchers.Default) { var factorial = BigInteger.ONE for (i in 1..number) { factorial = factorial.multiply(BigInteger.valueOf(i.toLong())) } factorial }
這個掛起函式有一個問題:它不支援 “合作式取消” 。這意味著即使執行這個函式的協程被提前取消了,它仍然會繼續執行直到計算完成。為了避免這種情況,可以定期執行以下函式:
ensureActive()isActive()yield()下面的程式碼使用了 ensureActive() 來支援取消。
// factorial of n (n!) = 1 * 2 * 3 * 4 * ... * nsuspend fun calculateFactorialOf(number: Int): BigInteger = withContext(Dispatchers.Default) { var factorial = BigInteger.ONE for (i in 1..number) { ensureActive() factorial = factorial.multiply(BigInteger.valueOf(i.toLong())) } factorial }
Kotlin 標準庫中的掛起函式(如 delay()) 都是可以配合取消的。但是對於你自己的掛起函式,不要忘記考慮取消的情況。
4. 進行網路請求或者資料庫查詢時切換排程器這一項並不真的是一個 “錯誤” ,但是仍可能讓你的程式碼難以理解,甚至更加低效。一些開發者認為當呼叫協程時,就應該切換到後臺排程器,例如,進行網路請求的 Retrofit 的 suspend 函式,進行資料庫操作的 Room 的 suspend 函式。
5. 嘗試使用 try/catch 來處理協程的異常協程的異常處理很複雜,我花了相當多的時間才完全理解,並透過 部落格 和 講座 向其他開發者進行了解釋。我還作了一些 圖 來總結這個複雜的話題。
關於 Kotlin 協程異常處理最不直觀的方面之一是,你不能使用 try-catch 來捕獲異常。
fun main() = runBlocking<Unit> { try { launch { throw Exception() } } catch (exception: Exception) { println("Handled $exception") }}
如果執行上面的程式碼,異常不會被處理並且應用會 crash 。
>_ Exception in thread "main" java.lang.ExceptionProcess finished with exit code 1
Kotlin Coroutines 讓我們可以用傳統的編碼方式書寫非同步程式碼。但是,在異常處理方面,並沒有如大多數開發者想的那樣使用傳統的 try-catch 機制。如果你想處理異常,在協程內直接使用 try-catch 或者使用 CoroutineExceptionHandler 。
更多資訊可以閱讀前面提到的這篇 文章 。
6. 在子協程中使用 CoroutineExceptionHandler再來一條簡明扼要的:在子協程的構建器中使用 CoroutineExceptionHandler 不會有任何效果。這是因為異常處理是代理給父協程的。因為,你必須在根或者父協程或者 CoroutineScope 中使用 CoroutineExceptionHandler 。
同樣,更多細節請閱讀 這裡 。
7. 捕獲 CancellationExceptions當協程被取消,正在執行的掛起函式會丟擲 CancellationException 。這通常會導致協程發生 "異常" 並且立即停止執行。如下面程式碼所示:
fun main() = runBlocking { val job = launch { println("Performing network request in Coroutine") delay(1000) println("Coroutine still running ... ") } delay(500) job.cancel()}
500 ms 之後,掛起函式 delay() 丟擲了 CancellationException ,協程 "異常結束" 並且停止執行。
>_Performing network request in CoroutineProcess finished with exit code 0
現在讓我們假設 delay() 代表一個網路請求,為了處理網路請求可能發生的異常,我們用 try-catch 程式碼塊來捕獲所有異常。
fun main() = runBlocking { val job = launch { try { println("Performing network request in Coroutine") delay(1000) } catch (e: Exception) { println("Handled exception in Coroutine") } println("Coroutine still running ... ") } delay(500) job.cancel()}
現在,假設服務端發生了 bug 。catch 分支不僅會捕獲錯誤網路請求的 HttpException ,對於 CancellationExceptions 也是。因此協程不會 “異常停止”,而是繼續執行。
>_Performing network request in CoroutineHandled exception in CoroutineCoroutine still running ... Process finished with exit code 0
這可能導致裝置資源浪費,甚至在某些情況下導致崩潰。
要解決這個問題,我們可以只捕獲 HttpException 。
fun main() = runBlocking { val job = launch { try { println("Performing network request in Coroutine") delay(1000) } catch (e: HttpException) { println("Handled exception in Coroutine") } println("Coroutine still running ... ") } delay(500) job.cancel()}
或者再次丟擲 CancellationExceptions 。
最後Android學習是一條漫長的道路,我們要學習的東西不僅僅只有表面的 技術,還要深入底層,弄明白下面的 原理,只有這樣,我們才能夠提高自己的競爭力,在當今這個競爭激烈的世界裡立足。