Kotlin Playground 中的協同程式簡介

1. 事前準備

本程式碼研究室將介紹並行,這是 Android 開發人員為了提供優質使用者體驗而必須瞭解的一項重要技能。「並行」是指在應用程式中同時執行多項工作。舉例來說,應用程式可以從網路伺服器擷取資料,或是將使用者資料儲存在裝置中,同時回應使用者輸入事件,並據此更新 UI。

如要在應用程式中並行執行作業,請使用 Kotlin 協同程式。協同程式可暫停執行某區塊的程式碼,稍後再重新啟用,以便同時執行其他作業。有了協同程式,您就能輕鬆編寫非同步程式碼,不需要百分之百完成一項工作即可開始下一項工作,達到多工處理的成效。

本程式碼研究室會引導您逐步瞭解 Kotlin Playground 中的幾個基本範例,您可以練習使用協同程式,在使用非同步程式設計時更加得心應手。

必要條件

  • 能夠使用 main() 函式建立基本的 Kotlin 程式
  • Kotlin 語言的基本知識,包括函式和 lambda

建構項目

  • 建構 Kotlin 小程式,學習及試用協同程式的基本功能

課程內容

  • Kotlin 協同程式如何簡化非同步程式設計
  • 結構化並行的目的及重要性

軟硬體需求

2. 同步程式碼

簡易程式

同步程式碼中,一次只能處理一項概念工作。您可以將此視為有先後順序的線性路徑,必須先完成一項工作,才能開始執行下一項工作。以下是同步程式碼的範例。

  1. 開啟 Kotlin Playground
  2. 將程式碼換成以下內容,編寫一個顯示晴天預報的程式。在 main() 函式中,先輸出文字 Weather forecast,然後輸出 Sunny
fun main() {
    println("Weather forecast")
    println("Sunny")
}
  1. 執行程式碼。執行上述程式碼的輸出內容應如下所示:
Weather forecast
Sunny

println() 是同步呼叫,因為系統會先完成文字輸出工作,再將執行作業移至下一行程式碼。由於 main() 中的每個函式呼叫都是同步的,因此整個 main() 函式都會保持同步。函式同不同步取決於組成部分。

同步函式只會在工作全部完成時傳回。因此,在執行 main() 中的最後一個輸出陳述式之後,所有作業都已完成,系統會傳回 main() 函式並結束程式。

新增延遲

假設要獲得晴天預報,您必須向遠端網路伺服器發出網路要求。請模擬網路要求,在程式碼的晴天預報輸出陳述式前新增延遲。

  1. 首先,在 main() 函式之前的程式碼頂端新增 import kotlinx.coroutines.*。系統會從 Kotlin 協同程式庫匯入您要使用的函式。
  2. 在程式碼中加入對 delay(1000) 的呼叫,將 main() 函式的其餘部分延遲 1000 毫秒 (1 秒) 執行。請在 Sunny 的輸出陳述式前方插入 delay() 呼叫。
import kotlinx.coroutines.*

fun main() {
    println("Weather forecast")
    delay(1000)
    println("Sunny")
}

delay() 是 Kotlin 協同程式庫提供的特殊暫停函式。系統執行這一行時會暫停 main() 函式,等到指定延遲時間結束後再繼續 (本例為一秒)。

如果您在延遲期間嘗試執行程式,系統會顯示編譯錯誤:Suspend function 'delay' should be called only from a coroutine or another suspend function

為了學習 Kotlin Playground 中的協同程式,您可以呼叫協同程式庫中的 runBlocking() 函式,包裝現有程式碼。runBlocking() 會執行事件迴圈,在準備好時從中斷處繼續執行各項任務,因此可以一次處理多項任務。

  1. main() 函式的現有內容移至 runBlocking {} 呼叫的主體中。系統會在新的協同程式中執行 runBlocking{} 的主體。
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("Weather forecast")
        delay(1000)
        println("Sunny")
    }
}

runBlocking() 具同步性質;必須等到 lambda 區塊內的所有工作完成之後才會傳回。這表示它會等待 delay() 呼叫中的作業完成 (一秒過後),然後繼續執行 Sunny 輸出陳述式。runBlocking() 函式中的所有作業都完成後,系統會傳回該函式並結束程式。

  1. 執行程式。輸出內容如下所示:
Weather forecast
Sunny

輸出內容與先前相同。程式碼仍然處於同步狀態 - 以直線運作,且一次只能執行一項工作。然而差別在於由於存在延遲,執行時間會較長。

協同程式中的「co-」是指合作模式。這個程式碼會協同運作,在暫停等待時分享基礎事件迴圈,以允許其他作業同時執行。(「協同程式」中的「-routine」代表一組指示,例如函式)。在此範例中,協同程式會在達到 delay() 呼叫時暫停。協同程式暫停時,其他作業可在一秒內完成 (儘管在這個程式中,並沒有需要執行的其他作業)。延遲時間過後,協同程式會繼續執行,並繼續將 Sunny 輸出到畫面上。

暫停函式

如果執行網路要求以取得天氣資料的實際邏輯變得更加複雜,您可能需要將邏輯擷取到其自身的函式中。讓我們重構程式碼,看看效果如何。

  1. 擷取用於模擬天氣資料網路要求的程式碼,並移至其自身名為 printForecast() 的函式。從 runBlocking() 代碼呼叫 printForecast()
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("Weather forecast")
        printForecast()
    }
}

fun printForecast() {
    delay(1000)
    println("Sunny")
}

如果現在執行該程式,系統會顯示與先前相同的編譯錯誤。只能從協同程式或其他暫停函式呼叫暫停函式,因此請將 printForecast() 定義為 suspend 函式。

  1. printForecast() 函式宣告的 fun 關鍵字前方新增 suspend 修飾符,以將其定義為暫停函式。
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("Weather forecast")
        printForecast()
    }
}

suspend fun printForecast() {
    delay(1000)
    println("Sunny")
}

