Android Studio 中的协程简介

1. 开始之前

在之前的代码实验室中,您学习了协程。您使用 Kotlin Playground 使用协程编写并发代码。在这个代码实验室中,您将在 Android 应用程序及其生命周期内应用您的协程知识。您将添加代码以并发启动新协程,并学习如何测试它们。

先决条件

  • 了解 Kotlin 语言基础知识,包括函数和 lambda 表达式
  • 能够在 Jetpack Compose 中构建布局
  • 能够用 Kotlin 编写单元测试(参考 为 ViewModel 代码实验室编写单元测试
  • 线程和并发的工作原理
  • 协程和 CoroutineScope 的基本知识

您将构建什么

  • 模拟两个玩家之间比赛进程的 Race Tracker 应用程序。将此应用程序视为一个机会,可以试验和深入了解协程的不同方面。

您将学到什么

  • 在 Android 应用程序生命周期中使用协程。
  • 结构化并发的原理。
  • 如何编写单元测试来测试协程。

您需要什么

  • Android Studio 的最新稳定版本

2. 应用程序概述

Race Tracker 应用程序模拟两个玩家进行比赛。应用程序 UI 包含两个按钮,开始/暂停重置,以及两个进度条,用于显示赛车手的进度。玩家 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 存储库中浏览启动代码。

启动代码演练

您可以通过单击开始按钮来开始比赛。比赛进行时,开始按钮的文本将变为暂停

2ee492f277625f0a.png

在任何时候,您都可以使用此按钮来暂停或继续比赛。

50e992f4cf6836b7.png

比赛开始时,您可以通过状态指示器查看每个玩家的进度。 StatusIndicator 可组合函数显示每个玩家的进度状态。它使用 LinearProgressIndicator 可组合函数来显示进度条。您将使用协程来更新进度的值。

79cf74d82eacae6f.png

RaceParticipant 提供进度增量的數據。此类是每个玩家的状态持有者,并维护参与者的 name、完成比赛所需达到的 maxProgress、进度增量之间的延迟持续时间 progressDelayMillis、比赛中的 currentProgress 以及 initialProgress

在下一部分中,您将使用协程来实现模拟比赛进程的功能,而不会阻塞应用程序 UI。

3. 实现比赛进程

您需要 run() 函数,该函数将玩家的 currentProgressmaxProgress 进行比较,maxProgress 反映比赛的总进度,并使用 delay() 挂起函数在进度增量之间添加一个轻微的延迟。此函数必须是一个 suspend 函数,因为它正在调用另一个挂起函数 delay()。此外,您将在代码实验室的后面部分从协程中调用此函数。请按照以下步骤实现此函数

  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 循环中将 currentProgress 的值增加 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() 函数的调用的左侧看到一个图标,如下面的屏幕截图所示: 11b5df57dcb744dc.png

此图标表示挂起点,函数可能在该点挂起并在以后恢复执行。

在协程等待完成延迟持续时间时,主线程不会被阻塞,如以下图表所示

a3c314fb082a9626.png

协程在调用带有所需间隔值的 delay() 函数后,会挂起(但不阻塞)执行。延迟完成后,协程恢复执行并更新 currentProgress 属性的值。

4. 开始比赛

当用户按下开始按钮时,您需要通过在两个玩家实例的每个实例上调用 run() 挂起函数来“开始比赛”。为此,您需要启动一个协程来调用 run() 函数。

当您启动一个协程来触发比赛时,您需要确保以下方面对两个参与者都适用

  • 它们在单击开始按钮时立即开始运行,也就是说,协程启动。
  • 它们在单击暂停重置按钮时分别暂停或停止运行,也就是说,协程被取消。
  • 当用户关闭应用程序时,取消操作得到妥善处理,也就是说,所有协程都被取消并绑定到生命周期。

在第一个代码实验室中,您了解到只能从另一个挂起函数中调用挂起函数。为了安全地从可组合函数内部调用挂起函数,您需要使用 LaunchedEffect() 可组合函数。 LaunchedEffect() 可组合函数在它仍然存在于组合中时运行提供的挂起函数。您可以使用 LaunchedEffect() 可组合函数来完成以下所有操作

  • LaunchedEffect() 可组合函数允许您安全地从可组合函数中调用挂起函数。
  • LaunchedEffect() 函数进入组合时,它将使用作为参数传递的代码块启动一个协程。它在它仍然存在于组合中时运行提供的挂起函数。当用户在 RaceTracker 应用程序中单击开始按钮时, LaunchedEffect() 进入组合并启动一个协程来更新进度。
  • LaunchedEffect() 退出组合时,协程会被取消。在应用程序中,如果用户单击重置/暂停按钮, 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 对象作为 key 添加到 LaunchedEffect。类似于 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。当用户单击开始按钮并且 LaunchedEffect() 执行时, raceInProgress 状态的值更新为 true
if (raceInProgress) {
    LaunchedEffect(playerOne, playerTwo) {
        playerOne.run()
        playerTwo.run() 
    }
}
  1. raceInProgress 标志更新为 false 以结束比赛。当用户也单击暂停时,此值将设置为 false。当此值设置为 false 时, LaunchedEffect() 确保所有启动的协程都被取消。
LaunchedEffect(playerOne, playerTwo) {
    playerOne.run()
    playerTwo.run()
    raceInProgress = false 
}
  1. 运行应用程序并单击开始。您应该看到玩家一在玩家二开始运行之前完成比赛,如以下视频所示

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. 运行应用程序并点击 **开始**。虽然您希望比赛开始,但按钮的文本意外地立即变回 **开始**。

c46c2aa7c580b27b.png

当两位玩家都完成比赛时,Race Tracker 应用程序应将 **暂停** 按钮的文本重置回 **开始**。但是,现在应用程序在启动协程后立即更新了 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() 并立即返回以执行代码块中的下一行。
  • 第二个 launch 构建器函数执行 playerTwo.run() 函数时,会发生相同的执行流程。
  • 第二个 launch 构建器返回后,立即更新 raceInProgress 标志。这立即将按钮文本更改为 **开始**,并且比赛不会开始。

协程作用域

coroutineScope 挂起函数创建一个 CoroutineScope 并使用当前作用域调用指定的挂起块。该作用域从 LaunchedEffect() 作用域继承其 coroutineContext

当给定块及其所有子协程完成时,该作用域返回。对于 RaceTracker 应用程序,它在两个参与者对象都完成执行 run() 函数后返回。

  1. 为了确保在更新 raceInProgress 标志之前完成 playerOneplayerTworun() 函数的执行,请使用 coroutineScope 块包装两个启动构建器。
LaunchedEffect(playerOne, playerTwo) {
    coroutineScope {
        launch { playerOne.run() }
        launch { playerTwo.run() }
    }
    raceInProgress = false
}
  1. 在模拟器/Android 设备上运行应用程序。您应该看到以下屏幕

598ee57f8ba58a52.png

  1. 点击 **开始** 按钮。玩家 2 比玩家 1 速度更快。比赛结束后,即两个玩家都达到 100% 进度时,**暂停** 按钮的标签变为 **开始**。您可以点击 **重置** 按钮重置比赛并重新执行模拟。比赛在以下视频中显示。

c1035eecc5513c58.gif

执行流程在以下图表中显示

cf724160fd66ff21.png

  • LaunchedEffect() 块执行时,控制权将转移到 coroutineScope{..} 块。
  • coroutineScope 块并发启动两个协程,并等待它们完成执行。
  • 执行完成后,raceInProgress 标志更新。

coroutineScope 块仅在块内所有代码完成执行后返回并继续执行。对于块外部的代码,并发的存在与否仅仅是一个实现细节。这种编码风格为并发编程提供了一种结构化方法,被称为结构化并发。

当您在比赛结束后点击 **重置** 按钮时,协程会被取消,并且两个玩家的进度都会重置为 0

要查看用户点击 **重置** 按钮时协程是如何被取消的,请执行以下步骤

  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. 运行应用程序并点击 **开始** 按钮。
  2. 在进度增加了一些之后,点击 **重置** 按钮。
  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** 目录中。

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** 文件顶部的通知栏中,点击 **立即同步** 以让导入和构建完成,如以下屏幕截图所示

1c20fc10750ca60c.png

构建完成后,您就可以开始编写测试了。

为启动和结束比赛实现单元测试

为了确保比赛进度在比赛的不同阶段正确更新,您的单元测试需要涵盖不同的场景。对于本 Codelab,将涵盖两种场景

  • 比赛开始后的进度。
  • 比赛结束后后的进度。

要检查比赛开始后比赛进度是否正确更新,您需要断言在 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 编写单元测试 Codelab 中讨论的测试策略。添加测试以涵盖正常路径、错误情况和边界情况。

将您编写的测试与解决方案代码中提供的测试进行比较。

8. 获取解决方案代码

要下载完成的 Codelab 的代码,您可以使用以下 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)都包含提供完整协程支持的扩展。一些库还提供自己的协程作用域,可用于结构化并发。
  • 结构化并发: 协程使并发代码安全且易于实现,消除不必要的样板代码,并确保应用程序启动的协程不会丢失或浪费资源。

总结

  • 协程允许您编写在不学习新的编程风格的情况下并发运行的长时间运行代码。协程的执行本质上是顺序的。
  • The suspend 关键字用于标记函数或函数类型,以指示其可用于执行、暂停和恢复一组代码指令。
  • 只能从另一个 suspend 函数调用 suspend 函数。
  • 您可以使用 launchasync 构建器函数启动新的协程。
  • 协程上下文、协程构建器、Job、协程作用域和调度器是实现协程的主要组件。
  • 协程使用调度器来确定用于执行的线程。
  • Job 在确保结构化并发方面发挥着重要作用,它通过管理协程的生命周期并维护父子关系。
  • A CoroutineContext 使用 Job 和协程调度器来定义协程的行为。
  • A CoroutineScope 通过其 Job 控制协程的生命周期,并强制执行取消和其他规则,使其子级及其子级递归地执行。
  • 启动、完成、取消和失败是协程执行中的四个常见操作。
  • 协程遵循结构化并发的原则。

了解更多