Android Studio 中的协程简介

1. 开始之前

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

先决条件

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

您将构建的内容

  • 模拟两位玩家之间比赛进程的赛车追踪器应用。将此应用视为一个实验机会,并学习有关协程不同方面的更多知识。

您将学习的内容

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

您需要的内容

  • 最新稳定版本的 Android Studio

2. 应用概述

赛车追踪器应用模拟两位玩家进行赛跑。应用 UI 包括两个按钮,**开始**/**暂停** 和 **重置**,以及两个进度条以显示赛车手的进度。玩家 1 和玩家 2 设置为以不同的速度“跑”比赛。比赛开始时,玩家 2 的进度是玩家 1 的两倍。

您将在应用中使用协程以确保

  • 两位玩家同时“进行比赛”。
  • 应用 UI 具有响应性,并且在比赛期间进度条会递增。

入门代码已准备好赛车追踪器应用的 UI 代码。codelab 此部分的主要重点是让您熟悉 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

您可以在 赛车追踪器 GitHub 存储库中浏览入门代码。

入门代码演练

您可以点击**开始**按钮开始比赛。比赛进行期间,**开始**按钮的文本会更改为**暂停**。

2ee492f277625f0a.png

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

50e992f4cf6836b7.png

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

79cf74d82eacae6f.png

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

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

3. 实现比赛进度

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

  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() 函数。

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

  • 它们在点击**开始**按钮后立即开始运行,即协程启动。
  • 分别点击**暂停**或**重置**按钮时,它们会暂停或停止运行,即协程被取消。
  • 用户关闭应用时,取消操作会得到正确处理,即所有协程都会被取消并绑定到生命周期。

在第一个 codelab 中,您了解到只能从另一个挂起函数调用挂起函数。要从可组合函数内部安全地调用挂起函数,您需要使用 LaunchedEffect() 可组合函数。LaunchedEffect() 可组合函数在它保留在组合中时运行提供的挂起函数。您可以使用 LaunchedEffect() 可组合函数来完成以下所有操作

  • LaunchedEffect() 可组合函数允许您从可组合函数安全地调用挂起函数。
  • LaunchedEffect() 函数进入 Composition 时,它会使用作为参数传递的代码块启动一个协程。它在它保留在组合中时运行提供的挂起函数。当用户在 RaceTracker 应用中点击**开始**按钮时,LaunchedEffect() 进入组合并启动一个协程来更新进度。
  • LaunchedEffect() 退出组合时,协程会被取消。在应用中,如果用户点击**重置**/**暂停**按钮,LaunchedEffect() 将从组合中删除,并且基础协程将被取消。

对于 RaceTracker 应用,您不必显式提供 Dispatcher,因为 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 通过设计遵循此结构化并发。您不能从未标记为 suspend 的函数中调用 suspend 函数。此限制确保您从协程构建器(例如 launch)调用 suspend 函数。这些构建器又与 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() 并立即返回以执行代码块中的下一行。
  • 执行流程与执行 playerTwo.run() 函数的第二个 launch 构建器函数相同。
  • 第二个 launch 构建器返回后,raceInProgress 标志立即更新。这会立即将按钮文本更改为“**开始**”,比赛也不会开始。

协程作用域

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

一旦给定的块及其所有子协程完成,作用域就会返回。对于 RaceTracker 应用程序,一旦两个参与者对象完成执行 run() 函数,它就会返回。

  1. 为了确保在更新 raceInProgress 标志之前完成 playerOneplayerTworun() 函数的执行,请使用 coroutineScope 块包装两个 launch 构建器。
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 文件,该文件位于**项目**窗格中**app**目录下的**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

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

实现用于启动和结束比赛的单元测试。

为了确保在比赛的不同阶段进度正确更新,您的单元测试需要涵盖不同的场景。对于此代码实验室,涵盖了两种场景。

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

要检查比赛开始后进度是否正确更新,您需要断言在 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 函数只能从另一个 suspend 函数调用。
  • 你可以使用 launchasync 构建器函数启动新的协程。
  • 协程上下文、协程构建器、Job、协程作用域和调度器是实现协程的主要组件。
  • 协程使用调度器来确定用于执行的线程。
  • Job 在确保结构化并发方面起着重要作用,它通过管理协程的生命周期并维护父子关系来实现。
  • CoroutineContext 使用 Job 和协程调度器定义协程的行为。
  • CoroutineScope 通过其 Job 控制协程的生命周期,并强制对其子项及其子项(递归)执行取消和其他规则。
  • 启动、完成、取消和失败是协程执行中的四个常见操作。
  • 协程遵循结构化并发的原则。

了解更多