Android Studio 中的協同程式簡介

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 應用程式的使用者介面程式碼。程式碼研究室此部分的宗旨,是要協助您熟悉 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」

5f5e985b8771e93.png

您隨時可以使用此按鈕暫停或繼續比賽。

7b4647b66e2ec936.png

比賽開始後,您可以透過狀態指標查看每位玩家的進度。StatusIndicator 可組合函式會顯示每位玩家的進度狀態。它使用 LinearProgressIndicator 可組合項來顯示進度列。您將使用協同程式更新進度值。

fd0ce4493876a128.png

RaceParticipant 提供進度增加的資料。此類別是每位玩家的狀態容器,並包含參與者的 name、完成競賽的 maxProgress、進度增加之間的延遲持續時間、競賽中的 currentProgressinitialProgress

在下一節中,您將使用協同程式實作模擬競賽進度的功能,而不會封鎖應用程式使用者介面。

3. 實作競賽進度

您需要使用 run() 函式比較玩家的 currentProgressmaxProgress,以反映競賽的整體進度,並使用 delay() 暫停函式,在進度增加之間新增些微延遲。由於此函式會呼叫另一個暫停函式 delay(),因此,此函式必須為 suspend 函式。此外,您稍後會在程式碼研究室中從協同程式呼叫此函式。請按照下列步驟實作此函式:

  1. 開啟 RaceParticipant 類別,這是範例程式碼的一部分。
  2. RaceParticipant 類別中,定義名為 run() 的新 suspend 函式。
class RaceParticipant(
    ...
) {
    var currentProgress by mutableStateOf(initialProgress)
        private set

    suspend fun run() {

    }
    ...
}
  1. 如要模擬進度,請新增 while 迴圈,直至 currentProgress 達到 maxProgress 值 (設為 100)。
class RaceParticipant(
    ...
    val maxProgress: Int = 100,
    ...
) {
    var currentProgress by mutableStateOf(initialProgress)
        private set

    suspend fun run() {
        while (currentProgress < maxProgress) {

        }
    }
    ...
}
  1. currentProgress 的值已設為 initialProgress,也就是 0。如要模擬參與者的進度,請將值 currentProgress 增加 while 迴圈內的 progressIncrement 屬性值。請注意,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
        }
    }
}
  1. 如要模擬競賽中的不同進度間隔,請使用 delay() 暫停函式。請將 progressDelayMillis 屬性的值以引數形式傳遞。
suspend fun run() {
    while (currentProgress < maxProgress) {
        delay(progressDelayMillis)
        currentProgress += progressIncrement
    }
}

查看剛新增的程式碼時,您會在 Android Studio 中對 delay() 函式的呼叫左側看到一個圖示,如以下螢幕截圖所示:bb73023b614e73ef.png

此圖示表示函式可能會在什麼暫停點暫停,並於稍後繼續。

在協同程式等待完成延遲時間時,系統不會封鎖主執行緒,如下圖所示:

f2d3ab39375788b2.png

使用所需間隔值呼叫 delay() 函式後,協同程式會暫停執行程序,但不會終止執行程序。延遲完成後,協同程式即繼續執行,並更新 currentProgress 屬性的值。

4. 開始競賽

使用者按下「Start」按鈕時,您必須在兩個玩家執行個體上分別呼叫 run() 暫停函式,以便「開始競賽」。為此,您必須啟動協同程式來呼叫 run() 函式。

啟動協同程式來觸發競賽時,必須同時為兩位參與者確保下列層面:

  • 點選「Start」按鈕,也就是啟動協同程式後,雙方便會起跑。
  • 如果點選「Pause」或「Reset」按鈕,也就是取消協同程式後,雙方便會暫停或停止奔跑。
  • 使用者關閉應用程式後,系統會妥善管理取消作業,也就是所有協同程式都會遭到取消,並且繫結於生命週期。