請注意,delay() 是暫停函式,而您也已經將 printForecast() 設為暫停函式。

暫停函式與一般函式類似,但暫停函式可能會暫停,稍後再次繼續執行。如要這麼做,只能從提供這項功能的其他暫停函式呼叫暫停函式。

暫停函式可能包含零個或多個暫停點。暫停點是指函式中可暫停執行函式的位置。繼續執行作業後,它會從程式碼中上次中斷的地方開始,並繼續執行函數的其餘部分。

  1. 練習在 printForecast() 函式的宣告下方新增其他暫停函式。呼叫這個新的暫停函式 printTemperature()。您可以假定此函式發出了網路要求,以取得天氣預報的溫度資料。

在此函式中,同樣將執行作業延遲 1000 毫秒,然後將溫度值輸出到畫面上,例如攝氏 30 度。您可以使用逸出順序 "\u00b0" 輸出度數符號 °

suspend fun printTemperature() {
    delay(1000)
    println("30\u00b0C")
}
  1. main() 函式中從 runBlocking() 程式碼呼叫新的 printTemperature() 函式。以下是完整的程式碼:
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("Weather forecast")
        printForecast()
        printTemperature()
    }
}

suspend fun printForecast() {
    delay(1000)
    println("Sunny")
}

suspend fun printTemperature() {
    delay(1000)
    println("30\u00b0C")
} 
  1. 執行程式。輸出內容應如下所示:
Weather forecast
Sunny
30°C

在這個程式碼中,協同程式會先暫停 printForecast() 暫停函式的延遲,一秒延遲過後繼續執行。Sunny 文字會輸出到畫面上。printForecast() 函式會傳回呼叫端。

接下來,系統會呼叫 printTemperature() 函式。協同程式會在達到 delay() 呼叫時暫停,一秒後繼續執行,並將溫度值輸出到畫面上。printTemperature() 函式已完成所有作業並傳回。

runBlocking() 主體中,沒有其他可執行的工作,因此 runBlocking() 函式會傳回,程式結束。

如先前所述,runBlocking() 是同步函式,系統會依序呼叫主體中的每次呼叫。請注意,設計完善的暫停函式在完成所有作業後才會傳回。因此,這些暫停函式會依序執行。

  1. (選用) 如果您想查看延遲執行這個程式需要多長時間,可以將程式碼納入 measureTimeMillis() 的呼叫中,然後系統會傳回執行傳遞的程式碼區塊所需的時間 (以毫秒為單位)。新增匯入陳述式 (import kotlin.system.*),即可使用此函式。輸出執行時間並除以 1000.0,即可將單位從毫秒轉換為秒。
import kotlin.system.*
import kotlinx.coroutines.*

fun main() {
    val time = measureTimeMillis {
        runBlocking {
            println("Weather forecast")
            printForecast()
            printTemperature()
        }
    }
    println("Execution time: ${time / 1000.0} seconds")
}
suspend fun printForecast() {
    delay(1000)
    println("Sunny")
}

suspend fun printTemperature() {
    delay(1000)
    println("30\u00b0C")
} 

輸出內容:

Weather forecast
Sunny
30°C
Execution time: 2.128 seconds

輸出內容顯示執行程式大約需要 2.1 秒 (精確的執行時間可能略有不同)。看起來是合理的,因為每個暫停函式都有一秒延遲。

到目前為止,根據預設,系統依序叫用協同程式中的程式碼。請務必明確説明您是否要同時執行多項工作,您將在下一個部分中瞭解操作方法。您將利用合作模式事件迴圈來同時執行多項工作,以加快程式的執行時間。

3. 非同步程式碼

launch()

使用協同程式庫中的 launch() 函式啟動新的協同程式。如要以並行方式執行任務,請在程式碼中新增多個 launch() 函式,以便同時處理多個協同程式。

Kotlin 中的協同程式遵循稱為結構化並行的重要概念,除非您明確要求執行並行作業 (例如使用 launch()),否則根據預設,您的程式碼會依序執行,並與基礎事件迴圈合作。這裡的假設是,如果您呼叫某個函式,那麼無論該函式在實作詳細資料中使用了多少個協同程式,都應在徹底完成其工作後再傳回。即使函式因發生例外狀況而執行失敗,一旦系統擲回例外狀況後,函式就不會再有其他待處理的任務。因此,無論是擲回例外狀況還是順利完成工作,只要控制流程從函數返回之後,所有工作就都會完成。

  1. 從前幾步的程式碼著手,使用 launch() 函式,將 printForecast()printTemperature() 的每個呼叫分別移至其各自的協同程式。
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("Weather forecast")
        launch {
            printForecast()
        }
        launch {
            printTemperature()
        }
    }
}

suspend fun printForecast() {
    delay(1000)
    println("Sunny")
}

suspend fun printTemperature() {
    delay(1000)
    println("30\u00b0C")
} 
  1. 執行程式。輸出內容如下所示:
Weather forecast
Sunny
30°C

輸出內容相同,但您可能已注意到執行程式的速度有所提升。先前,您必須等到 printForecast() 暫停函式執行完畢,才能繼續執行 printTemperature() 函式。現在 printForecast()printTemperature() 能以並行方式執行,因為兩者皆位於單獨的協同程式中。

圖表頂端是 println (氣象預測) 陳述式的方塊,正下方有垂直向下的箭頭。這個垂直向下的箭頭又往右分出了兩個箭頭:第一個指向 printForecast() 陳述式的方塊;第二個指向 printTemperature() 陳述式的方塊。

launch { printForecast() } 的呼叫可在 printForecast() 中的所有工作完成前傳回。這正是協同程式的絕妙之處。您可以移至下一個 launch() 呼叫,啟動下一個協同程式。同樣地,launch { printTemperature() } 也會在所有工作完成前傳回。

  1. (選用) 如要查看程式現在的執行速度,您可以新增 measureTimeMillis() 程式碼來檢查執行時間。
