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 應用程式的 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」

2ee492f277625f0a.png

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

50e992f4cf6836b7.png

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

79cf74d82eacae6f.png

RaceParticipant 提供進度遞增的資料。此類別是每位玩家的狀態容器,包含參與者的 name、達成即結束賽跑的 maxProgress、每次進度遞增之間的延遲期間、賽跑時的 currentProgress,以及 initialProgress

在下一節中,您將使用協同程式實作模擬賽跑進度的功能,而不會阻斷應用程式 UI。

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。如要模擬參與者的進度,請使用 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
        }
    }
}
  1. 如要模擬賽跑中的不同進度間隔,請使用 delay() 暫停函式。將 progressDelayMillis 屬性的值做為引數傳遞。
suspend fun run() {
    while (currentProgress < maxProgress) {
        delay(progressDelayMillis)
        currentProgress += progressIncrement
    }
}

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

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

協同程式在等待延遲期間結束時,不會阻斷主執行緒,如下圖所示:

a3c314fb082a9626.png

使用所需的間隔值呼叫 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() 函式,然後執行下列步驟:

  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 才開始奔跑,如以下影片所示:

fa0630395ee18f21.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」

c46c2aa7c580b27b.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 裝置上執行應用程式。您應看到以下畫面:

598ee57f8ba58a52.png

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

c1035eecc5513c58.gif

執行流程如下圖所示:

cf724160fd66ff21.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. 在「Project」窗格的 app 目錄中,開啟應用程式模組的 build.gradle.kts 檔案。

e7c9e573c41199c6.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」,讓系統完成匯入和建構程序,如下方螢幕截圖所示:

1c20fc10750ca60c.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. 依照 raceParticipant.progressDelayMillis 的值,使用 advanceTimeBy() 輔助函式將時間往後調。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 整合:Compose 和 ViewModel 等許多 Jetpack 程式庫皆提供擴充功能,可完整支援協同程式。有些程式庫也提供專屬的協同程式範圍,可用於結構化並行。
  • 結構化並行:協同程式可讓並行程式碼安全無虞且易於實作、排除不必要的樣板程式碼,並確保應用程式啟動的協同程式不會遺失或繼續浪費資源。

摘要

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

瞭解詳情