從第一個程式碼研究室中學到的經驗是,您只能從另一個暫停函式呼叫暫停函式。如要從可組合函式內部安全地呼叫暫停函式,您必須使用 LaunchedEffect() 可組合項。只要仍在組合中,LaunchedEffect() 可組合項就會執行所提供的暫停函式。您可以使用 LaunchedEffect() 可組合函式來完成以下所有工作:

  • LaunchedEffect() 可組合項可讓您安全地從可組合項呼叫暫停函式。
  • LaunchedEffect() 函式進入組合時,即啟動一個協同程式,並將程式碼區塊做為參數傳遞。只要其仍存在於組合中,就會執行所提供的暫停函式。使用者在 RaceTracker 應用程式中按一下「Start」按鈕後,LaunchedEffect() 便會進入組合並啟動協同程式來更新進度。
  • LaunchedEffect() 離開組合時,即取消協同程式。在應用程式中,如果使用者點選「Reset」/「Pause」按鈕,系統便會將 LaunchedEffect() 從組合中移除,並取消基礎協同程式。

針對 RaceTracker 應用程式,由於 LaunchedEffect() 會負責處理,因此您不必明確提供調度工具。

如要開始競賽,請為每位參賽者呼叫 run() 函式,然後執行下列步驟:

  1. 開啟位於 com.example.racetracker.ui 套件中的 RaceTrackerApp.kt 檔案。
  2. 前往 RaceTrackerApp() 可組合函式,然後在 raceInProgress 定義後方的該行程式碼中,新增對 LaunchedEffect() 可組合函式的呼叫。
@Composable
fun RaceTrackerApp() {
    ...
    var raceInProgress by remember { mutableStateOf(false) }

    LaunchedEffect {

    }
    RaceTrackerScreen(...)
}
  1. 為確保 playerOneplayerTwo 的執行個體被取代為不同的執行個體,則 LaunchedEffect() 必須取消並重新啟動基礎協同程式,將 playerOneplayerTwo 物件新增為 LaunchedEffectkey。與 Text() 可組合項在文字值變更時的重組方式類似,如果 LaunchedEffect() 的任何鍵引數變更,即取消並重新啟動基礎協同程式。
LaunchedEffect(playerOne, playerTwo) {
}
  1. 新增 playerOne.run()playerTwo.run() 函式的呼叫。
@Composable
fun RaceTrackerApp() {
    ...
    var raceInProgress by remember { mutableStateOf(false) }

    LaunchedEffect(playerOne, playerTwo) {
        playerOne.run()
        playerTwo.run()
    }
    RaceTrackerScreen(...)
}
  1. 使用 if 條件納入 LaunchedEffect() 區塊。此狀態的初始值為 false。使用者按一下「Start」按鈕且 LaunchedEffect() 執行後,raceInProgress 狀態的值會更新為 true
if (raceInProgress) {
    LaunchedEffect(playerOne, playerTwo) {
        playerOne.run()
        playerTwo.run()
    }
}
  1. raceInProgress 旗標更新為 false 即可完成競賽。使用者也按下「Pause」後,此值會設為 false。將此值設為 false 時,LaunchedEffect() 可確保取消所有啟動的協同程式。
LaunchedEffect(playerOne, playerTwo) {
    playerOne.run()
    playerTwo.run()
    raceInProgress = false
}
  1. 執行應用程式,然後按一下「Start」。您應該會先看到玩家 1 完成競賽,才看到玩家 2 開始奔跑,如以下影片所示:

b2c41fb021e3c58.gif

這場競賽似乎不公平!在下一節中,您將瞭解如何啟動並行工作,讓兩位玩家同時執行、瞭解概念並實作此行為。

5. 結構化並行

使用協同程式撰寫程式碼的方式稱為結構化並行。此程式設計風格可以提升程式碼的可讀性和開發時間。結構化並行的概念就是為協同程式設定階層結構,當中工作可能會啟動子工作,而子工作可能會轉而啟動子工作。此階層的單位稱為協同程式範圍。協同程式範圍應與生命週期建立關聯。