import kotlin.system.*
import kotlinx.coroutines.*

fun main() {
    val time = measureTimeMillis {
        runBlocking {
            println("Weather forecast")
            launch {
                printForecast()
            }
            launch {
                printTemperature()
            }
        }
    }
    println("Execution time: ${time / 1000.0} seconds")
}

...

輸出內容:

Weather forecast
Sunny
30°C
Execution time: 1.122 seconds

可以看到,執行時間從約 2.1 秒下降到約 1.1 秒,因此當您新增並行作業後,執行程式的速度會更快!您可以移除這個時間評估程式碼,再繼續執行後續步驟。

如果在第二個 launch() 呼叫之後、runBlocking() 程式碼結束之前新增其他輸出陳述式,您認為會發生什麼情況?該訊息顯示在輸出內容中的哪個位置?

  1. 修改 runBlocking() 程式碼,在該區塊結束之前新增其他輸出陳述式。
...

fun main() {
    runBlocking {
        println("Weather forecast")
        launch {
            printForecast()
        }
        launch {
            printTemperature()
        }
        println("Have a good day!")
    }
}

...
  1. 執行程式,輸出內容如下所示:
Weather forecast
Have a good day!
Sunny
30°C

透過此輸出內容,您會發現為 printForecast()printTemperature() 啟動兩個新的協同程式後,就可以繼續處理輸出 Have a good day! 的下一個指示了。這說明 launch() 的「啟動即棄用」性質。使用 launch() 啟動新的協同程式時,您不必擔心其作業何時完成。

協同程式稍後會完成作業,並顯示其餘的輸出陳述式。完成 runBlocking() 呼叫主體中的所有作業 (包括所有協同程式) 後,系統就會傳回 runBlocking() 並結束程式。

您現已將同步程式碼變更為非同步程式碼。非同步函式傳回時,工作可能尚未完成。這是您在 launch() 案例中看到的情況。函式雖已傳回,但作業尚未完成。使用 launch() 時,可在程式碼中同時執行多項工作,這項強大的功能適用於您開發的 Android 應用程式。

async()

在實際生活中,您並不知道發出網路要求獲得天氣預報和溫度所需的時間。如果您想在完成這兩項工作後顯示統合天氣報告,那麼 launch() 目前采用的方法不足以實現此目標。這時 async() 就能派上用場。

如果您關心協同程式是何時完成的並需要該協同程式的傳回值,請使用協同程式庫中的 async() 函式。

async() 函式會傳回 Deferred 類型的物件,這就像是一個承諾,準備就緒時顯示結果。您可以使用 await() 存取 Deferred 物件上的結果。

  1. 請先變更暫停函式以傳回 String,而不是輸出天氣預報和溫度資料。將函式名稱從 printForecast()printTemperature() 更新為 getForecast()getTemperature()
...

suspend fun getForecast(): String {
    delay(1000)
    return "Sunny"
}

suspend fun getTemperature(): String {
    delay(1000)
    return "30\u00b0C"
}
  1. 修改 runBlocking() 程式碼,使其針對兩個協同程式使用 async(),而非 launch()。將每個 async() 呼叫的傳回值儲存在名為 forecasttemperature 的變數中,這類變數是保留 String 類型結果的 Deferred 物件。(指定類型是選用操作,因為在 Kotlin 中已指定類型推論,隨附於下方,以便您清楚查看 async() 呼叫傳回的內容)。
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("Weather forecast")
        val forecast: Deferred<String> = async {
            getForecast()
        }
        val temperature: Deferred<String> = async {
            getTemperature()
        }
        ...
    }
}

...
  1. 在協同程式中,兩次 async() 呼叫之後,您可以在 Deferred 物件上呼叫 await(),以存取這些協同程式的結果。在這種情況下,您可以使用 forecast.await()temperature.await() 輸出每個協同程式的值。
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("Weather forecast")
        val forecast: Deferred<String> = async {
            getForecast()
        }
        val temperature: Deferred<String> = async {
            getTemperature()
        }
        println("${forecast.await()} ${temperature.await()}")
        println("Have a good day!")
    }
}

suspend fun getForecast(): String {
    delay(1000)
    return "Sunny"
}

suspend fun getTemperature(): String {
    delay(1000)
    return "30\u00b0C"
}
  1. 執行程式,輸出內容應如下所示:
Weather forecast
Sunny 30°C
Have a good day!

真棒!您建立了兩個同時執行的協同程式,用於取得天氣預報和溫度資料。每個協同程式完成工作後都會傳回值。然後將兩個傳回值合併為單一輸出陳述式:Sunny 30°C

平行分解

進一步以此天氣預報爲例,看看協同程式在作業的平行分解中的用途有多大。並行分解需要將問題分解為可以平行解決的較小的子工作。子工作的結果準備就緒後,您就可以將這些結果合併到最終結果中。

在程式碼中,從 runBlocking() 的主體將天氣報告的邏輯擷取為單一 getWeatherReport() 函式,該函式會傳回 Sunny 30°C 的合併字串。

  1. 在程式碼中定義新的暫停函式 getWeatherReport()
  2. 將函式設定為相當於使用空白 lambda 區塊呼叫 coroutineScope{} 函式的結果,該區塊最終包含取得天氣報告的邏輯。
...

suspend fun getWeatherReport() = coroutineScope {
    
}

...

coroutineScope{} 會為這項天氣報告工作確定本地範圍。在這個範圍內啟動的協同程式會分組到這個範圍內,這對於取消作業和您稍後瞭解的例外狀況也會造成影響。

  1. coroutineScope() 的主體中,使用 async() 建立兩個新的協同程式,分別擷取天氣預報和溫度資料。合併這兩個協同程式的結果來建立天氣報告字串。方法是在 async() 呼叫傳回的每個 Deferred 物件上呼叫 await()。這樣可確保所有協同程式完成作業並傳回其結果,然後再傳回這個函式。
