Android Studio 中的协程简介

1. 准备工作

在上一个 Codelab 中,您学习了协程的相关知识。您通过 Kotlin 园地使用协程编写了并发代码。在此 Codelab 中,您将在一个 Android 应用及其生命周期内运用所学的协程知识。您将添加代码,以并发方式启动新协程,并了解如何测试这些协程。

前提条件

  • 了解 Kotlin 语言的基础知识,包括函数和 lambda
  • 能够在 Jetpack Compose 中构建布局
  • 能够使用 Kotlin 编写单元测试(请参考为 ViewModel 编写单元测试 Codelab
  • 了解线程和并发的工作原理
  • 具备协程和协程作用域方面的基础知识

构建内容

  • 用于模拟两位选手之间的比赛进度的 Race Tracker 应用。在构建此应用的过程中,您可以借机进行试验,详细了解协程的不同方面。

学习内容

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

所需条件

  • 最新的稳定版 Android Studio

2. 应用概览

Race Tracker 应用可模拟两位选手赛跑。应用界面中包含两个按钮:Start/PauseReset,以及两个用于显示赛跑者进度的进度条。选手 1 和 2 被设置为以不同的速度“奔跑”。比赛开始后,选手 2 的进度是选手 1 的两倍。

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

  • 两位选手同时“开跑”。
  • 应用界面响应迅速,并且进度条会在比赛过程中不断增加。

起始代码已包含 Race Tracker 应用的界面代码。这一部分 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

您可以在 Race Tracker GitHub 代码库中浏览起始代码。

起始代码演示

您可以点按 Start 按钮,开始比赛。比赛过程中,Start 按钮上的文字将变为 Pause

2ee492f277625f0a.png

您可以随时使用此按钮暂停或继续比赛。

50e992f4cf6836b7.png

比赛开始后,您可以通过状态指示器查看每位选手的进度。StatusIndicator 可组合函数会显示每位选手的进度状态。该函数使用 LinearProgressIndicator 可组合项来显示进度条。您将使用协程来更新进度值。

79cf74d82eacae6f.png

RaceParticipant 可提供进度增量数据。此类是每位选手的状态容器,会保留参赛者的 name、完成比赛需要达到的 maxProgress、两次进度增加之间的延迟时间、比赛的 currentProgress 以及 initialProgress

在下一部分中,您将使用协程实现相关功能,在不屏蔽应用界面的情况下模拟比赛进度。

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. 开始比赛

当用户按 Start 按钮时,您需要对每个选手实例调用 run() 挂起函数,从而“开始比赛”。为此,您需要启动一个协程来调用 run() 函数。

启动协程以触发比赛时,您需要针对两位参赛者确保以下方面:

  • 当用户点按 Start 按钮时(即启动协程之时),他们都会立即开跑。
  • 当用户点按 PauseReset 按钮时(即取消协程之时),他们分别会暂停或停止奔跑。
  • 当用户关闭应用时,系统会妥善管理取消操作,即取消所有协程并将其绑定到一个生命周期。

在第一个 Codelab 中,您了解到您只能通过另一个挂起函数调用挂起函数。如需从可组合项内安全地调用挂起函数,您需要使用 LaunchedEffect() 可组合项。只要还在组合中,LaunchedEffect() 可组合项就会运行提供的挂起函数。您可以使用 LaunchedEffect() 可组合函数实现下述所有目标:

  • 借助 LaunchedEffect() 可组合项,您可以安全地从可组合项调用挂起函数。
  • LaunchedEffect() 函数进入组合时,它会启动一个协程,并将代码块作为参数传递。只要还在组合中,它就会运行提供的挂起函数。当用户点按 RaceTracker 应用中的 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 对象作为 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。当用户点按 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. 打开应用模块的 build.gradle.kts 文件,该文件位于 Project 窗格的 app 目录中。

785ba583eaea1275.png

  1. 在文件内,向下滚动,直到找到 dependencies{} 代码块。
  2. 使用 testImplementation 配置向 kotlinx-coroutines-test 库添加依赖项。
plugins {
    ...
}

android {
    ...
}

dependencies {
    ...
    testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4")
}
  1. build.gradle 文件顶部的通知栏中,点击 Sync Now,这将完成导入和构建,如以下屏幕截图所示:

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

总结

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

了解更多内容