Android Studio 中的协程简介

1. 准备工作

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

前提条件

  • 了解 Kotlin 语言基础知识,包括函数和 Lambda 表达式
  • 能够使用 Jetpack Compose 构建布局
  • 能够用 Kotlin 编写单元测试(请参阅为 ViewModel 编写单元测试 Codelab
  • 了解线程和并发的工作原理
  • 基本了解协程和 CoroutineScope

您将构建什么

  • 赛道追踪器应用,用于模拟两名玩家之间的比赛进度。将此应用视为一个实验和学习协程不同方面知识的机会。

您将学习什么

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

您需要什么

  • 最新稳定版 Android Studio

2. 应用概览

赛道追踪器应用模拟了两名玩家跑步比赛。应用界面由两个按钮(开始/暂停重置)以及两个进度条组成,用于显示参赛者的进度。玩家 1 和玩家 2 以不同的速度“跑步”。比赛开始时,玩家 2 的进度是玩家 1 的两倍。

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

  • 两名玩家同时“跑步”。
  • 应用界面响应迅速,且进度条在比赛期间会增加。

起始代码已包含赛道追踪器应用的界面代码。本部分 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 仓库中浏览起始代码。

起始代码导览

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

2ee492f277625f0a.png

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

50e992f4cf6836b7.png

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

79cf74d82eacae6f.png

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

在下一部分中,您将使用协程实现模拟比赛进度的功能,同时不阻塞应用界面。

3. 实现比赛进度

您需要 run() 函数,该函数会比较玩家的 currentProgress 与代表比赛总进度的 maxProgress,并使用 delay() suspend 函数在进度增量之间添加少量延迟。此函数必须是一个 suspend 函数,因为它调用了另一个 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() suspend 函数。将 progressDelayMillis 属性的值作为参数传入。
suspend fun run() {
    while (currentProgress < maxProgress) {
        delay(progressDelayMillis)
        currentProgress += progressIncrement
    }
}

查看刚刚添加的代码时,您会在 Android Studio 中调用 delay() 函数的左侧看到一个图标,如以下屏幕截图所示:调用 delay() 函数旁边的图标

此图标表示函数可能暂停并在稍后再次恢复执行的挂起点。

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

a3c314fb082a9626.png

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

4. 开始比赛

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

启动协程来触发比赛时,您需要为两名参与者确保以下几个方面

  • 当点击开始按钮时,它们立即开始跑步,即协程启动。
  • 当分别点击暂停重置按钮时,它们暂停或停止跑步,即协程被取消。
  • 当用户关闭应用时,取消操作得到妥善管理,即所有协程都被取消并绑定到生命周期。

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

  • LaunchedEffect() 可组合项允许您从可组合项安全地调用 suspend 函数。
  • LaunchedEffect() 函数进入 Composition 时,它会启动一个协程,并执行作为参数传入的代码块。它会在其保留在组合中时一直运行提供的 suspend 函数。当用户点击 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 对象作为 LaunchedEffectkey 添加。与当文本值更改时 Text() 可组合项会重新组合类似,如果 LaunchedEffect() 的任何 key 参数发生更改,则底层协程会被取消并重新启动。
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. 结构化并发

您使用协程编写代码的方式称为结构化并发。这种编程风格提高了代码的可读性和开发时间。结构化并发的思想是协程具有层级关系 - 任务可以启动子任务,子任务又可以启动其子任务。此层级关系的单位称为协程作用域 (CoroutineScope)。协程作用域应始终与生命周期相关联。

协程 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

当两名玩家完成跑步后,赛道追踪器应用应该将暂停按钮的文本重置回开始。然而,现在应用在启动协程后立即更新了 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 标志就会更新。这会立即将按钮文本更改为开始,并且比赛没有开始。

协程作用域 (Coroutine Scope)

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

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

  1. 为确保 playerOneplayerTworun() 函数在更新 raceInProgress 标志之前完成执行,请使用 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. 使用 try-catch 块包裹 run() 方法的主体,如以下代码所示
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. 编写单元测试来测试协程

对使用协程的代码进行单元测试需要额外注意,因为它们的执行可以是异步的,并且跨多个线程发生。

要在测试中调用 suspending 函数,您需要在协程中。由于 JUnit 测试函数本身不是 suspending 函数,因此您需要使用 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() 函数将 dispatcher 的时间前进 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 函数只能从另一个 suspend 函数调用。
  • 您可以使用 launchasync 构建器函数启动新的协程。
  • 协程上下文 (Coroutine context)、协程构建器 (coroutine builders)、Job、协程作用域 (coroutine scope) 和 Dispatcher 是实现协程的主要组件。
  • 协程使用 dispatchers 来确定执行时使用的线程。
  • Job 通过管理协程的生命周期和维护父子关系,在确保结构化并发方面发挥重要作用。
  • A CoroutineContext 使用 Job 和协程 dispatcher 定义协程的行为。
  • A CoroutineScope 通过其 Job 控制协程的生命周期,并将其取消及其他规则递归应用于其子协程及其子子协程。
  • 启动 (Launch)、完成 (completion)、取消 (cancellation) 和失败 (failure) 是协程执行中的四种常见操作。
  • 协程遵循结构化并发原则。

了解详情