...

suspend fun getWeatherReport() = coroutineScope {
    val forecast = async { getForecast() }
    val temperature = async { getTemperature() }
    "${forecast.await()} ${temperature.await()}"
}

...
  1. runBlocking() 呼叫這個新的 getWeatherReport() 函式。以下是完整的程式碼:
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("Weather forecast")
        println(getWeatherReport())
        println("Have a good day!")
    }
}

suspend fun getWeatherReport() = coroutineScope {
    val forecast = async { getForecast() }
    val temperature = async { getTemperature() }
    "${forecast.await()} ${temperature.await()}"
}

suspend fun getForecast(): String {
    delay(1000)
    return "Sunny"
}

suspend fun getTemperature(): String {
    delay(1000)
    return "30\u00b0C"
}
  1. 執行程式後,顯示以下輸出內容:
Weather forecast
Sunny 30°C
Have a good day!

輸出內容相同,但需要留意以下要點。如前文所述,coroutineScope() 在完成所有作業 (包括啟動的任何協同程式) 後才會傳回。在此情況下,協同程式 getForecast()getTemperature() 都需要完成作業並傳回各自的結果。系統會合併 Sunny 文字和 30°C,並從範圍傳回。Sunny 30°C 的天氣報告已輸出到畫面上,且呼叫端可繼續執行 Have a good day! 的最後一個輸出陳述式。

使用 coroutineScope() 時,即使函式會在內部並行執行作業,但呼叫端會顯示為同步作業,因為 coroutineScope 在完成所有作業後才會傳回。

此處結構化並行的重點是,您可以執行多項並行作業,然後合併為單一同步作業,其中並行是實作詳細資料。針對呼叫程式碼的唯一規定是位於暫停函式或協同程式中。除此之外,呼叫代碼的結構不需要考量並行詳細資料。

4. 例外狀況和取消

接下來,我們來談談某些可能發生錯誤的情況,以及部分可能遭到取消的工作。

例外狀況簡介

「例外狀況」是指執行程式碼期間發生的非預期事件。您應該採取適當方法處理這些例外狀況,以防應用程式異常終止,對使用者體驗造成負面影響。

以下是因例外狀況提前終止的程式範例。這個程式會將 numberOfPizzas / numberOfPeople 相除,計算出每個人能吃到的披薩數量。假設您不小心忘了將 numberOfPeople 的值設為實際值。

fun main() {
    val numberOfPeople = 0
    val numberOfPizzas = 20
    println("Slices per person: ${numberOfPizzas / numberOfPeople}")
}

當您執行此程式時,由於無法將數字除以零,所以程式會因計算時出現例外狀況而異常終止。

Exception in thread "main" java.lang.ArithmeticException: / by zero
 at FileKt.main (File.kt:4)
 at FileKt.main (File.kt:-1)
 at jdk.internal.reflect.NativeMethodAccessorImpl.invoke0 (:-2)

這個問題有簡單直接的修正方式,那就是您可以將 numberOfPeople 的初始值改為非零的數字。但隨著程式碼變得越來越複雜,在某些時候,您會無法預測及避免所有例外狀況發生。

試想,如果其中一個協同程式因例外狀況而無法執行,會發生什麼事?請修改天氣程式的程式碼來一探究竟。

協同程式的例外狀況

  1. 我們從上一節的天氣程式著手。
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("Weather forecast")
        println(getWeatherReport())
        println("Have a good day!")
    }
}

suspend fun getWeatherReport() = coroutineScope {
    val forecast = async { getForecast() }
    val temperature = async { getTemperature() }
    "${forecast.await()} ${temperature.await()}"
}

suspend fun getForecast(): String {
    delay(1000)
    return "Sunny"
}

suspend fun getTemperature(): String {
    delay(1000)
    return "30\u00b0C"
}

在其中一個暫停函式中刻意擲回例外狀況,即可查看效果。從模擬伺服器擷取資料時,這可模擬發生了非預期錯誤。

  1. getTemperature() 函式中新增一行用來擲回例外狀況的程式碼。請在 Kotlin 中使用 throw 關鍵字編寫擲回運算式,後面接著從 Throwable 擴充的例外狀況新例項。

例如,您可以擲回 AssertionError 並傳入可詳細說明錯誤的訊息字串:throw AssertionError("Temperature is invalid")。擲回這個例外狀況會停止進一步執行 getTemperature() 函式。

...

suspend fun getTemperature(): String {
    delay(500)
    throw AssertionError("Temperature is invalid")
    return "30\u00b0C"
}

您也可以將 getTemperature() 方法的延遲時間變更為 500 毫秒,這樣就能知道在其他 getForecast() 函式完成工作之前會發生例外狀況。

  1. 執行程式即可查看結果。
Weather forecast
Exception in thread "main" java.lang.AssertionError: Temperature is invalid
 at FileKt.getTemperature (File.kt:24)
 at FileKt$getTemperature$1.invokeSuspend (File.kt:-1)
 at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith (ContinuationImpl.kt:33)

您需要知道協同程式之間存在父項與子項關係,才能瞭解這個行為。您可以啟動一個協同程式 (所謂子項),但從另一個協同程式 (父項) 操作。從這些協同程式啟動更多協同程式時,您可以建構完整的協同程式階層。

執行 getTemperature() 和執行 getForecast() 的協同程式,都是相同父項協同程式的子項協同程式。您在協同程式中看到的例外狀況行為,是因結構化並行問題所致。如果其中一個子項協同程式因例外狀況而無法執行,這個問題會向上蔓延。父項協同程式遭到取消後,也會取消任何其他子項協同程式 (如本例中執行 getForecast() 的協同程式),終至錯誤向上擴散,程式因 AssertionError 而異常終止。

try-catch 例外狀況