協同程式 API 遵循此結構化並行設計。您無法透過未標示為暫停的函式呼叫暫停函式。此限制確保您可從協同程式建構工具 (例如 launch) 呼叫暫停函式。這些建構工具轉而繫結至 CoroutineScope

6. 啟動並行工作

  1. 如要同時讓兩位參與者同時執行,則須啟動兩個獨立的協同程式,並將每個呼叫移至這些協同程式中的 run() 函式。使用 launch 建構工具納入 playerOne.run() 的呼叫。
LaunchedEffect(playerOne, playerTwo) {
    launch { playerOne.run() }
    playerTwo.run()
    raceInProgress = false
}
  1. 同樣地,也可以使用 launch 建構工具納入 playerTwo.run() 函式的呼叫。進行此變更後,應用程式會啟動兩個同時執行的協同程式。現在兩位玩家可以同時執行。
LaunchedEffect(playerOne, playerTwo) {
    launch { playerOne.run() }
    launch { playerTwo.run() }
    raceInProgress = false
}
  1. 執行應用程式,然後按一下「Start」。在您預期競賽開始時,按鈕的文字會無預警地立即變回「Start」

8e6af93513e26e7.png

兩位玩家均完成競賽後,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() 函式後,該應用程式便會回傳。

  1. 如要確保 playerOneplayerTworun() 函式在更新 raceInProgress 旗標前完成執行,請使用 coroutineScope 區塊納入這兩個啟動建構工具。
LaunchedEffect(playerOne, playerTwo) {
    coroutineScope {
        launch { playerOne.run() }
        launch { playerTwo.run() }
    }
    raceInProgress = false
}
  1. 在模擬器/Android 裝置上執行應用程式。您應會看到以下畫面:

11b22b5f6a485777.png

  1. 點選「Start」按鈕。玩家 2 跑得比玩家 1 快。競賽結束後,當兩位玩家都達到 100% 的進度時,「Pause」按鈕的標籤就會變更為「Start」。您可以按一下「Reset」按鈕重設競賽,並重新執行模擬。競賽結果如以下影片所示。

8f15e905230bccd7.gif

執行流程如下圖所示:

8cb11ef243cb088.png

  • 系統執行 LaunchedEffect() 區塊時,控制項會轉移至 coroutineScope{..} 區塊。
  • coroutineScope 區塊會並行啟動這兩個協同程式,並等候這些協同程式完成執行。
  • 執行完成後,raceInProgress 旗標便會更新。

coroutineScope 區塊只會在區塊中的所有程式碼執行完畢後回傳並繼續。對於區塊外的程式碼,並行是否存在僅屬於實作詳細資料。此編碼樣式提供並行程式設計的結構化方法,稱為結構化並行。

如果在競賽完成後點選「Reset」按鈕,系統就會取消協同程式,兩位玩家的進度也會重設為 0

如要瞭解當使用者點選「Reset」按鈕時,系統如何取消協同程式,請按照下列步驟操作:

  1. 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.
    }
}
  1. 執行應用程式,然後按一下「Start」按鈕
  2. 進度增加後,按一下「Reset」按鈕。
  3. 請確認您已在 Logcat 中看到下列訊息。
Player 1: StandaloneCoroutine was cancelled
Player 2: StandaloneCoroutine was cancelled

7. 撰寫測試協同程式的單元測試

使用協同程式的單元測試程式碼時需要特別留意,因為執行作業可能並不同步,且會在多個執行緒中執行。

如要呼叫測試中的暫停函式,您必須位於協同程式中。由於 JUnit 測試函式本身不是暫停函式,因此您必須使用 runTest 協同程式建構工具。此建構工具是 kotlinx-coroutines-test 程式庫的一部分,旨在執行測試。建構工具會在新的協同程式中執行測試內文。

