第3章 Kotlin协程基础回顾:启动协程、挂起函数、CoroutineScope与Job
本章从单元测试需要的角度回顾 Kotlin 协程的核心概念:启动方式、挂起函数、作用域与 Job。理解这些基础后,你才能准确控制协程的生命周期、隔离测试副作用,并为后续章节的 Worker 单元测试打下坚实根基。
3.1 协程启动的三种方式
Kotlin 提供 launch、async、runBlocking 三种启动方式,它们的使用场景和返回类型完全不同。
| 启动方式 | 返回类型 | 适用场景 | 典型错误 |
|---|---|---|---|
launch | Job | 非阻塞地发起一个“即发即忘”的协程 | 在测试中忘记 join() 导致协程未执行完 |
async | Deferred<T> | 需要获取异步计算结果(类似 Future) | 对 Deferred 调用 .await() 但作用域已取消 |
runBlocking | T | 桥接阻塞代码与协程(测试中常用) | 在真实业务代码中滥用导致线程阻塞 |
测试提示: 单元测试中推荐使用
runBlocking 包装测试体,并配合 TestCoroutineDispatcher 控制时间,不要依赖真实的 delay。
3.2 挂起函数(suspend 函数)的本质
挂起函数是协程的核心抽象。用 suspend 关键字标记的函数可以被挂起而不阻塞线程,恢复时继续执行。
- 挂起点: 调用另一个挂起函数(如
delay、withContext)的位置。 - 状态保存: 编译器将挂起函数编译为状态机,每个挂起点对应一个状态。
- 与普通函数区别: 挂起函数只能在协程或其他挂起函数中调用;不能从普通函数直接调用。
// 示例:一个模拟网络请求的挂起函数
suspend fun fetchUser(id: String): User {
delay(500L) // 挂起点
return User(id, "TestUser")
}
// 测试中使用 runBlocking 调用
fun testFetchUser() = runBlocking {
val user = fetchUser("123")
println(user)
}
3.3 CoroutineScope:结构化并发的容器
CoroutineScope 定义了协程的上下文和生命周期。所有协程都必须在一个作用域内启动,作用域取消时,其所有子协程自动取消。
| 常用作用域 | 典型用途 | 取消行为 |
|---|---|---|
GlobalScope | 顶层全局协程(不推荐生产使用) | 独立于应用生命周期,容易被遗忘 |
CoroutineScope(Dispatchers.IO) | 自定义作用域,通常绑定 ViewModel/Worker | 调用 cancel() 取消所有子协程 |
viewModelScope / lifecycleScope | Android 组件作用域 | 随组件销毁自动取消 |
TestScope(测试) | 单元测试中可控的虚拟时间 | 手动 cancel() 或随测试结束 |
常见错误: 在 Worker 或后台任务中使用
GlobalScope 启动协程,导致任务无法随 Worker 生命周期取消,引发内存泄漏或资源浪费。始终将协程作用域绑定到 Worker 本身。
3.4 Job:协程的生命周期句柄
每个 launch 返回一个 Job,async 返回 Deferred(继承自 Job)。Job 具有状态机:New → Active → Completing → Completed(或 Cancelling → Cancelled)。
- join():等待 Job 完成(挂起当前协程)。
- cancel():取消 Job,子协程也会被取消(结构化并发)。
- children:通过
job.children遍历所有子 Job。 - isActive:检查协程是否仍在活跃状态(常用于取消检测)。
// 使用 Job 管理协程生命周期
val scope = CoroutineScope(Dispatchers.Default)
val job = scope.launch {
doSomeWork()
}
// 在 Worker 停止时取消
scope.cancel() // 或 job.cancel(),效果相同(结构化父级取消)
// 等待完成(测试场景)
runBlocking {
job.join()
println("Job completed")
}
3.5 核心概念关系图
下面的 SVG 展示了协程启动、挂起函数、CoroutineScope 与 Job 之间的交互关系,以及典型生命周期。
3.6 常见错误与调试建议
- 在非协程作用域调用挂起函数:编译报错 “Suspend function 'xxx' should be called only from a coroutine or another suspend function”。解决方案:使用
runBlocking或在协程内调用。 - 忘记调用 join() 导致测试提前结束:使用
runBlocking { job.join() }确保协程完成。 - 错误地取消整个作用域后仍访问 Deferred:在
finally块中使用isActive或ensureActive()检查。 - 使用 GlobalScope 启动协程:在 Worker/ViewModel 中应绑定自定义作用域,否则无法随组件取消。
调试技巧: 在协程体内打印当前线程与协程名称(通过
coroutineContext[CoroutineName]),并结合 kotlinx.coroutines.debug 属性(JVM 参数添加 -Dkotlinx.coroutines.debug)获得协程堆栈。