如果您知道程式碼的某些部分可能會擲回例外狀況,可以使用 try-catch 區塊包住該程式碼。您可以擷取例外狀況,並在應用程式中以更妥善的方式處理,例如向使用者顯示實用的錯誤訊息。這個程式碼片段可能會像這樣:

try {
    // Some code that may throw an exception
} catch (e: IllegalArgumentException) {
    // Handle exception
}

此方法也適用於使用協同程式的非同步程式碼。您仍可利用 try-catch 運算式擷取及處理協同程式中的例外狀況。這是因為多虧了結構化並行,依序程式碼仍為同步程式碼,因此 try-catch 區塊仍可按照預期方式運作。

...

fun main() {
    runBlocking {
        ...
        try {
            ...
            throw IllegalArgumentException("No city selected")
            ...
        } catch (e: IllegalArgumentException) {
            println("Caught exception $e")
            // Handle error
        }
    }
}

...

為了要能更順利地處理例外狀況,請修改天氣程式來擷取您先前新增的例外狀況,然後將例外狀況輸出到輸出結果中。

  1. runBlocking() 函式中找到呼叫 getWeatherReport() 的程式碼,然後在前後加上 try-catch 區塊。請將擷取到的錯誤輸出,同時輸出無法提供天氣報告的訊息。
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("Weather forecast")
        try {
            println(getWeatherReport())
        } catch (e: AssertionError) {
            println("Caught exception in runBlocking(): $e")
            println("Report unavailable at this time")
        }
        println("Have a good day!")
    }
}

suspend fun getWeatherReport() = coroutineScope {
    val forecast = async { getForecast() }
    val temperature = async { getTemperature() }
    "${forecast.await()} ${temperature.await()}"
}

suspend fun getForecast(): String {
    delay(1000)
    return "Sunny"
}

suspend fun getTemperature(): String {
    delay(500)
    throw AssertionError("Temperature is invalid")
    return "30\u00b0C"
}
  1. 執行程式,現在即可妥善處理錯誤,而程式也可順利執行完畢。
Weather forecast
Caught exception in runBlocking(): java.lang.AssertionError: Temperature is invalid
Report unavailable at this time
Have a good day!

您可以從輸出結果中觀察到 getTemperature() 會擲回例外狀況。在 runBlocking() 函式的主體中,您以 try-catch 區塊包住 println(getWeatherReport()) 呼叫,擷取了預期的例外狀況類型 (在此例中為 AssertionError)。然後,您將例外狀況輸出為 "Caught exception",後面接著錯誤訊息字串。為了處理這項錯誤,您利用額外的 println() 陳述式「Report unavailable at this time」,讓使用者瞭解無法使用天氣報告。

請注意,這個行為代表如果無法取得溫度,就不會產生任何天氣報告,即便已擷取到有效的預測值也一樣。

視您希望的程式行為而定,或許有其他方法能處理天氣程式中的例外狀況。

  1. 移動錯誤處理程序,讓 try-catch 行為在 async() 啟動的協同程式內發生,以便擷取溫度。如此一來,即使未成功取得溫度,天氣報告仍可輸出預測值。請參考以下程式碼:
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("Weather forecast")
        println(getWeatherReport())
        println("Have a good day!")
    }
}

suspend fun getWeatherReport() = coroutineScope {
    val forecast = async { getForecast() }
    val temperature = async {
        try {
            getTemperature()
        } catch (e: AssertionError) {
            println("Caught exception $e")
            "{ No temperature found }"
        }
    }

    "${forecast.await()} ${temperature.await()}"
}

suspend fun getForecast(): String {
    delay(1000)
    return "Sunny"
}

suspend fun getTemperature(): String {
    delay(500)
    throw AssertionError("Temperature is invalid")
    return "30\u00b0C"
}
  1. 執行程式。
Weather forecast
Caught exception java.lang.AssertionError: Temperature is invalid
Sunny { No temperature found }
Have a good day!

從輸出結果中,您可以看到例外狀況導致無法順利呼叫 getTemperature(),但 async() 中的程式碼能夠讓協同程式依舊傳回表示找不到溫度的 String,進而擷取並妥善處理例外狀況。因此,我們仍可以輸出天氣報告,成功預測天氣會是 Sunny。雖然天氣報告中缺少溫度資訊,但取而代之的是一則說明找不到溫度的訊息。比起程式發生錯誤而異常終止,這會帶來較佳的使用者體驗。

如果協同程式是透過 async() 啟動,您可以將其視為生產端,這是理解這個錯誤處理做法的實用方式。由於 await() 正在等待取用協同程式的結果,因此會是取用端。生產端負責執行作業並產生結果;取用端則會取用結果。如果生產端出現未經處理的例外狀況,取用端也會發生這個例外狀況,導致協同程式失敗。不過,如果生產端能擷取並處理例外狀況,取用端就不會發生此狀況,而會產生有效的結果。

以下再次附上 getWeatherReport() 程式碼供您參考:

suspend fun getWeatherReport() = coroutineScope {
    val forecast = async { getForecast() }
    val temperature = async {
        try {
            getTemperature()
        } catch (e: AssertionError) {
            println("Caught exception $e")
            "{ No temperature found }"
        }
    }

    "${forecast.await()} ${temperature.await()}"
}

在此例中,生產端 (async()) 能夠擷取並處理例外狀況,因此仍會傳回 "{ No temperature found }"String 結果。而取用端 (await()) 收到這項 String 結果,甚至無須知道曾發生例外狀況也無妨。如果您想妥善處理程式碼中預期可能出現的例外狀況,這會是另一個可行方式。

現在,您已瞭解如未處理例外狀況,這個問題就會在協同程式的樹狀結構中往上蔓延。當例外狀況全面擴散到階層的根目錄時,也請務必謹慎處理,因為這會導致整個應用程式當機。如要進一步瞭解如何處理例外狀況,請參閱網誌文章「協同程式中的例外狀況」和「協同程式例外狀況處理」一文。

取消