由於 runTestkotlinx-coroutines-test 程式庫的一部分,因此必須新增其依附元件。

如要新增依附元件,請完成下列步驟:

  1. 開啟應用程式模組的 build.gradle.kts 檔案,該檔案位於「Project」窗格中的 app 目錄。

3bd7ba0feab72861.png

  1. 在檔案中向下捲動,直到找到 dependencies{} 區塊。
  2. 使用 testImplementation 設定,將依附元件新增至 kotlinx-coroutines-test 程式庫。
plugins {
    ...
}

android {
    ...
}

dependencies {
    ...
    testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4")
}
  1. build.gradle.kts 檔案頂端的通知列中,按一下「Sync Now」,讓系統完成匯入和建構程序,如下方螢幕截圖所示:

bc28685e109e80d3.png

建構完成後,即可開始撰寫測試。

實作單元測試來開始並完成競賽

為確保在競賽的不同階段正確更新競賽進度,單元測試必須涵蓋不同的情況。此程式碼研究室涵蓋兩種情況:

  • 競賽開始後進度。
  • 競賽完成後的進度。

如要在競賽開始後,確認競賽進度是否正確更新,必須在 raceParticipant.progressDelayMillis 持續時間經過後,宣告目前進度設為 1。

如要實作測試情況,請按照下列步驟操作:

  1. 前往測試來源集下方的 RaceParticipantTest.kt 檔案。
  2. 如要定義測試,請在 raceParticipant 定義之後建立 raceParticipant_RaceStarted_ProgressUpdated() 函式,並使用 @Test 註解加上備註。由於測試區塊必須放置於 runTest 建構工具中,因此請使用運算式語法傳回 runTest() 區塊做為測試結果。
class RaceParticipantTest {
    private val raceParticipant = RaceParticipant(
        ...
    )

    @Test
    fun raceParticipant_RaceStarted_ProgressUpdated() = runTest {
    }
}
  1. 新增唯讀 expectedProgress 變數,並將其設為 1
@Test
fun raceParticipant_RaceStarted_ProgressUpdated() = runTest {
    val expectedProgress = 1
}
  1. 如要模擬競賽開始,請使用 launch 建構工具啟動新的協同程式,並呼叫 raceParticipant.run() 函式。
@Test
fun raceParticipant_RaceStarted_ProgressUpdated() = runTest {
    val expectedProgress = 1
    launch { raceParticipant.run() }
}

raceParticipant.progressDelayMillis 屬性的值決定競賽進度的更新時間長度。如要在經過 progressDelayMillis 時間後測試進度,必須在測試中新增某種延遲形式。

  1. 使用 advanceTimeBy() 輔助函式將時間提前 raceParticipant.progressDelayMillis 值。advanceTimeBy() 函式有助於縮短測試執行時間。
@Test
fun raceParticipant_RaceStarted_ProgressUpdated() = runTest {
    val expectedProgress = 1
    launch { raceParticipant.run() }
    advanceTimeBy(raceParticipant.progressDelayMillis)
}
  1. 由於 advanceTimeBy() 不會執行指定時間內排定的工作,因此您必須呼叫 runCurrent() 函式。此函式會在目前執行任何待處理工作。
@Test
fun raceParticipant_RaceStarted_ProgressUpdated() = runTest {
    val expectedProgress = 1
    launch { raceParticipant.run() }
    advanceTimeBy(raceParticipant.progressDelayMillis)
    runCurrent()
}
  1. 為確保進度更新,請新增 assertEquals() 函式的呼叫,藉以檢查 raceParticipant.currentProgress 屬性的值是否與 expectedProgress 變數的值相符。
@Test
fun raceParticipant_RaceStarted_ProgressUpdated() = runTest {
    val expectedProgress = 1
    launch { raceParticipant.run() }
    advanceTimeBy(raceParticipant.progressDelayMillis)
    runCurrent()
    assertEquals(expectedProgress, raceParticipant.currentProgress)
}
  1. 執行測試以確認通過。

