十分鐘帶你快速認識 Kotlin — Coroutine

前言

開發 Android 的開發者或多或少一定有遇過 ANR(Application Not Response),你知道出現 ANR 的原因嗎?主執行緒大約 16ms 更新畫面一次,而一次大約是 60 幀。當我們執行的任務太耗時,佔用太多主執行緒 (Main Thread) 的時間,那麼就會因為主執行緒一直忙著運算該耗時任務,而沒有時間更新畫面。輕者造成系統卡頓,嚴重則造成 ANR。

Coroutine 是 Kotlin 的殺手級函式庫之一,它是一個執行非同步程序的解決方案,它提供了簡易的操作方式,讓我們可以輕鬆的在不同執行緒上作切換,而它不需要 Callback 的設計,讓非同步的程式能夠以同步的方式來執行,如此閱讀非同步程式碼的時候,就能夠由上至下順暢的閱讀,而不會被 Callback 給干擾。

除了上述的優點之外, Coroutine 更可以想成是一個輕量級的執行緒,因為一個執行緒中可以產生更多的 Coroutine;在 Coroutine 中,我們是不能夠直接使用執行緒的,取而代之的是讓調度器來分配不同的執行緒,Coroutine 在背景已經建立好執行緒池(Thread pool),根據選擇的調度器,把任務丟到不同的執行緒上操作,因為執行緒已經建立了,所以就不存在建立執行緒的消耗。

在 Android 中使用 Coroutine 另外一個好處是,Coroutine 的生命週期可以跟 Android 的畫面綁在一起,當離開頁面但背景程序還沒完成時, Coroutine 會自動的取消該程序,避免記憶體洩漏(Memory Leak) 的情況。

三個基本元素

一個 Coroutine 是由三個基本元素所組成:Scope、Suspend、Dispatcher。

Scope

Kotlin 的 Coroutine 需要在執行域(CoroutineScope)中執行,而當建立一個 Coroutine 時,相對應的 CoroutineScope 也就會同時被建立出。有三種方法可以建立一個 CoroutineScope。

  • launch():建立一個新的 Coroutine,會回傳一個 Job ,我們可以在 launch() 中直接執行任務,或是透過回傳的 Job 在適當的時機呼叫其 start() 或者 join() 延遲執行。適合使用在沒有回傳值的非同步任務。
  • async():如果非同步任務是有回傳值的,那就需要使用 async() 來建立 Coroutine Scope, async() 的回傳值將會回傳一個 Deferred<T> ,當任務完成時,我們可以使用 await() 來取得回傳值得內容。
  • coroutineScope():建立一個 Coroutine Scope,但是不建立新的 Coroutine。也就是說它內部的 coroutineContext 是由繼承外部的 CoroutineScope 得來的。目的是用來分解並行工作的。

Android 上的 CoroutineScope — ViewModelScope

現今 Android 建議的架構為 MVVM,而 Jetpack 提供了一個能在 ViewModel 中使用的 ViewModelScope。若在 ViewModelScope 中執行 Coroutine,Coroutine 就會與 ViewModel 的生命週期綁在一起。當 ViewModel 被清除時,假如還有 Coroutine 尚未執行完,那麼就會隨著 ViewModel 被清除而被一併取消,所以在 ViewModel 中使用 Coroutine 就不需要獨自考慮 App 的生命週期。有了 ViewModelScope 就能夠更輕鬆的在 ViewModel 中使用 Coroutine 。

Suspend

Kotlin 利用 suspend 關鍵字將函式宣告成可暫停(Suspendable)的函式,那麼什麼是可暫停的函式呢?每一個 suspend function 都必須在 CoroutineScope 中執行,在 CoroutineScope 中遇到 suspend function 則會將目前的控制權暫停,直到函式結束才返回。

想想看,下面的程式會輸出怎麼樣的結果?

runBlocking {
//Coroutine1
launch {
delay(1000)
println("done1")
}
//Coroutine2
launch {
delay(500)
println("done2")
}
println("Start")
}
Start
done2
done1

PS:runBlocking 是用來建立一個 Coroutine 用的,不過執行後會阻塞主執行緒,所以只適合用在測試上。