協同程式的取消作業,是與例外狀況類似的另一個主題。一般來說,這種情境是由使用者觸發,指的是某個事件導致應用程式取消先前啟動的工作。

舉例來說,假設使用者在應用程式中選取了某項偏好設定,不想再看到應用程式中的溫度值,也就是他們只想知道天氣預報資訊 (例如 Sunny) 而非確切溫度,那麼,我們就要將目前負責取得溫度資料的協同程式取消。

  1. 首先,我們從下方的初始程式碼著手 (沒有取消作業)。
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("Weather forecast")
        println(getWeatherReport())
        println("Have a good day!")
    }
}

suspend fun getWeatherReport() = coroutineScope {
    val forecast = async { getForecast() }
    val temperature = async { getTemperature() }
    "${forecast.await()} ${temperature.await()}"
}

suspend fun getForecast(): String {
    delay(1000)
    return "Sunny"
}

suspend fun getTemperature(): String {
    delay(1000)
    return "30\u00b0C"
}
  1. 一段時間後,請將擷取溫度資訊的協同程式取消,讓天氣報告只顯示預測資料。請將 coroutineScope 區塊的傳回值變更為僅限天氣預報字串。
...

suspend fun getWeatherReport() = coroutineScope {
    val forecast = async { getForecast() }
    val temperature = async { getTemperature() }
    
    delay(200)
    temperature.cancel()

    "${forecast.await()}"
}

...
  1. 執行程式。現在輸出結果會如下方所示,您會看到天氣報告中只有 Sunny 的天氣預報,但沒有溫度資訊,因為這個協同程式已經取消。
Weather forecast
Sunny
Have a good day!

您在這裡學到的是,協同程式可以取消,但不會影響同一範圍內的其他協同程式,也不會取消父項協同程式。

我們在本節說明了協同程式的取消和例外狀況行為,以及這兩者與協同程式階層的關聯。以下將進一步說明協同程式背後的正式概念,讓您瞭解如何將所有重要元素拼湊起來。

5. 協同程式概念

以非同步或並行方式執行作業時,您需要回答的問題包括作業執行方式、協同程式存留時間、作業取消或因發生錯誤而失敗時會發生的情況等。協同程式遵循結構化並行原則,因此當您使用機制組合在程式碼中使用協同程式時,必須回答上述問題。

工作

使用 launch() 函式啟動協同程式時,會傳回 Job 的例項。工作會保留協同程式的控制代碼或參照,以便您管理其生命週期。

val job = launch { ... }

工作可用於控制協同程式的生命週期 (應存留多長時間)。舉例來說,如果您不再需要執行該任務,則可取消協同程式。

job.cancel()

藉助工作,您可以查看工作是處於活動、已取消還是已完成狀態。如果協同程式和它啟動的任何協同程式均完成了所有作業,即代表工作完成。請注意,協同程式可能因為各種原因而完成,例如取消或因例外狀況而失敗,但此時工作仍視為已完成。

工作也會追蹤協同程式中父項與子項的關係。

工作階層

當協同程式啟動另一個協同程式時,從新協同程式傳回的工作稱為原始父項工作的子項。

val job = launch {
    ...            

    val childJob = launch { ... }

    ...
}

這些父項-子項關係形成工作階層,其中每個工作都可以啟動其他工作,依此類推。

此圖顯示工作的樹狀結構。階層的根目錄是父項工作。其中包含 3 個子項:子項 1 工作、子項 2 工作和子項 3 工作。子項 1 工作本身有兩個子項:子項 1a 工作和子項 1b 工作。子項 2 工作還有一個子項,稱爲子項 2a 工作。最後,子項 3 工作有兩個子項:子項 3a 工作和子項 3b 工作。

父項/子項關係會為子項、父項以及屬於同一父項的其他子項指定特定行為,因此十分重要。在前面的例子中,我們已透過天氣程式介紹這個行為。

  • 如果父項工作遭到取消,則子項工作也會取消。
  • 使用 job.cancel() 取消子項工作時,子項工作會終止,但不會取消其父項工作。
  • 如果工作因出現例外狀況而失敗,其父項也會因為該例外狀況而遭取消。這就是所謂的錯誤向上蔓延 (蔓延至父項、父項的父項等,依此類推)。

CoroutineScope

協同程式通常會啟動到 CoroutineScope 中。這樣可以確保我們沒有不受管理且丟失的協同程式,避免了資源的浪費。

launch()async()CoroutineScope 上的擴充功能函式。在該範圍內呼叫 launch()async(),以便在該範圍內建立新的協同程式。

CoroutineScope 與生命週期連結,以設定該範圍內協同程式的存留時間。如果範圍遭到取消,則系統會取消該範圍內的工作,並且會將取消作業套用到其子項工作。如果該範圍內的子項工作因發生例外狀況而失敗,則其他子項工作會被取消,父項工作會被取消,例外狀況也會重新擲回呼叫端。

Kotlin Playground 中的 CoroutineScope

在本程式碼研究室中,您使用 runBlocking() 來為程式提供 CoroutineScope。此外,您也學習了如何使用 coroutineScope { }getWeatherReport() 函式中建立新範圍。

Android 應用程式中的 CororineScope

Android 為具有明確定義的生命週期的實體提供協同程式範圍的支援,例如 Activity (lifecycleScope) 和 ViewModel (viewModelScope)。在這些範圍內啟動的協同程式必須遵循對應實體的生命週期,例如 ActivityViewModel

舉例來說,假設您在 Activity 中使用所提供的稱爲 lifecycleScope 的協同程式範圍啟動協同程式。如果活動遭到刪除,系統會取消 lifecycleScope,其所有子項協同程式也會自動取消。您只需決定協同程式遵循 Activity 的生命週期是否是您所需的行為。

在您將使用的 Race Tracker Android 應用程式中,您將學習如何將協同程式的範圍限制為可組合項的生命週期。

CoroutineScope 的實作詳細資料