如要在競賽完成後查看競賽進度是否正確更新,您需要宣告:在競賽完成時,目前的進度設為 100

請按照下列步驟實作測試:

  1. raceParticipant_RaceStarted_ProgressUpdated() 測試函式之後,建立 raceParticipant_RaceFinished_ProgressUpdated() 函式,並使用 @Test 註解加上備註。此函式應傳回 runTest{} 區塊的測試結果。
class RaceParticipantTest {
    ...

    @Test
    fun raceParticipant_RaceStarted_ProgressUpdated() = runTest {
        ...
    }

    @Test
    fun raceParticipant_RaceFinished_ProgressUpdated() = runTest {
    }
}
  1. 使用 launch 建構工具啟動新的協同程式,並在其中新增 raceParticipant.run() 函式的呼叫。
@Test
fun raceParticipant_RaceFinished_ProgressUpdated() = runTest {
    launch { raceParticipant.run() }
}
  1. 如要模擬競賽完成,請使用 advanceTimeBy() 函式,將調度工具的時間提前 raceParticipant.maxProgress * raceParticipant.progressDelayMillis
@Test
fun raceParticipant_RaceFinished_ProgressUpdated() = runTest {
    launch { raceParticipant.run() }
    advanceTimeBy(raceParticipant.maxProgress * raceParticipant.progressDelayMillis)
}
  1. 新增 runCurrent() 函式的呼叫以執行任何待處理工作。
@Test
fun raceParticipant_RaceFinished_ProgressUpdated() = runTest {
    launch { raceParticipant.run() }
    advanceTimeBy(raceParticipant.maxProgress * raceParticipant.progressDelayMillis)
    runCurrent()
}
  1. 為確保進度正確更新,請新增 assertEquals() 函式的呼叫,藉以檢查 raceParticipant.currentProgress 屬性的值是否等於 100
@Test
fun raceParticipant_RaceFinished_ProgressUpdated() = runTest {
    launch { raceParticipant.run() }
    advanceTimeBy(raceParticipant.maxProgress * raceParticipant.progressDelayMillis)
    runCurrent()
    assertEquals(100, raceParticipant.currentProgress)
}
  1. 執行測試以確認通過。

隨堂測驗

應用在如何撰寫 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 整合:許多 Jetpack 程式庫 (例如 Compose 和 ViewModel) 都包含提供完整協同程式支援的擴充功能。有些程式庫也提供專屬的協同程式範圍,可讓您用於結構化並行。
  • 結構化並行:協同程式可讓並行程式碼安全無虞且易於實作、排除不必要的樣板程式碼,並確保應用程式啟動的協同程式不會遺失或外洩。

摘要

  • 協同程式可讓您撰寫可同時執行的長時間執行程式碼,而不必學習新的程式設計樣式。協同程式採用依序執行設計。
  • suspend 關鍵字用於標記函式或函式類型,以表示程式碼是否可執行、暫停及重新啟用程式碼指令集。
  • suspend 函式只能從其他暫停函式呼叫。
  • 您可以使用 launchasync 建構工具函式來啟動新的協同程式。
  • 協同程式結構定義、協同程式建構工具、工作、協同程式範圍和調度工具是實作協同程式的主要元件。
  • 協同程式會使用調度工具決定其執行的執行緒。
  • 工作會管理協同程式的生命週期並維持父項和子項的關係,藉此確保結構化並行。
  • CoroutineContext 會使用工作和協同程式調度工具定義協同程式的行為。
  • CoroutineScope 透過其工作控制協同程式的生命週期,並以遞迴方式強制取消及其子項的規則和其子項。
  • 啟動、完成、取消和失敗是協同程式執行中的四個常見作業。
  • 協同程式遵循結構化並行原則。

瞭解詳情