第3章 Kotlin协程基础回顾:启动协程、挂起函数、CoroutineScope与Job

本章从单元测试需要的角度回顾 Kotlin 协程的核心概念:启动方式、挂起函数、作用域与 Job。理解这些基础后,你才能准确控制协程的生命周期、隔离测试副作用,并为后续章节的 Worker 单元测试打下坚实根基。

3.1 协程启动的三种方式

Kotlin 提供 launch、async、runBlocking 三种启动方式,它们的使用场景和返回类型完全不同。

启动方式返回类型适用场景典型错误
launchJob非阻塞地发起一个“即发即忘”的协程在测试中忘记 join() 导致协程未执行完
asyncDeferred<T>需要获取异步计算结果(类似 Future)对 Deferred 调用 .await() 但作用域已取消
runBlockingT桥接阻塞代码与协程(测试中常用)在真实业务代码中滥用导致线程阻塞
测试提示: 单元测试中推荐使用 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 / lifecycleScopeAndroid 组件作用域随组件销毁自动取消
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 之间的交互关系,以及典型生命周期。

协程核心概念关系图 CoroutineScope 管理协程生命周期 launch / async 启动子协程 Job / Deferred 生命周期句柄 suspend 函数 可挂起的异步操作 子协程 (children) cancel() / join() 结构化并发:作用域取消 → 所有子协程自动取消;挂起函数在挂起点可响应取消信号。

3.6 常见错误与调试建议

  1. 在非协程作用域调用挂起函数:编译报错 “Suspend function 'xxx' should be called only from a coroutine or another suspend function”。解决方案:使用 runBlocking 或在协程内调用。
  2. 忘记调用 join() 导致测试提前结束:使用 runBlocking { job.join() } 确保协程完成。
  3. 错误地取消整个作用域后仍访问 Deferred:在 finally 块中使用 isActive 或 ensureActive() 检查。
  4. 使用 GlobalScope 启动协程:在 Worker/ViewModel 中应绑定自定义作用域,否则无法随组件取消。
调试技巧: 在协程体内打印当前线程与协程名称(通过 coroutineContext[CoroutineName]),并结合 kotlinx.coroutines.debug 属性(JVM 参数添加 -Dkotlinx.coroutines.debug)获得协程堆栈。