1. 事前準備
本程式碼研究室將介紹並行,這是 Android 開發人員為了提供優質使用者體驗而必須瞭解的一項重要技能。「並行」是指在應用程式中同時執行多項工作。舉例來說,應用程式可以從網路伺服器擷取資料,或是將使用者資料儲存在裝置中,同時回應使用者輸入事件,並據此更新 UI。
如要在應用程式中並行執行作業,請使用 Kotlin 協同程式。協同程式可暫停執行某區塊的程式碼,稍後再重新啟用,以便同時執行其他作業。有了協同程式,您就能輕鬆編寫非同步程式碼,不需要百分之百完成一項工作即可開始下一項工作,達到多工處理的成效。
本程式碼研究室會引導您逐步瞭解 Kotlin Playground 中的幾個基本範例,您可以練習使用協同程式,在使用非同步程式設計時更加得心應手。
必要條件
- 能夠使用
main()
函式建立基本的 Kotlin 程式 - Kotlin 語言的基本知識,包括函式和 lambda
建構項目
- 建構 Kotlin 小程式,學習及試用協同程式的基本功能
課程內容
- Kotlin 協同程式如何簡化非同步程式設計
- 結構化並行的目的及重要性
軟硬體需求
- 需要上網才能使用 Kotlin Playground
2. 同步程式碼
簡易程式
在同步程式碼中,一次只能處理一項概念工作。您可以將此視為有先後順序的線性路徑,必須先完成一項工作,才能開始執行下一項工作。以下是同步程式碼的範例。
- 開啟 Kotlin Playground。
- 將程式碼換成以下內容,編寫一個顯示晴天預報的程式。在
main()
函式中,先輸出文字Weather forecast
,然後輸出Sunny
。
fun main() {
println("Weather forecast")
println("Sunny")
}
- 執行程式碼。執行上述程式碼的輸出內容應如下所示:
Weather forecast Sunny
println()
是同步呼叫,因為系統會先完成文字輸出工作,再將執行作業移至下一行程式碼。由於 main()
中的每個函式呼叫都是同步的,因此整個 main()
函式都會保持同步。函式同不同步取決於組成部分。
同步函式只會在工作全部完成時傳回。因此,在執行 main()
中的最後一個輸出陳述式之後,所有作業都已完成,系統會傳回 main()
函式並結束程式。
新增延遲
假設要獲得晴天預報,您必須向遠端網路伺服器發出網路要求。請模擬網路要求,在程式碼的晴天預報輸出陳述式前新增延遲。
- 首先,在
main()
函式之前的程式碼頂端新增import kotlinx.coroutines.*
。系統會從 Kotlin 協同程式庫匯入您要使用的函式。 - 在程式碼中加入對
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()
會執行事件迴圈,在準備好時從中斷處繼續執行各項任務,因此可以一次處理多項任務。
- 將
main()
函式的現有內容移至runBlocking {}
呼叫的主體中。系統會在新的協同程式中執行runBlocking{}
的主體。
import kotlinx.coroutines.*
fun main() {
runBlocking {
println("Weather forecast")
delay(1000)
println("Sunny")
}
}
runBlocking()
具同步性質;必須等到 lambda 區塊內的所有工作完成之後才會傳回。這表示它會等待 delay()
呼叫中的作業完成 (一秒過後),然後繼續執行 Sunny
輸出陳述式。runBlocking()
函式中的所有作業都完成後,系統會傳回該函式並結束程式。
- 執行程式。輸出內容如下所示:
Weather forecast Sunny
輸出內容與先前相同。程式碼仍然處於同步狀態 - 以直線運作,且一次只能執行一項工作。然而差別在於由於存在延遲,執行時間會較長。
協同程式中的「co-」是指合作模式。這個程式碼會協同運作,在暫停等待時分享基礎事件迴圈,以允許其他作業同時執行。(「協同程式」中的「-routine」代表一組指示,例如函式)。在此範例中,協同程式會在達到 delay()
呼叫時暫停。協同程式暫停時,其他作業可在一秒內完成 (儘管在這個程式中,並沒有需要執行的其他作業)。延遲時間過後,協同程式會繼續執行,並繼續將 Sunny
輸出到畫面上。
暫停函式
如果執行網路要求以取得天氣資料的實際邏輯變得更加複雜,您可能需要將邏輯擷取到其自身的函式中。讓我們重構程式碼,看看效果如何。
- 擷取用於模擬天氣資料網路要求的程式碼,並移至其自身名為
printForecast()
的函式。從runBlocking()
代碼呼叫printForecast()
。
import kotlinx.coroutines.*
fun main() {
runBlocking {
println("Weather forecast")
printForecast()
}
}
fun printForecast() {
delay(1000)
println("Sunny")
}
如果現在執行該程式,系統會顯示與先前相同的編譯錯誤。只能從協同程式或其他暫停函式呼叫暫停函式,因此請將 printForecast()
定義為 suspend
函式。
- 在
printForecast()
函式宣告的fun
關鍵字前方新增suspend
修飾符,以將其定義為暫停函式。
import kotlinx.coroutines.*
fun main() {
runBlocking {
println("Weather forecast")
printForecast()
}
}
suspend fun printForecast() {
delay(1000)
println("Sunny")
}
請注意,delay()
是暫停函式,而您也已經將 printForecast()
設為暫停函式。
暫停函式與一般函式類似,但暫停函式可能會暫停,稍後再次繼續執行。如要這麼做,只能從提供這項功能的其他暫停函式呼叫暫停函式。
暫停函式可能包含零個或多個暫停點。暫停點是指函式中可暫停執行函式的位置。繼續執行作業後,它會從程式碼中上次中斷的地方開始,並繼續執行函數的其餘部分。
- 練習在
printForecast()
函式的宣告下方新增其他暫停函式。呼叫這個新的暫停函式printTemperature()
。您可以假定此函式發出了網路要求,以取得天氣預報的溫度資料。
在此函式中,同樣將執行作業延遲 1000
毫秒,然後將溫度值輸出到畫面上,例如攝氏 30
度。您可以使用逸出順序 "\u00b0"
輸出度數符號 °
。
suspend fun printTemperature() {
delay(1000)
println("30\u00b0C")
}
- 在
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")
}
- 執行程式。輸出內容應如下所示:
Weather forecast Sunny 30°C
在這個程式碼中,協同程式會先暫停 printForecast()
暫停函式的延遲,一秒延遲過後繼續執行。Sunny
文字會輸出到畫面上。printForecast()
函式會傳回呼叫端。
接下來,系統會呼叫 printTemperature()
函式。協同程式會在達到 delay()
呼叫時暫停,一秒後繼續執行,並將溫度值輸出到畫面上。printTemperature()
函式已完成所有作業並傳回。
在 runBlocking()
主體中,沒有其他可執行的工作,因此 runBlocking()
函式會傳回,程式結束。
如先前所述,runBlocking()
是同步函式,系統會依序呼叫主體中的每次呼叫。請注意,設計完善的暫停函式在完成所有作業後才會傳回。因此,這些暫停函式會依序執行。
- (選用) 如果您想查看延遲執行這個程式需要多長時間,可以將程式碼納入
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()
),否則根據預設,您的程式碼會依序執行,並與基礎事件迴圈合作。這裡的假設是,如果您呼叫某個函式,那麼無論該函式在實作詳細資料中使用了多少個協同程式,都應在徹底完成其工作後再傳回。即使函式因發生例外狀況而執行失敗,一旦系統擲回例外狀況後,函式就不會再有其他待處理的任務。因此,無論是擲回例外狀況還是順利完成工作,只要控制流程從函數返回之後,所有工作就都會完成。
- 從前幾步的程式碼著手,使用
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")
}
- 執行程式。輸出內容如下所示:
Weather forecast Sunny 30°C
輸出內容相同,但您可能已注意到執行程式的速度有所提升。先前,您必須等到 printForecast()
暫停函式執行完畢,才能繼續執行 printTemperature()
函式。現在 printForecast()
和 printTemperature()
能以並行方式執行,因為兩者皆位於單獨的協同程式中。
對 launch { printForecast() }
的呼叫可在 printForecast()
中的所有工作完成前傳回。這正是協同程式的絕妙之處。您可以移至下一個 launch()
呼叫,啟動下一個協同程式。同樣地,launch { printTemperature() }
也會在所有工作完成前傳回。
- (選用) 如要查看程式現在的執行速度,您可以新增
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()
程式碼結束之前新增其他輸出陳述式,您認為會發生什麼情況?該訊息顯示在輸出內容中的哪個位置?
- 修改
runBlocking()
程式碼,在該區塊結束之前新增其他輸出陳述式。
...
fun main() {
runBlocking {
println("Weather forecast")
launch {
printForecast()
}
launch {
printTemperature()
}
println("Have a good day!")
}
}
...
- 執行程式,輸出內容如下所示:
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
物件上的結果。
- 請先變更暫停函式以傳回
String
,而不是輸出天氣預報和溫度資料。將函式名稱從printForecast()
和printTemperature()
更新為getForecast()
和getTemperature()
。
...
suspend fun getForecast(): String {
delay(1000)
return "Sunny"
}
suspend fun getTemperature(): String {
delay(1000)
return "30\u00b0C"
}
- 修改
runBlocking()
程式碼,使其針對兩個協同程式使用async()
,而非launch()
。將每個async()
呼叫的傳回值儲存在名為forecast
和temperature
的變數中,這類變數是保留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()
}
...
}
}
...
- 在協同程式中,兩次
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"
}
- 執行程式,輸出內容應如下所示:
Weather forecast Sunny 30°C Have a good day!
真棒!您建立了兩個同時執行的協同程式,用於取得天氣預報和溫度資料。每個協同程式完成工作後都會傳回值。然後將兩個傳回值合併為單一輸出陳述式:Sunny 30°C
。
平行分解
進一步以此天氣預報爲例,看看協同程式在作業的平行分解中的用途有多大。並行分解需要將問題分解為可以平行解決的較小的子工作。子工作的結果準備就緒後,您就可以將這些結果合併到最終結果中。
在程式碼中,從 runBlocking()
的主體將天氣報告的邏輯擷取為單一 getWeatherReport()
函式,該函式會傳回 Sunny 30°C
的合併字串。
- 在程式碼中定義新的暫停函式
getWeatherReport()
。 - 將函式設定為相當於使用空白 lambda 區塊呼叫
coroutineScope{}
函式的結果,該區塊最終包含取得天氣報告的邏輯。
...
suspend fun getWeatherReport() = coroutineScope {
}
...
coroutineScope{}
會為這項天氣報告工作確定本地範圍。在這個範圍內啟動的協同程式會分組到這個範圍內,這對於取消作業和您稍後瞭解的例外狀況也會造成影響。
- 在
coroutineScope()
的主體中,使用async()
建立兩個新的協同程式,分別擷取天氣預報和溫度資料。合併這兩個協同程式的結果來建立天氣報告字串。方法是在async()
呼叫傳回的每個Deferred
物件上呼叫await()
。這樣可確保所有協同程式完成作業並傳回其結果,然後再傳回這個函式。
...
suspend fun getWeatherReport() = coroutineScope {
val forecast = async { getForecast() }
val temperature = async { getTemperature() }
"${forecast.await()} ${temperature.await()}"
}
...
- 從
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"
}
- 執行程式後,顯示以下輸出內容:
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
的初始值改為非零的數字。但隨著程式碼變得越來越複雜,在某些時候,您會無法預測及避免所有例外狀況發生。
試想,如果其中一個協同程式因例外狀況而無法執行,會發生什麼事?請修改天氣程式的程式碼來一探究竟。
協同程式的例外狀況
- 我們從上一節的天氣程式著手。
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"
}
在其中一個暫停函式中刻意擲回例外狀況,即可查看效果。從模擬伺服器擷取資料時,這可模擬發生了非預期錯誤。
- 在
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()
函式完成工作之前會發生例外狀況。
- 執行程式即可查看結果。
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
}
}
}
...
為了要能更順利地處理例外狀況,請修改天氣程式來擷取您先前新增的例外狀況,然後將例外狀況輸出到輸出結果中。
- 在
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"
}
- 執行程式,現在即可妥善處理錯誤,而程式也可順利執行完畢。
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
」,讓使用者瞭解無法使用天氣報告。
請注意,這個行為代表如果無法取得溫度,就不會產生任何天氣報告,即便已擷取到有效的預測值也一樣。
視您希望的程式行為而定,或許有其他方法能處理天氣程式中的例外狀況。
- 移動錯誤處理程序,讓 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"
}
- 執行程式。
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
) 而非確切溫度,那麼,我們就要將目前負責取得溫度資料的協同程式取消。
- 首先,我們從下方的初始程式碼著手 (沒有取消作業)。
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"
}
- 一段時間後,請將擷取溫度資訊的協同程式取消,讓天氣報告只顯示預測資料。請將
coroutineScope
區塊的傳回值變更為僅限天氣預報字串。
...
suspend fun getWeatherReport() = coroutineScope {
val forecast = async { getForecast() }
val temperature = async { getTemperature() }
delay(200)
temperature.cancel()
"${forecast.await()}"
}
...
- 執行程式。現在輸出結果會如下方所示,您會看到天氣報告中只有
Sunny
的天氣預報,但沒有溫度資訊,因為這個協同程式已經取消。
Weather forecast Sunny Have a good day!
您在這裡學到的是,協同程式可以取消,但不會影響同一範圍內的其他協同程式,也不會取消父項協同程式。
我們在本節說明了協同程式的取消和例外狀況行為,以及這兩者與協同程式階層的關聯。以下將進一步說明協同程式背後的正式概念,讓您瞭解如何將所有重要元素拼湊起來。
5. 協同程式概念
以非同步或並行方式執行作業時,您需要回答的問題包括作業執行方式、協同程式存留時間、作業取消或因發生錯誤而失敗時會發生的情況等。協同程式遵循結構化並行原則,因此當您使用機制組合在程式碼中使用協同程式時,必須回答上述問題。
工作
使用 launch()
函式啟動協同程式時,會傳回 Job
的例項。工作會保留協同程式的控制代碼或參照,以便您管理其生命週期。
val job = launch { ... }
工作可用於控制協同程式的生命週期 (應存留多長時間)。舉例來說,如果您不再需要執行該任務,則可取消協同程式。
job.cancel()
藉助工作,您可以查看工作是處於活動、已取消還是已完成狀態。如果協同程式和它啟動的任何協同程式均完成了所有作業,即代表工作完成。請注意,協同程式可能因為各種原因而完成,例如取消或因例外狀況而失敗,但此時工作仍視為已完成。
工作也會追蹤協同程式中父項與子項的關係。
工作階層
當協同程式啟動另一個協同程式時,從新協同程式傳回的工作稱為原始父項工作的子項。
val job = launch {
...
val childJob = launch { ... }
...
}
這些父項-子項關係形成工作階層,其中每個工作都可以啟動其他工作,依此類推。
父項/子項關係會為子項、父項以及屬於同一父項的其他子項指定特定行為,因此十分重要。在前面的例子中,我們已透過天氣程式介紹這個行為。
- 如果父項工作遭到取消,則子項工作也會取消。
- 使用
job.cancel()
取消子項工作時,子項工作會終止,但不會取消其父項工作。 - 如果工作因出現例外狀況而失敗,其父項也會因為該例外狀況而遭取消。這就是所謂的錯誤向上蔓延 (蔓延至父項、父項的父項等,依此類推)。
CoroutineScope
協同程式通常會啟動到 CoroutineScope
中。這樣可以確保我們沒有不受管理且丟失的協同程式,避免了資源的浪費。
launch()
和 async()
是 CoroutineScope
上的擴充功能函式。在該範圍內呼叫 launch()
或 async()
,以便在該範圍內建立新的協同程式。
CoroutineScope
與生命週期連結,以設定該範圍內協同程式的存留時間。如果範圍遭到取消,則系統會取消該範圍內的工作,並且會將取消作業套用到其子項工作。如果該範圍內的子項工作因發生例外狀況而失敗,則其他子項工作會被取消,父項工作會被取消,例外狀況也會重新擲回呼叫端。
Kotlin Playground 中的 CoroutineScope
在本程式碼研究室中,您使用 runBlocking()
來為程式提供 CoroutineScope
。此外,您也學習了如何使用 coroutineScope { }
在 getWeatherReport()
函式中建立新範圍。
Android 應用程式中的 CororineScope
Android 為具有明確定義的生命週期的實體提供協同程式範圍的支援,例如 Activity
(lifecycleScope
) 和 ViewModel
(viewModelScope
)。在這些範圍內啟動的協同程式必須遵循對應實體的生命週期,例如 Activity
或 ViewModel
。
舉例來說,假設您在 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 中嘗試下列範例,進一步瞭解協同程式調度工具。
- 使用下列程式碼取代 Kotlin Playground 中的任何程式碼:
import kotlinx.coroutines.*
fun main() {
runBlocking {
launch {
delay(1000)
println("10 results found.")
}
println("Loading...")
}
}
- 現在,透過呼叫
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
。
- 執行程式。輸出內容應如下所示:
Loading... 10 results found.
- 新增輸出陳述式,呼叫
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...")
}
}
- 執行程式。輸出內容應如下所示:
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
切換該作業的調度器。請根據作業類型,妥善選擇可供使用的調度器:Main
、Default
和 IO
。然後將作業指派給該目的專用的執行緒 (或稱為「執行緒集區」的執行緒群組)。協同程式可以自行暫停,而調度工具也會影響其繼續執行的方式。
請注意,使用 Room 和 Retrofit 等熱門程式庫時 (本單元和下一個單元中),如果程式庫程式碼已使用替代的協同程式調度工具 (例如 Dispatchers.IO.
) 執行這項作業,您就無須自行切換調度工具。在這種情況下,這些程式庫顯示的 suspend
函式可能已對主執行緒無威脅,並且可以從主執行緒上執行的協同程式呼叫。程式庫本身會負責將調度工具切換為使用工作站執行緒的調度工具。
現在,您已大致瞭解協同程式的重要部分,以及 CoroutineScope
、CoroutineContext
、CoroutineDispatcher
和 Jobs
在塑造協同程式的生命週期和行為方面所發揮的作用。
6. 結語
在協同程式這個具挑戰性的主題中,表現不錯!您瞭解到協同程式很實用,因為其執行作業可以暫停,釋放空間讓基礎執行緒執行其他作業,稍後還能能繼續執行該協同程式。這樣一來,您也可以在程式碼中執行並行作業。
Kotlin 中的協同程式程式碼遵循結構化並行的原則。根據預設,系統會依序執行作業,如果您想執行並行作業則需要明確説明 (例如使用 launch()
或 async()
)。採用結構化並行時,您可以執行多項並行作業,然後將其合併為單一同步作業,其中並行是實作詳細資料。針對呼叫程式碼的唯一規定是位於暫停函式或協同程式中。除此之外,呼叫代碼的結構不需要考量並行詳細資料。這樣一來,更容易讀取和推理非同步程式碼。
結構化並行會追蹤應用程式中已啟動的每個協同程式,並確保它們不會遺失。協同程式可以有階層 — 工作可能會啟動子工作,進而啟動子工作。工作會保留協同程式中的父項-子項關係,並且您可以管控協同程式的生命週期。
執行協同程式時的四個常見作業包括:啟動、完成、取消和失敗。為了方便維持並行程式,結構化並行定義了相關原則,這些原則構成階層中常見作業的管理方式的基礎:
- 啟動:在一定範圍內啟動協同程式,該範圍定義了協同程式存留時間的邊界。
- 完成:所有子項工作完成後,工作才算完全完成。
- 取消:此作業需要向下傳播。如果取消協同程式,也應一併取消子項協同程式。
- 失敗:此作業應向上傳播。協同程式擲回例外狀況時,父項會取消所有子項、取消其本身,並將例外狀況套用到其父項,直至捕捉到失敗並進行處理為止。這樣可以確保程式碼中的所有錯誤都會正確回報,並且絕對不會出現遺失的狀況。
透過協同程式的實作練習,瞭解協同程式的概念,您現在可以更有自信地在 Android 應用程式中編寫並行程式碼。使用協同程式進行非同步程式設計時,更容易讀取和推理程式碼,在取消作業和發生例外狀況時運作更穩定,並且能夠為使用者提供更出色、回應更敏捷的體驗。
摘要
- 協同程式可讓您撰寫可同時執行的長時間執行程式碼,而不必學習新的程式設計樣式。協同程式採用依序執行設計。
- 協同程式遵循結構化並行原則,確保作業不會遺失,且作業的限定範圍會定義協同程式的存留時間。除非您明確要求執行並行作業 (例如使用
launch()
或async()
),否則根據預設,您的程式碼會依序執行,並合作執行基礎事件迴圈。假設您呼叫某個函式,則該函式在傳回時應已完全完成其作業 (除非該函式因出現例外狀況而未能完全完成作業),而無論該函式在實作詳細資料中使用了多少個協同程式。 suspend
修飾符用於標示稍後可以暫停並繼續執行的函式。- 只能從其他暫停函式或協同程式呼叫
suspend
函式。 - 您可以在
CoroutineScope
中使用launch()
或async()
擴充功能函式來啟動新的協同程式。 - 工作會管理協同程式的生命週期並維持父項-子項的關係,藉此確保結構化並行。
CoroutineScope
透過其工作控制協同程式的生命週期,並以遞迴方式強制取消工作,同時執行子項的其他規則,以及子項之子項的規則。CoroutineContext
定義協同程式的行為,可包含工作和協同程式調度工具的參考資料。- 協同程式使用
CoroutineDispatcher
來決定用於執行的執行緒。