如果您檢查原始碼如何在 Kotlin 協同程式庫中實作 CoroutineScope.kt,可以看到 CoroutineScope 已宣告為介面,其中包含 CoroutineContext 變數。

launch()async() 函式會在該範圍內建立新的子項協同程式,而子項也會沿用該範圍的結構定義。結構定義包含哪些內容?接下來要對此進行討論。

CoroutineContext

CoroutineContext 提供執行協同程式的結構定義的相關資訊。基本上,CoroutineContext 是儲存元素的地圖,其中各元素都有專屬索引鍵。以下是選填欄位,但列舉的一些範例可能包含在結構定義中:

  • 名稱 - 協同程式的名稱,用於唯一識別協同程式
  • 工作 - 控管協同程式的生命週期
  • 調度工具 - 將工作調派給適當的執行緒
  • 例外狀況處理常式 - 處理程式碼在協同程式中執行時擲回的例外狀況

結構定義中的每個元素都可以與 + 運算子一起附加。舉例來說,一個 CoroutineContext 可以定義如下:

Job() + Dispatchers.Main + exceptionHandler

由於未提供名稱,系統會使用預設的協同程式名稱。

在協同程式中,啟動新的協同程式時,子項協同程式會繼承父項協同程式的 CoroutineContext,但會取代剛建立的協同程式專屬的工作。您還可以覆寫從父項結構定義繼承的任何元素,方法是針對要有所不同的結構定義部分,將引數傳入 launch()async() 函式。

scope.launch(Dispatchers.Default) {
    ...
}

如要進一步瞭解 CoroutineContext 以及如何從父項沿用結構定義,請參閲 KotlinConf 視訊會議的談話內容

您已經看到文中提及調度工具數次。其角色是將工作調派或指派給適當的執行緒。接著就來詳細瞭解執行緒和調度工具。

調度工具

協同程式使用調度工具來決定用於執行的執行緒。您可以啟動執行緒,執行一些作業 (執行部分程式碼),並在沒有其他需要完成的作業時終止。

使用者啟動應用程式時,Android 系統會為應用程式建立新的處理程序和一個執行緒 (稱為主執行緒)。主執行緒會處理應用程式的許多重要作業,包括 Android 系統事件、繪製螢幕上的 UI、處理使用者輸入事件等。因此,您為應用程式編寫的大部分程式碼可能會在主執行緒上執行。

說到程式碼的執行緒行為時,就不能不理解以下兩個術語:阻斷非阻斷。一般函式會阻斷呼叫執行緒,直到相關工作完成為止。也就是說,在工作完成之前,這個函式不會產生呼叫執行緒,因此在此期間無法完成其他工作。相反地,阻斷程式碼則會產生呼叫執行緒,直到符合特定條件為止,所以您同時也能執行其他工作。您可以使用非同步函式執行非阻斷工作,因為這個函式會在自身的工作完成前傳回。

如果是 Android 應用程式,您應只在執行速度相當快時,才在主執行緒呼叫阻斷程式碼。這麼做的目標是維持主執行緒的非阻斷狀態,以便在新事件觸發時立即執行工作。這個主執行緒是活動的 UI 執行緒,負責執行 UI 繪製和 UI 相關事件。當螢幕上出現變更時,必須重新繪製 UI。對於螢幕上的動畫效果,必須經常重新繪製 UI,以便順利轉換畫面。如果主執行緒需要執行的工作區塊會長時間執行,則螢幕不會頻繁更新,而使用者會看到突兀的轉場 (稱為「卡頓」),或者應用程式會停止運作或較慢回應。

因此,我們需要將任何長時間執行的作業項目移出主執行緒,並在其他執行緒中處理。應用程式從單一主執行緒啟動,但您可以選擇建立多個執行緒來執行其他作業。這些額外的執行緒稱為工作站執行緒。對於長時間執行的任務而言,長時間阻斷某個背景工作執行緒完全沒問題,因為在此期間,主執行緒會處於非阻斷狀態,並且可以主動回應使用者。

以下是 Kotlin 提供的幾個內建調度程式:

  • Dispatchers.Main:使用這個調派程式在 Android 主執行緒上執行協同程式。這個調度工具主要用於處理 UI 更新和互動,以及執行快速作業。
  • Dispatchers.IO:這個調派程式已完成最佳化調整,以便在主執行緒外執行磁碟或網路 I/O。例如讀取或寫入檔案,以及執行任何網路作業。
  • Dispatchers.Default:如果未在結構定義中指定調度工具,系統在呼叫 launch()async() 時使用此預設調度工具。您可以使用這個調度器,在主執行緒外執行計算密集型作業。例如,處理點陣圖圖片檔。

在 Kotlin Playground 中嘗試下列範例,進一步瞭解協同程式調度工具。

  1. 使用下列程式碼取代 Kotlin Playground 中的任何程式碼:
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        launch {
            delay(1000)
            println("10 results found.")
        }
        println("Loading...")
    }
}
  1. 現在,透過呼叫 withContext() 納入已啟動協同程式的內容,從而變更執行協同程式的 CoroutineContext,並明確覆寫調度工具。改爲使用 Dispatchers.Default (而不是目前用於程式中其他協同程式程式碼的 Dispatchers.Main)。
...

fun main() {
    runBlocking {
        launch {
            withContext(Dispatchers.Default) {
                delay(1000)
                println("10 results found.")
            }
        }
        println("Loading...")
    }
}

您可以切換調度工具,因為 withContext() 本身是暫停函式。系統會使用新的 CoroutineContext 執行提供的程式碼區塊。新的結構定義來自父項工作的結構定義 (外部 launch() 區塊),但它會用此處指定的調度器 (Dispatchers.Default) 覆寫用於父項結構定義的調度器。透過這種方式,我們能夠從使用 Dispatchers.Main 執行作業轉向使用 Dispatchers.Default

  1. 執行程式。輸出內容應如下所示:
