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 仓库中浏览起始代码。
起始代码导览
您可以点击开始按钮来开始比赛。比赛进行期间,开始按钮的文本会变为暂停。
在任何时候,您都可以使用此按钮暂停或继续比赛。
比赛开始时,您可以通过状态指示器查看每位玩家的进度。StatusIndicator
可组合函数显示每位玩家的进度状态。它使用 LinearProgressIndicator
可组合函数来显示进度条。您将使用协程来更新进度的值。
RaceParticipant
提供进度增量所需的数据。此类是每位玩家的状态持有者,并维护参与者的 name
、完成比赛所需达到的 maxProgress
、进度增量之间的延迟时间、比赛中的 currentProgress
和 initialProgress
。
在下一部分中,您将使用协程实现模拟比赛进度的功能,同时不阻塞应用界面。
3. 实现比赛进度
您需要 run()
函数,该函数会比较玩家的 currentProgress
与代表比赛总进度的 maxProgress
,并使用 delay()
suspend 函数在进度增量之间添加少量延迟。此函数必须是一个 suspend
函数,因为它调用了另一个 suspend 函数 delay()
。此外,您稍后将在 Codelab 中从协程调用此函数。按照以下步骤实现此函数
- 打开
RaceParticipant
类,该类是起始代码的一部分。 - 在
RaceParticipant
类中,定义一个名为run()
的新suspend
函数。
class RaceParticipant(
...
) {
var currentProgress by mutableStateOf(initialProgress)
private set
suspend fun run() {
}
...
}
- 为了模拟比赛进度,添加一个
while
循环,使其运行直到currentProgress
达到maxProgress
的值(设置为100
)。
class RaceParticipant(
...
val maxProgress: Int = 100,
...
) {
var currentProgress by mutableStateOf(initialProgress)
private set
suspend fun run() {
while (currentProgress < maxProgress) {
}
}
...
}
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
}
}
}
- 为了模拟比赛中不同的进度间隔,使用
delay()
suspend 函数。将progressDelayMillis
属性的值作为参数传入。
suspend fun run() {
while (currentProgress < maxProgress) {
delay(progressDelayMillis)
currentProgress += progressIncrement
}
}
查看刚刚添加的代码时,您会在 Android Studio 中调用 delay()
函数的左侧看到一个图标,如以下屏幕截图所示:
此图标表示函数可能暂停并在稍后再次恢复执行的挂起点。
协程等待完成延迟时间时,主线程不会被阻塞,如以下图所示
协程在调用 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()
函数,并执行以下步骤
- 打开位于
com.example.racetracker.ui
包中的RaceTrackerApp.kt
文件。 - 导航到
RaceTrackerApp()
可组合项,并在定义raceInProgress
的行后面添加对LaunchedEffect()
可组合项的调用。
@Composable
fun RaceTrackerApp() {
...
var raceInProgress by remember { mutableStateOf(false) }
LaunchedEffect {
}
RaceTrackerScreen(...)
}
- 为确保如果
playerOne
或playerTwo
的实例被替换为不同的实例时,LaunchedEffect()
需要取消并重新启动底层协程,请将playerOne
和playerTwo
对象作为LaunchedEffect
的key
添加。与当文本值更改时Text()
可组合项会重新组合类似,如果LaunchedEffect()
的任何 key 参数发生更改,则底层协程会被取消并重新启动。
LaunchedEffect(playerOne, playerTwo) {
}
- 添加对
playerOne.run()
和playerTwo.run()
函数的调用。
@Composable
fun RaceTrackerApp() {
...
var raceInProgress by remember { mutableStateOf(false) }
LaunchedEffect(playerOne, playerTwo) {
playerOne.run()
playerTwo.run()
}
RaceTrackerScreen(...)
}
- 使用
if
条件将LaunchedEffect()
块包裹起来。此状态的初始值为false
。当用户点击开始按钮且LaunchedEffect()
执行时,raceInProgress
状态的值会更新为true
。
if (raceInProgress) {
LaunchedEffect(playerOne, playerTwo) {
playerOne.run()
playerTwo.run()
}
}
- 将
raceInProgress
标志更新为false
以结束比赛。当用户点击暂停时,此值也会设置为false
。当此值设置为false
时,LaunchedEffect()
会确保所有已启动的协程都被取消。
LaunchedEffect(playerOne, playerTwo) {
playerOne.run()
playerTwo.run()
raceInProgress = false
}
- 运行应用并点击开始。您应该会看到玩家一在玩家二开始跑步之前完成了比赛,如以下视频所示
这看起来不像一场公平的比赛!在下一部分中,您将学习如何启动并发任务,以便两名玩家可以同时跑步,了解相关概念并实现此行为。
5. 结构化并发
您使用协程编写代码的方式称为结构化并发。这种编程风格提高了代码的可读性和开发时间。结构化并发的思想是协程具有层级关系 - 任务可以启动子任务,子任务又可以启动其子任务。此层级关系的单位称为协程作用域 (CoroutineScope)。协程作用域应始终与生命周期相关联。
协程 API 在设计上遵循这种结构化并发。您不能从未标记为 suspend 的函数调用 suspend 函数。此限制确保您从 launch
等协程构建器调用 suspend 函数。这些构建器反过来又绑定到 CoroutineScope
。
6. 启动并发任务
- 为了让两名参与者并发运行,您需要启动两个独立的协程,并将对
run()
函数的每个调用移到这些协程内部。使用launch
构建器包裹对playerOne.run()
的调用。
LaunchedEffect(playerOne, playerTwo) {
launch { playerOne.run() }
playerTwo.run()
raceInProgress = false
}
- 同样,使用
launch
构建器包裹对playerTwo.run()
函数的调用。通过此更改,应用会启动两个并发执行的协程。现在两名玩家可以同时跑步了。
LaunchedEffect(playerOne, playerTwo) {
launch { playerOne.run() }
launch { playerTwo.run() }
raceInProgress = false
}
- 运行应用并点击开始。虽然您预期比赛会开始,但按钮文本意外地立即变回了开始。
当两名玩家完成跑步后,赛道追踪器应用应该将暂停按钮的文本重置回开始。然而,现在应用在启动协程后立即更新了 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()
函数后返回。
- 为确保
playerOne
和playerTwo
的run()
函数在更新raceInProgress
标志之前完成执行,请使用coroutineScope
块包裹两个 launch 构建器。
LaunchedEffect(playerOne, playerTwo) {
coroutineScope {
launch { playerOne.run() }
launch { playerTwo.run() }
}
raceInProgress = false
}
- 在模拟器/Android 设备上运行应用。您应该会看到以下屏幕
- 点击开始按钮。玩家 2 跑得比玩家 1 快。比赛完成后(即两名玩家都达到 100% 进度时),暂停按钮的标签会变为开始。您可以点击重置按钮来重置比赛并重新执行模拟。比赛如以下视频所示。
执行流程如以下图所示
- 当
LaunchedEffect()
块执行时,控制权会转移到coroutineScope{..}
块。 coroutineScope
块会并发启动两个协程并等待它们完成执行。- 执行完成后,
raceInProgress
标志会更新。
coroutineScope
块只有在块内的所有代码执行完毕后才会返回并继续。对于块外的代码而言,并发的存在与否仅是一个实现细节。这种编码风格为并发编程提供了一种结构化方法,被称为结构化并发。
比赛完成后,当您点击重置按钮时,协程会被取消,并且两名玩家的进度都会重置为 0
。
要了解当用户点击重置按钮时协程是如何被取消的,请按照以下步骤操作
- 使用 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.
}
}
- 运行应用并点击开始按钮。
- 在进度增加一些后,点击重置按钮。
- 确保您在 Logcat 中看到打印出以下消息
Player 1: StandaloneCoroutine was cancelled Player 2: StandaloneCoroutine was cancelled
7. 编写单元测试来测试协程
对使用协程的代码进行单元测试需要额外注意,因为它们的执行可以是异步的,并且跨多个线程发生。
要在测试中调用 suspending 函数,您需要在协程中。由于 JUnit 测试函数本身不是 suspending 函数,因此您需要使用 runTest
协程构建器。此构建器是 kotlinx-coroutines-test
库的一部分,专为执行测试而设计。该构建器会在一个新的协程中执行测试主体。
由于 runTest
是 kotlinx-coroutines-test
库的一部分,您需要添加其依赖项。
要添加依赖项,请完成以下步骤
- 打开应用模块的
build.gradle.kts
文件,该文件位于 Project 面板的app
目录中。
- 在文件中,向下滚动直到找到
dependencies{}
块。 - 使用
testImplementation
配置为kotlinx-coroutines-test
库添加依赖项。
plugins {
...
}
android {
...
}
dependencies {
...
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4")
}
- 在 build.gradle.kts 文件顶部的通知栏中,点击立即同步,让导入和构建完成,如以下屏幕截图所示
构建完成后,您就可以开始编写测试了。
实现启动和完成比赛的单元测试
为确保比赛进度在比赛的不同阶段正确更新,您的单元测试需要覆盖不同的场景。对于本 Codelab,涵盖了两个场景
- 比赛开始后的进度。
- 比赛结束后的进度。
要检查比赛开始后比赛进度是否正确更新,您需要断言在经过 raceParticipant.progressDelayMillis
持续时间后,当前进度设置为 1。
要实现此测试场景,请按照以下步骤操作
- 导航到位于测试源集下的
RaceParticipantTest.kt
文件。 - 要定义测试,在
raceParticipant
定义之后,创建一个raceParticipant_RaceStarted_ProgressUpdated()
函数,并使用@Test
注解对其进行标注。由于测试块需要放在runTest
构建器中,因此使用表达式语法将runTest()
块作为测试结果返回。
class RaceParticipantTest {
private val raceParticipant = RaceParticipant(
...
)
@Test
fun raceParticipant_RaceStarted_ProgressUpdated() = runTest {
}
}
- 添加一个只读的
expectedProgress
变量并将其设置为1
。
@Test
fun raceParticipant_RaceStarted_ProgressUpdated() = runTest {
val expectedProgress = 1
}
- 为了模拟比赛开始,使用
launch
构建器启动一个新的协程并调用raceParticipant.run()
函数。
@Test
fun raceParticipant_RaceStarted_ProgressUpdated() = runTest {
val expectedProgress = 1
launch { raceParticipant.run() }
}
raceParticipant.progressDelayMillis
属性的值决定了比赛进度更新的持续时间。为了测试经过 progressDelayMillis
时间后的进度,您需要在测试中添加某种形式的延迟。
- 使用
advanceTimeBy()
辅助函数将时间前进raceParticipant.progressDelayMillis
的值。advanceTimeBy()
函数有助于缩短测试执行时间。
@Test
fun raceParticipant_RaceStarted_ProgressUpdated() = runTest {
val expectedProgress = 1
launch { raceParticipant.run() }
advanceTimeBy(raceParticipant.progressDelayMillis)
}
- 由于
advanceTimeBy()
不会运行在给定持续时间调度的任务,因此您需要调用runCurrent()
函数。此函数会执行当前时间下的所有待处理任务。
@Test
fun raceParticipant_RaceStarted_ProgressUpdated() = runTest {
val expectedProgress = 1
launch { raceParticipant.run() }
advanceTimeBy(raceParticipant.progressDelayMillis)
runCurrent()
}
- 为了确保进度更新,添加对
assertEquals()
函数的调用,以检查raceParticipant.currentProgress
属性的值是否与expectedProgress
变量的值匹配。
@Test
fun raceParticipant_RaceStarted_ProgressUpdated() = runTest {
val expectedProgress = 1
launch { raceParticipant.run() }
advanceTimeBy(raceParticipant.progressDelayMillis)
runCurrent()
assertEquals(expectedProgress, raceParticipant.currentProgress)
}
- 运行测试以确认其通过。
要检查比赛结束后比赛进度是否正确更新,您需要断言比赛结束时,当前进度设置为 100
。
按照以下步骤实现此测试
- 在
raceParticipant_RaceStarted_ProgressUpdated()
测试函数之后,创建一个raceParticipant_RaceFinished_ProgressUpdated()
函数,并使用@Test
注解对其进行标注。该函数应从runTest{}
块返回测试结果。
class RaceParticipantTest {
...
@Test
fun raceParticipant_RaceStarted_ProgressUpdated() = runTest {
...
}
@Test
fun raceParticipant_RaceFinished_ProgressUpdated() = runTest {
}
}
- 使用
launch
构建器启动一个新的协程,并在其中添加对raceParticipant.run()
函数的调用。
@Test
fun raceParticipant_RaceFinished_ProgressUpdated() = runTest {
launch { raceParticipant.run() }
}
- 为了模拟比赛结束,使用
advanceTimeBy()
函数将 dispatcher 的时间前进raceParticipant.maxProgress * raceParticipant.progressDelayMillis
。
@Test
fun raceParticipant_RaceFinished_ProgressUpdated() = runTest {
launch { raceParticipant.run() }
advanceTimeBy(raceParticipant.maxProgress * raceParticipant.progressDelayMillis)
}
- 添加对
runCurrent()
函数的调用,以执行所有待处理任务。
@Test
fun raceParticipant_RaceFinished_ProgressUpdated() = runTest {
launch { raceParticipant.run() }
advanceTimeBy(raceParticipant.maxProgress * raceParticipant.progressDelayMillis)
runCurrent()
}
- 为了确保进度正确更新,添加对
assertEquals()
函数的调用,以检查raceParticipant.currentProgress
属性的值是否等于100
。
@Test
fun raceParticipant_RaceFinished_ProgressUpdated() = runTest {
launch { raceParticipant.run() }
advanceTimeBy(raceParticipant.maxProgress * raceParticipant.progressDelayMillis)
runCurrent()
assertEquals(100, raceParticipant.currentProgress)
}
- 运行测试以确认其通过。
试试这项挑战
应用 为 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 函数调用。- 您可以使用
launch
或async
构建器函数启动新的协程。 - 协程上下文 (Coroutine context)、协程构建器 (coroutine builders)、Job、协程作用域 (coroutine scope) 和 Dispatcher 是实现协程的主要组件。
- 协程使用 dispatchers 来确定执行时使用的线程。
- Job 通过管理协程的生命周期和维护父子关系,在确保结构化并发方面发挥重要作用。
- A
CoroutineContext
使用 Job 和协程 dispatcher 定义协程的行为。 - A
CoroutineScope
通过其 Job 控制协程的生命周期,并将其取消及其他规则递归应用于其子协程及其子子协程。 - 启动 (Launch)、完成 (completion)、取消 (cancellation) 和失败 (failure) 是协程执行中的四种常见操作。
- 协程遵循结构化并发原则。