前言
合体初成,你已将协程融入 Android 四大组件。CoroutineWorker、Room、Retrofit、Compose 皆与协程浑然一体,你的架构如臂使指。然而,当你在生产环境面对崩溃日志时,当你在单元测试中苦等 delay 超时时,新的困惑悄然浮现:
- 1、为什么协程的堆栈信息如此难以阅读?那些
Continuation和invokeSuspend是什么鬼?- 2、测试中用了
delay(5000),难道真要让测试跑 5 秒?那我几百个测试用例要跑到猴年马月?- 3、
viewModelScope启动的协程,在单元测试里根本不执行,怎么办?- 4、如何测试
StateFlow发射的多个值?如何验证SharedFlow收到的事件序列?
这些问题指向协程开发中两个关键领域:调试 与 测试。不了解协程的调试技巧,你将在堆栈迷宫中寸步难行;不掌握协程的测试工具,你的单元测试将缓慢且不可靠。
本讲是合体境的中阶修炼。你将开启“天眼”,看穿协程的堆栈迷雾,掌控虚拟时间的流动:
- 理解协程堆栈为何难以阅读,以及如何使用 Debug 模式还原真实调用链。
- 掌握
runTest的核心魔法——虚拟时间调度器,让delay瞬间完成。 - 学会测试
viewModelScope中的协程,用TestDispatcher控制执行时机。 - 掌握
StateFlow和SharedFlow的测试断言技巧。
准备好开启天眼,洞察协程调试与测试的玄机了吗?我们开始。
操千曲而后晓声,观千剑而后识器。虐它千百遍方能通晓其真意。
协程堆栈的迷雾与 Debug 模式的救赎
为什么协程堆栈如此难读?
协程的挂起与恢复机制,本质上是将函数切分为多个状态机片段。当协程挂起时,调用栈被清空,线程被释放;恢复时,状态机从断点继续执行。这意味着,传统的线程调用栈无法完整呈现协程的执行路径。
看看下面这个崩溃堆栈:
java.lang.NullPointerException
at com.example.UserViewModel$loadUser$1.invokeSuspend(UserViewModel.kt:24)
at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:106)
...
你只能看到 invokeSuspend 和 DispatchedTask,却不知道 loadUser 是在哪个协程中被调用的,也不知道调用链的上游是什么。这对于排查问题极为不利。
开启协程 Debug 模式
Kotlin 协程提供了 Debug 模式,通过在 JVM 启动参数中添加 -Dkotlinx.coroutines.debug,协程运行时会保留创建时的调用栈快照,并将其附加到协程名称中。
开启方式:在 app/build.gradle.kts 中:
android {
// ...
testOptions {
unitTests.all {
it.jvmArgs("-Dkotlinx.coroutines.debug")
}
}
}
或者在运行配置中添加 VM options:-Dkotlinx.coroutines.debug
开启后,协程的名称会包含创建时的堆栈信息,例如:
"coroutine#2":StandaloneCoroutine{Active}@1b6d3586, created at UserViewModel.loadUser(UserViewModel.kt:18)
堆栈追踪也会更加清晰,包含 CoroutineName 和创建位置。
flowchart LR
subgraph WithoutDebug[无 Debug 模式]
W1[崩溃堆栈] --> W2[invokeSuspend]
W2 --> W3[BaseContinuationImpl]
W3 --> W4[DispatchedTask]
end
subgraph WithDebug[有 Debug 模式]
D1[崩溃堆栈] --> D2[协程名称含创建位置]
D2 --> D3[完整挂起调用链]
D3 --> D4[用户代码具体行号]
end
style WithoutDebug fill:#ffcdd2,stroke:#b71c1c,stroke-width:2px
style WithDebug fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px
style W1 fill:#ef9a9a
style D1 fill:#a5d6a7
最佳实践:在开发和测试阶段始终开启 Debug 模式;生产环境建议关闭以避免性能开销。
runTest:操纵时间的终极测试法器
传统测试的痛点:真实的 delay
假设你要测试一个倒计时功能:
fun countdown(seconds: Int): Flow<Int> = flow {
for (i in seconds downTo 1) {
delay(1000)
emit(i)
}
}
// 传统测试
@Test
fun testCountdown() = runBlocking {
val results = mutableListOf<Int>()
countdown(5).collect { results.add(it) }
assertEquals(listOf(5, 4, 3, 2, 1), results)
}
这个测试将真实等待 5 秒!几百个这样的测试将让 CI 流程不堪重负。
runTest 与虚拟时间
runTest 是 kotlinx-coroutines-test 库提供的测试协程构建器。它的核心魔法是:所有 delay 都会被虚拟时间调度器拦截,时间在测试代码中“瞬间”流逝,但 delay 的挂起和恢复语义完全保留。
import kotlinx.coroutines.test.runTest
@Test
fun testCountdownWithVirtualTime() = runTest {
val results = mutableListOf<Int>()
val job = launch {
countdown(5).collect { results.add(it) }
}
// 虚拟时间前进 5 秒,所有 delay 瞬间完成
advanceTimeBy(5000)
assertEquals(listOf(5, 4, 3, 2, 1), results)
job.cancel()
}
runTest 会自动跳过 delay 的等待时间,除非你需要精确控制时间推进(使用 advanceTimeBy 或 advanceUntilIdle)。
sequenceDiagram
participant Test as 测试协程
participant Flow as countdown Flow
participant Time as 虚拟时间调度器
Test->>Flow: collect
Flow->>Time: delay(1000)
Time-->>Flow: 立即恢复(时间瞬间过 1 秒)
Flow->>Test: emit(5)
Flow->>Time: delay(1000)
Time-->>Flow: 立即恢复
Flow->>Test: emit(4)
Note over Test: 所有 delay 瞬间完成,无需真实等待
runTest 的核心能力
| 功能 | 说明 |
|---|---|
advanceTimeBy(ms) | 手动推进虚拟时间 |
advanceUntilIdle() | 推进时间直到没有待处理的挂起任务 |
currentTime | 获取当前虚拟时间戳 |
runCurrent() | 执行当前队列中所有已到期的任务 |
@Test
fun testDebounce() = runTest {
val flow = MutableSharedFlow<Int>()
val results = mutableListOf<Int>()
flow.debounce(300).collect { results.add(it) }
flow.emit(1)
advanceTimeBy(200) // 未到 300ms
flow.emit(2) // 重置计时器
advanceTimeBy(200) // 仍未到
assertEquals(emptyList<Int>(), results)
advanceTimeBy(100) // 总共 300ms,debounce 触发
assertEquals(listOf(2), results)
}
测试 viewModelScope:TestDispatcher 与作用域替换
viewModelScope 在测试中的问题
viewModelScope 默认使用 Dispatchers.Main.immediate,而单元测试环境中没有主线程 Looper,导致 viewModelScope.launch 中的代码不会被执行。
使用 TestDispatcher 替换 Main 调度器
kotlinx-coroutines-test 提供了 TestDispatcher 和 setMain 扩展函数,让你在测试前将 Dispatchers.Main 替换为测试调度器。
import kotlinx.coroutines.test.*
@Before
fun setUp() {
Dispatchers.setMain(StandardTestDispatcher())
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun testViewModel() = runTest {
val viewModel = MyViewModel()
viewModel.loadData()
// 执行所有已排队的任务
advanceUntilIdle()
assertEquals(UiState.Success, viewModel.uiState.value)
}
关键:StandardTestDispatcher 需要手动调用 advanceUntilIdle() 或 runCurrent() 来执行排队的协程任务。
flowchart LR
subgraph TestEnv[测试环境]
T[runTest] --> TD[TestDispatcher]
TD --> Queue[任务队列]
end
subgraph ViewModel[ViewModel]
VS[viewModelScope.launch]
end
VS -->|提交任务| Queue
TD -->|advanceUntilIdle| Queue
Queue -->|执行| Coroutine[协程体]
style TestEnv fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
style ViewModel fill:#e3f2fd,stroke:#1976d2,stroke-width:2px
style TD fill:#a5d6a7
style Queue fill:#ffb74d
测试 StateFlow 与 SharedFlow
测试 StateFlow
StateFlow 的测试重点在于:初始值、更新序列、去重。
@Test
fun testStateFlow() = runTest {
val state = MutableStateFlow(0)
val results = mutableListOf<Int>()
val job = launch {
state.collect { results.add(it) }
}
state.value = 1
state.value = 1 // 去重,不会发射
state.value = 2
advanceUntilIdle()
assertEquals(listOf(0, 1, 2), results)
job.cancel()
}
测试 SharedFlow
SharedFlow 需要验证:重放、缓冲、多订阅者。
@Test
fun testSharedFlow() = runTest {
val sharedFlow = MutableSharedFlow<Int>(replay = 1)
// 预先发射一个值
sharedFlow.emit(1)
val results = mutableListOf<Int>()
val job = launch {
sharedFlow.collect { results.add(it) }
}
// 由于 replay=1,新订阅者立即收到 1
advanceUntilIdle()
assertEquals(listOf(1), results)
sharedFlow.emit(2)
advanceUntilIdle()
assertEquals(listOf(1, 2), results)
job.cancel()
}
实战:完整的 ViewModel + 协程测试
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.*
import org.junit.*
@OptIn(ExperimentalCoroutinesApi::class)
class UserViewModelTest {
private lateinit var repository: FakeUserRepository
private lateinit var viewModel: UserViewModel
private val testDispatcher = StandardTestDispatcher()
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
repository = FakeUserRepository()
viewModel = UserViewModel(repository)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `loadUser success updates uiState to Success`() = runTest {
// Given
val expectedUser = User("张三")
repository.setUser(expectedUser)
// When
viewModel.loadUser("123")
advanceUntilIdle() // 等待 viewModelScope 中的协程完成
// Then
assertTrue(viewModel.uiState.value is UiState.Success)
assertEquals(expectedUser, (viewModel.uiState.value as UiState.Success).user)
}
@Test
fun `loadUser error updates uiState to Error`() = runTest {
// Given
repository.setError(IOException("网络错误"))
// When
viewModel.loadUser("123")
advanceUntilIdle()
// Then
assertTrue(viewModel.uiState.value is UiState.Error)
}
@Test
fun `retry triggers reload`() = runTest {
// Given
repository.setError(IOException())
viewModel.loadUser("123")
advanceUntilIdle()
assertTrue(viewModel.uiState.value is UiState.Error)
// When
repository.setUser(User("李四"))
viewModel.retry()
advanceUntilIdle()
// Then
assertTrue(viewModel.uiState.value is UiState.Success)
}
}
// 假 Repository
class FakeUserRepository {
private var user: User? = null
private var error: Throwable? = null
fun setUser(user: User) {
this.user = user
this.error = null
}
fun setError(error: Throwable) {
this.error = error
this.user = null
}
suspend fun getUser(id: String): User {
delay(100) // 模拟网络延迟,runTest 中瞬间完成
error?.let { throw it }
return user ?: error("未设置数据")
}
}
flowchart TD
subgraph Setup[测试准备]
S1[setMain TestDispatcher]
S2[创建 FakeRepository]
S3[创建 ViewModel]
end
subgraph Action[执行操作]
A1[viewModel.loadUser]
A2[advanceUntilIdle]
end
subgraph Assert[断言验证]
AS1[验证 uiState 类型]
AS2[验证数据内容]
end
Setup --> Action --> Assert
style Setup fill:#e3f2fd,stroke:#1976d2,stroke-width:2px
style Action fill:#fff3e0,stroke:#f57c00,stroke-width:2px
style Assert fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px
常见错误与避坑指南
错误 1:忘记 advanceUntilIdle,导致测试永远等待
@Test
fun test() = runTest {
viewModel.loadData()
// 没有 advanceUntilIdle,StateFlow 永远不会更新
assertEquals(Loading, viewModel.uiState.value) // 失败或超时
}
正确:在 runTest 中,使用 StandardTestDispatcher 时必须手动推进时间或调用 advanceUntilIdle()。
错误 2:在 runBlocking 中测试协程,导致真实延迟
@Test
fun test() = runBlocking {
delay(5000) // 真实等待 5 秒!
}
正确:使用 runTest,其内部 delay 是虚拟的。
错误 3:未重置 Dispatchers.Main,导致测试间相互污染
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
}
// 忘记 @After 中 resetMain
正确:务必在 @After 中调用 Dispatchers.resetMain()。
最佳实践
- 所有协程测试使用
runTest:避免真实延迟,加快测试速度。 - 在
@Before中替换Dispatchers.Main,@After中重置:保证测试隔离。 - 使用
advanceUntilIdle()等待协程完成:简单可靠。 - 为 ViewModel 的 StateFlow 编写状态转换测试:验证完整的状态序列。
- 使用
backgroundScope管理测试中的后台协程:自动在测试结束时取消。
总结与下回预告
恭喜,你已开启天眼,掌握了协程调试与测试的核心奥义,合体境中阶修炼完成!
本讲核心收获:
- Debug 模式可还原协程创建位置,改善堆栈可读性。
runTest使用虚拟时间,让delay瞬间完成。TestDispatcher替换Dispatchers.Main,使viewModelScope可测试。advanceUntilIdle控制协程任务执行时机。StateFlow和SharedFlow的测试断言技巧。
在下一讲 【合体境·后阶】 中,我们将深入协程的性能优化与线程池调优:Dispatchers.IO 的弹性机制、limitedParallelism 限流、以及如何避免常见的内存泄漏。届时你会明白:
- 如何限制
Dispatchers.IO的最大并发数? viewModelScope与lifecycleScope的内部取消机制有何不同?- 如何用
WeakReference+ 协程排查泄漏?
【当前境界修为面板】
| 当前境界 | 修炼技能 | 修炼进度 | 修炼心得 |
|---|---|---|---|
| 合体境 · 中阶 | 1、Debug 天眼术2、 runTest 时间操纵诀3、 TestDispatcher 作用域替换法 | 当前进度:70%修为: 700/1000下一突破: [合体境 · 后阶] (需领悟:协程性能调优、limitedParallelism、泄漏排查) | runTest让delay瞬间完成。虚拟时间操纵术是协程测试的终极法器。 |
【本讲思考题】
-
表象题:以下测试有什么问题?
@Test fun test() = runBlocking { val result = mySuspendFunction() assertEquals(expected, result) } -
场景题:你需要测试一个
ViewModel,它在init块中通过viewModelScope.launch启动了一个无限循环的协程(while(true) { delay(1000); updateState() })。如何在不超时的情况下测试updateState是否被正确调用?写出关键测试代码。 -
原理题:
runTest的虚拟时间是如何实现的?delay被替换成了什么?请从TestCoroutineScheduler的角度简述。
道友,合体境的最后一道关隘已在眼前。掌握了性能调优,你的协程应用将如臂使指、行云流水。合体境·后阶见。
欢迎一键四连(
关注+点赞+收藏+评论)