Loading...
10 results found.
  1. 新增輸出陳述式,呼叫 Thread.currentThread().name 即可查看您目前使用的執行緒。
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("${Thread.currentThread().name} - runBlocking function")
                launch {
            println("${Thread.currentThread().name} - launch function")
            withContext(Dispatchers.Default) {
                println("${Thread.currentThread().name} - withContext function")
                delay(1000)
                println("10 results found.")
            }
            println("${Thread.currentThread().name} - end of launch function")
        }
        println("Loading...")
    }
}
  1. 執行程式。輸出內容應如下所示:
main @coroutine#1 - runBlocking function
Loading...
main @coroutine#2 - launch function
DefaultDispatcher-worker-1 @coroutine#2 - withContext function
10 results found.
main @coroutine#2 - end of launch function

從此輸出內容中,可以觀察到大部分程式碼是在主執行緒的協同程式中執行的。不過,在 withContext(Dispatchers.Default) 區塊中,部分程式碼會在預設調度器背景工作執行緒 (非主執行緒) 的協同程式中執行。請注意,withContext() 傳回後,協同程式重新在主執行緒上運作 (如輸出陳述式所示:main @coroutine#2 - end of launch function)。這個範例說明修改用於協同程式的結構定義後,可以切換調度器。

如果在主執行緒上啟動了協同程式,而您想將特定作業移出主執行緒,可以使用 withContext 切換該作業的調度器。請根據作業類型,妥善選擇可供使用的調度器:MainDefaultIO。然後將作業指派給該目的專用的執行緒 (或稱為「執行緒集區」的執行緒群組)。協同程式可以自行暫停,而調度工具也會影響其繼續執行的方式。

請注意,使用 Room 和 Retrofit 等熱門程式庫時 (本單元和下一個單元中),如果程式庫程式碼已使用替代的協同程式調度工具 (例如 Dispatchers.IO.) 執行這項作業,您就無須自行切換調度工具。在這種情況下,這些程式庫顯示的 suspend 函式可能已對主執行緒無威脅,並且可以從主執行緒上執行的協同程式呼叫。程式庫本身會負責將調度工具切換為使用工作站執行緒的調度工具。

現在,您已大致瞭解協同程式的重要部分,以及 CoroutineScopeCoroutineContextCoroutineDispatcherJobs 在塑造協同程式的生命週期和行為方面所發揮的作用。

6. 結語

在協同程式這個具挑戰性的主題中,表現不錯!您瞭解到協同程式很實用,因為其執行作業可以暫停,釋放空間讓基礎執行緒執行其他作業,稍後還能能繼續執行該協同程式。這樣一來,您也可以在程式碼中執行並行作業。

Kotlin 中的協同程式程式碼遵循結構化並行的原則。根據預設,系統會依序執行作業,如果您想執行並行作業則需要明確説明 (例如使用 launch()async())。採用結構化並行時,您可以執行多項並行作業,然後將其合併為單一同步作業,其中並行是實作詳細資料。針對呼叫程式碼的唯一規定是位於暫停函式或協同程式中。除此之外,呼叫代碼的結構不需要考量並行詳細資料。這樣一來,更容易讀取和推理非同步程式碼。

結構化並行會追蹤應用程式中已啟動的每個協同程式,並確保它們不會遺失。協同程式可以有階層 — 工作可能會啟動子工作,進而啟動子工作。工作會保留協同程式中的父項-子項關係,並且您可以管控協同程式的生命週期。

執行協同程式時的四個常見作業包括:啟動、完成、取消和失敗。為了方便維持並行程式,結構化並行定義了相關原則,這些原則構成階層中常見作業的管理方式的基礎:

  1. 啟動:在一定範圍內啟動協同程式,該範圍定義了協同程式存留時間的邊界。
  2. 完成:所有子項工作完成後,工作才算完全完成。
  3. 取消:此作業需要向下傳播。如果取消協同程式,也應一併取消子項協同程式。
  4. 失敗:此作業應向上傳播。協同程式擲回例外狀況時,父項會取消所有子項、取消其本身,並將例外狀況套用到其父項,直至捕捉到失敗並進行處理為止。這樣可以確保程式碼中的所有錯誤都會正確回報,並且絕對不會出現遺失的狀況。

透過協同程式的實作練習,瞭解協同程式的概念,您現在可以更有自信地在 Android 應用程式中編寫並行程式碼。使用協同程式進行非同步程式設計時,更容易讀取和推理程式碼,在取消作業和發生例外狀況時運作更穩定,並且能夠為使用者提供更出色、回應更敏捷的體驗。

摘要

  • 協同程式可讓您撰寫可同時執行的長時間執行程式碼,而不必學習新的程式設計樣式。協同程式採用依序執行設計。
  • 協同程式遵循結構化並行原則,確保作業不會遺失,且作業的限定範圍會定義協同程式的存留時間。除非您明確要求執行並行作業 (例如使用 launch()async()),否則根據預設,您的程式碼會依序執行,並合作執行基礎事件迴圈。假設您呼叫某個函式,則該函式在傳回時應已完全完成其作業 (除非該函式因出現例外狀況而未能完全完成作業),而無論該函式在實作詳細資料中使用了多少個協同程式。
  • suspend 修飾符用於標示稍後可以暫停並繼續執行的函式。
  • 只能從其他暫停函式或協同程式呼叫 suspend 函式。
  • 您可以在 CoroutineScope 中使用 launch()async() 擴充功能函式來啟動新的協同程式。
  • 工作會管理協同程式的生命週期並維持父項-子項的關係,藉此確保結構化並行。
  • CoroutineScope 透過其工作控制協同程式的生命週期,並以遞迴方式強制取消工作,同時執行子項的其他規則,以及子項之子項的規則。
  • CoroutineContext 定義協同程式的行為,可包含工作和協同程式調度工具的參考資料。
  • 協同程式使用 CoroutineDispatcher 來決定用於執行的執行緒。

瞭解詳情