1. 事前準備
在先前的程式碼研究室中,您已瞭解協同程式。您使用了 Kotlin Playground,以協同程式撰寫並行程式碼。在本程式碼研究室中,您將在 Android 應用程式和其生命週期中應用協同程式知識。您將新增程式碼,以並行方式啟動新的協同程式,並瞭解如何測試協同程式。
必要條件
- 具備 Kotlin 語言的基本知識,包括函式和 lambda
- 能在 Jetpack Compose 中建構版面配置
- 能以 Kotlin 撰寫單元測試 (請參閱「編寫 ViewModel 的單元測試」程式碼研究室)
- 執行緒和並行的運作方式
- 協同程式和 CoroutineScope 的基本知識
建構項目
- 模擬兩個玩家之間賽跑進度的 Race Tracker 應用程式。您可以將此應用程式視為實驗機會,進一步瞭解協同程式的不同層面。
課程內容
- 在 Android 應用程式生命週期中使用協同程式。
- 結構化並行原則。
- 如何撰寫用於測試協同程式的單元測試。
軟硬體需求
- 最新的 Android Studio 穩定版
2. 應用程式總覽
Race Tracker 應用程式會模擬兩位參加賽跑的玩家。應用程式 UI 包含「Start」/「Pause」和「Reset」這兩個按鈕,以及兩條顯示玩家進度的進度列。玩家 1 和 2 將以不同速度「賽跑」。賽跑開始時,玩家 2 的跑步速度是玩家 1 的兩倍。
您將在應用程式中使用協同程式,確保以下事項:
- 兩位玩家同時「賽跑」。
- 應用程式採用回應式 UI,且進度列會在賽跑期間遞增。
範例程式碼包含 Race Tracker 應用程式的 UI 程式碼。程式碼研究室此部分的重點,是要協助您熟悉 Android 應用程式中的 Kotlin 協同程式。
取得範例程式碼
如要開始使用,請先下載範例程式碼:
或者,您也可以複製 GitHub 存放區的程式碼:
$ git clone https://github.com/google-developer-training/basic-android-kotlin-compose-training-race-tracker.git $ cd basic-android-kotlin-compose-training-race-tracker $ git checkout starter
您可以瀏覽 Race Tracker GitHub 存放區中的範例程式碼。
範例程式碼逐步操作說明
點選「Start」按鈕即可開始賽跑。在賽跑期間,「Start」按鈕的文字會變成「Pause」。
您隨時可以使用此按鈕暫停或繼續比賽。
賽跑開始後,您可以透過狀態指標查看每位玩家的進度。StatusIndicator
可組合函式會顯示每位玩家的進度狀態,並使用 LinearProgressIndicator
可組合函式顯示進度列。您將使用協同程式更新進度值。
RaceParticipant
提供進度遞增的資料。此類別是每位玩家的狀態容器,包含參與者的 name
、達成即結束賽跑的 maxProgress
、每次進度遞增之間的延遲期間、賽跑時的 currentProgress
,以及 initialProgress
。
在下一節中,您將使用協同程式實作模擬賽跑進度的功能,而不會阻斷應用程式 UI。
3. 實作賽跑進度
您需要使用 run()
函式比較玩家的 currentProgress
與 maxProgress
,反映賽跑的整體進度,並使用 delay()
暫停函式,在每次進度遞增之間增加些微延遲。此函式會呼叫另一個暫停函式 delay()
,所以此函式必須為 suspend
函式。此外,您稍後會在程式碼研究室中從協同程式呼叫此函式。請按照下列步驟實作此函式:
- 開啟
RaceParticipant
類別,這是範例程式碼的一部分。 - 在
RaceParticipant
類別中,定義名為run()
的新suspend
函式。
class RaceParticipant(
...
) {
var currentProgress by mutableStateOf(initialProgress)
private set
suspend fun run() {
}
...
}
- 如要模擬賽跑進度,請新增
while
迴圈,這個迴圈要持續執行至currentProgress
達到maxProgress
值 (設為100
) 為止。
class RaceParticipant(
...
val maxProgress: Int = 100,
...
) {
var currentProgress by mutableStateOf(initialProgress)
private set
suspend fun run() {
while (currentProgress < maxProgress) {
}
}
...
}
currentProgress
的值已設為initialProgress
,也就是0
。如要模擬參與者的進度,請使用 while 迴圈內的progressIncrement
屬性值來遞增currentProgress
值。請注意,progressIncrement
的預設值是1
。
class RaceParticipant(
...
val maxProgress: Int = 100,
...
private val progressIncrement: Int = 1,
private val initialProgress: Int = 0
) {
...
var currentProgress by mutableStateOf(initialProgress)
private set
suspend fun run() {
while (currentProgress < maxProgress) {
currentProgress += progressIncrement
}
}
}
- 如要模擬賽跑中的不同進度間隔,請使用
delay()
暫停函式。將progressDelayMillis
屬性的值做為引數傳遞。
suspend fun run() {
while (currentProgress < maxProgress) {
delay(progressDelayMillis)
currentProgress += progressIncrement
}
}
查看剛新增的程式碼時,您將在 Android Studio 中對 delay()
函式的呼叫左側看到一個圖示,如以下螢幕截圖所示:
此圖示表示函式可能會在暫停點暫停,並於稍後繼續。
協同程式在等待延遲期間結束時,不會阻斷主執行緒,如下圖所示:
使用所需的間隔值呼叫 delay()
函式後,協同程式會暫停 (但不會阻斷) 執行作業。完成延遲後,協同程式就會繼續執行,並更新 currentProgress
屬性的值。
4. 開始賽跑
使用者按下「Start」按鈕時,您需要在兩個玩家例項上分別呼叫 run()
暫停函式,才能「開始賽跑」。為此,您需要啟動協同程式來呼叫 run()
函式。
啟動協同程式來觸發賽跑時,需要同時為兩位參與者確保下列層面:
- 點選「Start」按鈕,也就是啟動協同程式後,雙方便會起跑。
- 點選「Pause」或「Reset」按鈕,也就是取消協同程式後,雙方便會暫停或停止奔跑。
- 使用者關閉應用程式後,系統會妥善管理取消作業。也就是說,所有協同程式都會遭到取消,並繫結至生命週期。
您在第一個程式碼研究室中學到,暫停函式只能從另一個暫停函式呼叫。如要從可組合函式內部安全地呼叫暫停函式,您需要使用 LaunchedEffect()
可組合函式。只要仍在組成中,LaunchedEffect()
可組合函式就會執行所提供的暫停函式。您可以使用 LaunchedEffect()
可組合函式完成以下所有工作:
- 如果使用
LaunchedEffect()
可組合函式,就能安全地從可組合函式呼叫暫停函式。 LaunchedEffect()
函式進入組成時,會啟動協同程式,並以參數形式傳遞程式碼區塊。只要這個函式仍在組成中,就會執行所提供的暫停函式。使用者在 Race Tracker 應用程式中點選「Start」按鈕後,LaunchedEffect()
便會進入組成,並啟動協同程式來更新進度。LaunchedEffect()
離開組成時,協同程式就會取消。在應用程式中,如果使用者點選「Reset」/「Pause」按鈕,系統便會從組成中移除LaunchedEffect()
,並取消基礎協同程式。
在 Race Tracker 應用程式中,您不必明確提供調度器,因為 LaunchedEffect()
會負責相關作業。
如要開始賽跑,請為每位參賽者呼叫 run()
函式,然後執行下列步驟:
- 開啟位於
com.example.racetracker.ui
套件中的RaceTrackerApp.kt
檔案。 - 前往
RaceTrackerApp()
可組合函式,在raceInProgress
定義後方的該行程式碼中,新增對LaunchedEffect()
可組合函式的呼叫。
@Composable
fun RaceTrackerApp() {
...
var raceInProgress by remember { mutableStateOf(false) }
LaunchedEffect {
}
RaceTrackerScreen(...)
}
- 為確保在
playerOne
或playerTwo
的例項遭取代為不同例項時,LaunchedEffect()
需取消並重新啟動基礎協同程式,請將playerOne
和playerTwo
物件新增為LaunchedEffect
的key
。就像Text()
可組合函式在文字值變更時的重組方式,如果LaunchedEffect()
的任何鍵引數有所變更,系統會取消並重新啟動基礎協同程式。
LaunchedEffect(playerOne, playerTwo) {
}
- 新增
playerOne.run()
和playerTwo.run()
函式的呼叫。
@Composable
fun RaceTrackerApp() {
...
var raceInProgress by remember { mutableStateOf(false) }
LaunchedEffect(playerOne, playerTwo) {
playerOne.run()
playerTwo.run()
}
RaceTrackerScreen(...)
}
- 使用
if
條件納入LaunchedEffect()
區塊。此狀態的初始值為false
。當使用者點選「Start」按鈕,LaunchedEffect()
也開始執行後,raceInProgress
狀態的值會更新為true
。
if (raceInProgress) {
LaunchedEffect(playerOne, playerTwo) {
playerOne.run()
playerTwo.run()
}
}
- 將
raceInProgress
標記更新為false
,即可結束賽跑。使用者也按下「Pause」後,此值會設為false
。將此值設為false
時,LaunchedEffect()
可確保取消所有啟動的協同程式。
LaunchedEffect(playerOne, playerTwo) {
playerOne.run()
playerTwo.run()
raceInProgress = false
}
- 執行應用程式,然後點選「Start」。您應該會看到玩家 1 先跑完之後,玩家 2 才開始奔跑,如以下影片所示:
看來這場賽跑並不公平!在下一節中,您將瞭解如何啟動並行工作,讓兩位玩家同時起跑,並在瞭解概念後實作此行為。
5. 結構化並行
使用協同程式撰寫程式碼的方式,稱為結構化並行。此程式設計風格可提升程式碼的可讀性和開發效率。結構化並行的概念就是為協同程式設定階層:工作可能會啟動子工作,而子工作可能會再啟動子工作。此階層的單位稱為協同程式範圍。協同程式範圍應一律與生命週期建立關聯。
協同程式 API 的設計遵循結構化並行的架構。您無法透過未標示為暫停的函式呼叫暫停函式。此限制確保您可從協同程式建構工具 (例如 launch
) 呼叫暫停函式。這些建構工具會轉而繫結至 CoroutineScope
。
6. 啟動並行工作
- 如要同時讓兩位參與者同時起跑,則需啟動兩個獨立的協同程式,並將每個呼叫移至這些協同程式中的
run()
函式。使用launch
建構工具納入對playerOne.run()
的呼叫。
LaunchedEffect(playerOne, playerTwo) {
launch { playerOne.run() }
playerTwo.run()
raceInProgress = false
}
- 同樣地,使用
launch
建構工具納入對playerTwo.run()
函式的呼叫。完成此變更後,應用程式會並行啟動兩個協同程式。現在兩位玩家可以同時奔跑。
LaunchedEffect(playerOne, playerTwo) {
launch { playerOne.run() }
launch { playerTwo.run() }
raceInProgress = false
}
- 執行應用程式,然後點選「Start」。在您預期賽跑要開始時,按鈕文字會無預警地立即變回「Start」。
兩位玩家均完成賽跑後,Race Tracker 應用程式應將「Pause」按鈕的文字重設回「Start」。然而,現在應用程式在啟動協同程式後,就會立即更新 raceInProgress
,不會等待玩家完成賽跑:
LaunchedEffect(playerOne, playerTwo) {
launch {playerOne.run() }
launch {playerTwo.run() }
raceInProgress = false // This will update the state immediately, without waiting for players to finish run() execution.
}
raceInProgress
標記會立即更新,原因如下:
launch
建構工具函式會啟動協同程式來執行playerOne.run()
並立即傳回,以執行程式碼區塊中的下一行。- 第二個執行
playerTwo.run()
函式的launch
建構工具函式也會採用相同的執行流程。 - 系統傳回第二個
launch
建構工具時,就會立即更新raceInProgress
標記。按鈕文字會馬上變更為「Start」,而賽跑不會開始。
協同程式範圍
coroutineScope
暫停函式會建立 CoroutineScope
,並呼叫目前範圍指定的暫停區塊。範圍繼承 LaunchedEffect()
範圍的 coroutineContext
。
指定區塊及所有子項協同程式都完成後,範圍便會立即傳回。在 RaceTracker
應用程式中,當兩個參與者物件完成執行 run()
函式,範圍便會立即傳回。
- 如要確保
playerOne
和playerTwo
的run()
函式在更新raceInProgress
標記前執行完畢,請使用coroutineScope
區塊納入這兩個啟動建構工具。
LaunchedEffect(playerOne, playerTwo) {
coroutineScope {
launch { playerOne.run() }
launch { playerTwo.run() }
}
raceInProgress = false
}
- 在模擬器/Android 裝置上執行應用程式。您應看到以下畫面:
- 點選「Start」按鈕。玩家 2 跑得比玩家 1 快。賽跑結束後,當兩位玩家都達到 100% 的進度時,「Pause」按鈕的標籤就會變更為「Start」。您可以點選「Reset」按鈕重設賽跑,並重新執行模擬作業。賽跑結果會在以下影片中顯示。
執行流程如下圖所示:
LaunchedEffect()
區塊執行時,控制項會轉移至coroutineScope{..}
區塊。coroutineScope
區塊會並行啟動兩個協同程式,並等候這些協同程式執行完畢。- 執行完畢後,
raceInProgress
標記便會更新。
當區塊中所有程式碼皆執行完畢後,coroutineScope
區塊才會傳回並繼續。對於區塊外的程式碼,是否存在並行就只是一項實作細節。此程式碼樣式提供並行程式設計的結構化方法,稱為結構化並行。
如果在賽跑結束後點選「Reset」按鈕,系統就會取消協同程式,兩位玩家的進度也會重設為 0
。
如要瞭解系統如何在使用者點選「Reset」按鈕時取消協同程式,請按照下列步驟操作:
- 將
run()
方法的主體納入 try-catch 區塊,如以下程式碼所示。
suspend fun run() {
try {
while (currentProgress < maxProgress) {
delay(progressDelayMillis)
currentProgress += progressIncrement
}
} catch (e: CancellationException) {
Log.e("RaceParticipant", "$name: ${e.message}")
throw e // Always re-throw CancellationException.
}
}
- 執行應用程式,然後點選「Start」按鈕。
- 進度遞增後,點選「Reset」按鈕。
- 確認 Logcat 中已顯示下列訊息:
Player 1: StandaloneCoroutine was cancelled Player 2: StandaloneCoroutine was cancelled
7. 撰寫用來測試協同程式的單元測試
使用協同程式的單元測試程式碼時需要特別留意,因為其執行作業可能並不同步,且會在多個執行緒中執行。
如要呼叫測試中的暫停函式,您需要位於協同程式中。由於 JUnit 測試函式本身不是暫停函式,您需要使用 runTest
協同程式建構工具。此建構工具是 kotlinx-coroutines-test
程式庫的一部分,用途是執行測試。建構工具會在新協同程式中執行測試主體。
由於 runTest
是 kotlinx-coroutines-test
程式庫的一部分,您需要新增其依附元件。
如要新增依附元件,請完成下列步驟:
- 在「Project」窗格的
app
目錄中,開啟應用程式模組的build.gradle.kts
檔案。
- 在檔案中向下捲動,找出
dependencies{}
區塊。 - 使用
testImplementation
設定,將依附元件新增至kotlinx-coroutines-test
程式庫。
plugins {
...
}
android {
...
}
dependencies {
...
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4")
}
- 在 build.gradle.kts 檔案頂端的通知列中,按一下「Sync Now」,讓系統完成匯入和建構程序,如下方螢幕截圖所示:
建構完畢後,即可開始撰寫測試。
實作單元測試來開始和結束賽跑
為確保在賽跑的不同階段正確更新賽跑進度,單元測試需涵蓋不同情況。此程式碼研究室涵蓋兩種情況:
- 賽跑開始後的進度。
- 賽跑結束後的進度。
如要在賽跑開始後確認賽跑進度是否正確更新,您需要在 raceParticipant.progressDelayMillis
期間過後,宣告目前進度設為 1。
如要實作測試情況,請按照下列步驟操作:
- 前往測試來源集下方的
RaceParticipantTest.kt
檔案。 - 如要定義測試,請在
raceParticipant
定義之後建立raceParticipant_RaceStarted_ProgressUpdated()
函式,並使用@Test
註解加上備註。由於測試區塊需放在runTest
建構工具中,因此請使用運算式語法傳回runTest()
區塊做為測試結果。
class RaceParticipantTest {
private val raceParticipant = RaceParticipant(
...
)
@Test
fun raceParticipant_RaceStarted_ProgressUpdated() = runTest {
}
}
- 新增唯讀
expectedProgress
變數,並設為1
。
@Test
fun raceParticipant_RaceStarted_ProgressUpdated() = runTest {
val expectedProgress = 1
}
- 如要模擬賽跑開始的情況,請使用
launch
建構工具啟動新的協同程式,並呼叫raceParticipant.run()
函式。
@Test
fun raceParticipant_RaceStarted_ProgressUpdated() = runTest {
val expectedProgress = 1
launch { raceParticipant.run() }
}
raceParticipant.progressDelayMillis
屬性的值會決定要等待多久才更新賽跑進度。如要在 progressDelayMillis
時間過後測試進度,您需要在測試中新增某種延遲形式。
- 依照
raceParticipant.progressDelayMillis
的值,使用advanceTimeBy()
輔助函式將時間往後調。advanceTimeBy()
函式有助於縮短測試執行時間。
@Test
fun raceParticipant_RaceStarted_ProgressUpdated() = runTest {
val expectedProgress = 1
launch { raceParticipant.run() }
advanceTimeBy(raceParticipant.progressDelayMillis)
}
- 由於
advanceTimeBy()
不會執行指定時間內排定的工作,因此您需要呼叫runCurrent()
函式。此函式會在目前時間執行任何待處理工作。
@Test
fun raceParticipant_RaceStarted_ProgressUpdated() = runTest {
val expectedProgress = 1
launch { raceParticipant.run() }
advanceTimeBy(raceParticipant.progressDelayMillis)
runCurrent()
}
- 為確保進度順利更新,請新增對
assertEquals()
函式的呼叫,檢查raceParticipant.currentProgress
屬性的值是否與expectedProgress
變數的值相符。
@Test
fun raceParticipant_RaceStarted_ProgressUpdated() = runTest {
val expectedProgress = 1
launch { raceParticipant.run() }
advanceTimeBy(raceParticipant.progressDelayMillis)
runCurrent()
assertEquals(expectedProgress, raceParticipant.currentProgress)
}
- 執行測試,確認通過檢查。
如要在賽跑結束後檢查賽跑進度是否正確更新,您需要宣告當賽跑結束時,目前進度要設為 100
。
請按照下列步驟實作測試:
- 在
raceParticipant_RaceStarted_ProgressUpdated()
測試函式之後建立raceParticipant_RaceFinished_ProgressUpdated()
函式,並使用@Test
註解加上備註。此函式應傳回runTest{}
區塊的測試結果。
class RaceParticipantTest {
...
@Test
fun raceParticipant_RaceStarted_ProgressUpdated() = runTest {
...
}
@Test
fun raceParticipant_RaceFinished_ProgressUpdated() = runTest {
}
}
- 使用
launch
建構工具啟動新的協同程式,並在其中新增對raceParticipant.run()
函式的呼叫。
@Test
fun raceParticipant_RaceFinished_ProgressUpdated() = runTest {
launch { raceParticipant.run() }
}
- 如要模擬賽跑結束的情況,請使用
advanceTimeBy()
函式,根據raceParticipant.maxProgress * raceParticipant.progressDelayMillis
將調度器的時間往後調:
@Test
fun raceParticipant_RaceFinished_ProgressUpdated() = runTest {
launch { raceParticipant.run() }
advanceTimeBy(raceParticipant.maxProgress * raceParticipant.progressDelayMillis)
}
- 新增對
runCurrent()
函式的呼叫,執行任何待處理工作。
@Test
fun raceParticipant_RaceFinished_ProgressUpdated() = runTest {
launch { raceParticipant.run() }
advanceTimeBy(raceParticipant.maxProgress * raceParticipant.progressDelayMillis)
runCurrent()
}
- 為確保進度正確更新,請新增對
assertEquals()
函式的呼叫,檢查raceParticipant.currentProgress
屬性的值是否等於100
。
@Test
fun raceParticipant_RaceFinished_ProgressUpdated() = runTest {
launch { raceParticipant.run() }
advanceTimeBy(raceParticipant.maxProgress * raceParticipant.progressDelayMillis)
runCurrent()
assertEquals(100, raceParticipant.currentProgress)
}
- 執行測試,確認通過檢查。
隨堂測驗
請運用在「編寫 ViewModel 的單元測試」程式碼研究室中探討的測試策略,然後新增測試,涵蓋滿意路徑、錯誤案例和邊界案例。
請比較您撰寫的測試與解決方案程式碼中提供的測試。
8. 取得解決方案程式碼
完成程式碼研究室後,如要下載當中用到的程式碼,您可以使用這些 git 指令:
git clone https://github.com/google-developer-training/basic-android-kotlin-compose-training-race-tracker.git cd basic-android-kotlin-compose-training-race-tracker
另外,您也可以下載存放區為 ZIP 檔案,然後解壓縮並在 Android Studio 中開啟。
如要查看解決方案程式碼,請前往 GitHub。
9. 結語
恭喜!您已學會如何使用協同程式處理並行作業。協同程式可協助管理長時間執行的工作。這些工作可能會阻斷主執行緒,導致應用程式沒有回應。此外,您也學會如何撰寫單元測試來測試協同程式。
以下列舉一些協同程式的優點:
- 可讀性:使用協同程式撰寫程式碼時,可以清楚瞭解程式碼行的執行順序。
- Jetpack 整合:Compose 和 ViewModel 等許多 Jetpack 程式庫皆提供擴充功能,可完整支援協同程式。有些程式庫也提供專屬的協同程式範圍,可用於結構化並行。
- 結構化並行:協同程式可讓並行程式碼安全無虞且易於實作、排除不必要的樣板程式碼,並確保應用程式啟動的協同程式不會遺失或繼續浪費資源。
摘要
- 協同程式可讓您撰寫可同時執行的長時間執行程式碼,而不必學習新的程式設計樣式。根據設計原則,協同程式會依序執行。
suspend
關鍵字可用來標記函式或函式類型,表示程式碼是否可執行、暫停及重新啟用程式碼指令集。suspend
函式只能從其他暫停函式呼叫。- 您可以使用
launch
或async
建構工具函式,啟動新的協同程式。 - 實作協同程式的主要元件包括協同程式結構定義、協同程式建構工具、工作、協同程式範圍和調度器。
- 協同程式會使用調度器判斷要用於執行作業的執行緒。
- 工作會管理協同程式的生命週期並維持父項和子項的關係,在確保結構化並行這方面扮演重要角色。
CoroutineContext
會使用工作和協同程式調度器定義協同程式的行為。CoroutineScope
透過其工作控制協同程式的生命週期,並以遞迴方式強制取消工作,同時執行子項的其他規則,以及子項之子項的規則。- 啟動、完成、取消和失敗是協同程式執行作業的四個常見作業。
- 協同程式遵循結構化並行原則。