回到上面的範例,當我們進入 runBlocking 區塊遇到第一個 coroutineScope 時,在這個執行域裡面使用了 delay(1000) 將此 coroutine 暫停 1000 ms (1秒鐘),此 coroutine (Coroutine1) 會把自己的執行權先交出去,等到它完成的時候才會返回。接著會執行下一段 coroutineScope ,在這裡面只用了 delay(500) 延遲了 500 ms,同第一個執行域,此時第二個 coroutine (Coroutine2)也會將自己的執行權交出去,最後則是 println(”Start”) ,因為他並沒有使用 launch 包住,所以它的層級與 runBlocking 是同一層級的,由於並沒有任何延遲,所以可以印出 println("Start") ,接下來則是延遲時間比較短的 Coroutine2 會執行,最後是 Coroutine1。

那這些跟 suspend function 有什麼關係呢?

我們可以將上面的函式改為

runBlocking {
//Coroutine1
launch {
run1()
}
//Coroutine2
launch {
run2()
}
println("Start")
}
suspend fun run1(){
delay(1000)
println("done1")
}
suspend fun run2(){
delay(500)
println("done2")
}

launch{} 的內容抽出來放到 suspend fun 中,這樣子的作法可以讓程式碼更簡潔,而我們也可以重複使用。

不過要注意的是, suspend fun 只能用在 Coroutine 裡面,如果將 suspend fun 放在 Coroutine 外面可是會編譯失敗的。

Dispatchers — 調度器

前面有提到,Coroutine 厲害的地方在於它提供了調度器, 我們可以根據我們的需求來決定要使用什麼調度器。究竟什麼是調度器呢?我們可以從下面的範例一探究竟:

suspend fun fetchDocs() {    // Dispatchers.Main
val result = get("<https://developer.android.com>")
// Dispatchers.IO for `get
show(result) // Dispatchers.Main
}
suspend fun get(url: String) = withContext(Dispatchers.IO) { /* ... */ }

建立一個 Coroutine 時,若沒有特別設定,那麼它的 Dispatcher 將會是 Main,而在 Android 中,這代表著主執行緒 (Main Thread),用 withContext 我們可以使用其他調度器,如範例的 Dispatchers.IO,當 get() 執行完成之後,將取得的數據交由 show() 來處理,而 show() 執行時,又切回 Dispatchers.Main。

所以使用 withContext() 的好處是,由於它是 main-safe(主執行緒安全),在主執行緒的任何地方都可以呼叫 withContext() ,意味著當執行完成之後,就會立刻的切回 Dispatchers.Main。我們可以根據應用輕鬆地在不同調度器中切換,而在耗時任務結束之後,也可以立即主動的切回 Dispatchers.Main,減少調用者的困擾,也避免在非主執行緒更新畫面造成的異常。

在 Android 中,有三種不同的調度器可以使用,除了上面介紹的 Dispatchers.MainDispatchers.IO 外,還有 Dispatchers.Default

  • Dispatchers.Main:主執行緒,用來更新畫面。
  • Dispatchers.IO:針對硬碟及網路服務優化的調度器,可用於 Room、Retrofit…
  • Dispatchers.Default:針對耗費 CPU 資源的,如 JSON 的解析及排序列表 (List)。

結語

這三個元素是 Coroutine 的重要因子,Scope 代表的是 Coroutine 必須要在特定範圍(CoroutineScope)內才能執行,Suspend 代表的是可以在 Coroutine 中任意的暫停該 Coroutine,並把資源讓給其他需求。Dispatcher 代表的是在 Coroutine 中可以根據需求任意的切換執行緒,而在 Kotlin 的 Coroutine 中提供了三種不同目的的調度器,我們可以根據我們的應用來選擇適當的調度器,不過扣掉 Dispatchers.Main,只剩下兩個可以選了,跟 CPU 運算有關係的就選擇 Dispatchers.Default,跟硬碟、網路服務有關的就選擇 Dispatchers.IO

在 Android 的 Jetpack 提供 ViewModelScope ,讓 Coroutine 也能夠跟 ViewModel 的生命週期綁在一起,這樣只要 ViewModel 被清除, Coroutine 也會跟著被取消,也就不會有 Memory Leak 了。

本篇文章快速帶大家了解 Coroutine 的概念,若有內容不清楚的、有錯誤的,歡迎留言或來信跟我討論。

謝謝大家。

--

--

Android/Flutter developer, like to learn and share.

Love podcasts or audiobooks? Learn on the go with our new app.

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store
Andy Lu

Andy Lu

Android/Flutter developer, like to learn and share.