原文作者:Lukas Lechner[1]
原文地址:7 common mistakes you might be making when using Kotlin Coroutines[2]
譯者:秉心說
在我看來,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 Coroutine
Coroutine was cancelled
Process 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 Coroutine
Process 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 Coroutine
Coroutine was cancelled
Process 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 * ... * n
suspend 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() [3] -
isActive() [4] -
yield() [5]
下面的代碼使用了 ensureActive()[6] 來支持取消。
// factorial of n (n!) = 1 * 2 * 3 * 4 * ... * n
suspend 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
函數。
這並不是必須的。因爲所有的掛起函數都應該是主線程安全的,Retrofit 和 Room 都遵循了這一約定。你可以閱讀我的 這篇文章[7] 以瞭解更多內容。
5. 嘗試使用 try/catch 來處理協程的異常
協程的異常處理很複雜,我花了相當多的時間才完全理解,並通過 博客[8] 和 講座[9] 向其他開發者進行了解釋。我還作了一些 圖[10] 來總結這個複雜的話題。
關於 Kotlin 協程異常處理最不直觀的方面之一是,你不能使用 try-catch
來捕獲異常。
fun main() = runBlocking<Unit> {
try {
launch {
throw Exception()
}
} catch (exception: Exception) {
println("Handled $exception")
}
}
如果運行上面的代碼,異常不會被處理並且應用會 crash 。
>_
Exception in thread "main" java.lang.Exception
Process finished with exit code 1
Kotlin Coroutines 讓我們可以用傳統的編碼方式書寫異步代碼。但是,在異常處理方面,並沒有如大多數開發者想的那樣使用傳統的 try-catch
機制。如果你想處理異常,在協程內直接使用 try-catch
或者使用 CoroutineExceptionHandler
。
更多信息可以閱讀前面提到的這篇 文章[11] 。
6. 在子協程中使用 CoroutineExceptionHandler
再來一條簡明扼要的:在子協程的構建器中使用 CoroutineExceptionHandler
不會有任何效果。這是因爲異常處理是代理給父協程的。因爲,你必須在根或者父協程或者 CoroutineScope
中使用 CoroutineExceptionHandler
。
同樣,更多細節請閱讀 這裏[12] 。
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 Coroutine
Process 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 Coroutine
Handled exception in Coroutine
Coroutine 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
。
fun main() = runBlocking {
val job = launch {
try {
println("Performing network request in Coroutine")
delay(1000)
} catch (e: Exception) {
if (e is CancellationException) {
throw e
}
println("Handled exception in Coroutine")
}
println("Coroutine still running ... ")
}
delay(500)
job.cancel()
}
以上就是使用 Kotlin Coroutines 最常見的 7 個錯誤。如果你瞭解其他常見錯誤,歡迎在評論區留言!
另外,不要忘記向其他開發者分享這篇文章以免發生這樣的錯誤。Thanks !
Thank you for reading, and have a great day!
參考資料
Lukas Lechner: https://www.lukaslechner.com/
[2]7 common mistakes you might be making when using Kotlin Coroutines: https://www.lukaslechner.com/7-common-mistakes-you-might-be-making-when-using-kotlin-coroutines/
[3]ensureActive(): https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/ensure-active.html
[4]isActive(): https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/is-active.html
[5]yield(): https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/yield.html
[6]ensureActive(): https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/ensure-active.html
[7]這篇文章: https://www.lukaslechner.com/do-i-need-to-call-suspend-functions-of-retrofit-and-room-on-a-background-thread/
[8]博客: https://www.lukaslechner.com/why-exception-handling-with-kotlin-coroutines-is-so-hard-and-how-to-successfully-master-it/
[9]講座: https://www.droidcon.com/media-detail?video=481189746
[10]圖: https://www.lukaslechner.com/coroutines-exception-handling-cheat-sheet/
[11]文章: https://www.lukaslechner.com/why-exception-handling-with-kotlin-coroutines-is-so-hard-and-how-to-successfully-master-it/
[12]這裏: https://www.lukaslechner.com/why-exception-handling-with-kotlin-coroutines-is-so-hard-and-how-to-successfully-master-it/
本文分享自微信公衆號 - 秉心說TM(gh_c6504b1af5ae)。
如有侵權,請聯繫 [email protected] 刪除。
本文參與“OSC源創計劃”,歡迎正在閱讀的你也加入,一起分享。