25、协程与协程调试:协程的调试技巧、Android Studio的协程调试器、协程的日志与跟踪、协程的堆栈分析
协程写起来很爽,但一旦出问题,调试起来就让人头疼了。说实话,我刚开始用协程那会儿,遇到个死锁问题,愣是盯着代码看了两个小时没找到原因。后来我才意识到——协程的调试,跟普通线程调试完全是两码事。
为什么?因为协程是「挂起」的。线程的调用栈是连续的,你打断点就能看到谁调了谁。但协程呢?它可以在 A 线程挂起,在 B 线程恢复。你想想看,传统的断点调试在这种场景下基本就废了。
所以这一章,我就把我在项目中积累的协程调试经验,系统地给你梳理一遍。从 Android Studio 的协程调试器,到日志跟踪,再到堆栈分析,咱们一个一个来。
Android Studio 的协程调试器
从 Android Studio 4.1 开始,官方就内置了协程调试器。这个工具我几乎每天都在用。它藏在 Debug 面板的「Coroutines」标签里。
打开它,你会看到当前所有活跃的协程,按作用域分组。每个协程都标明了状态:Running、Suspended、Completed 或 Cancelled。
核心用法:
- 点击某个协程,可以看到它的创建位置和当前挂起点
- 双击协程,IDE 会直接跳转到对应的源码行
- 在协程上右键,可以「Force Cancel」强制取消它
我记得有一次线上反馈说某个页面加载特别慢。我本地复现后,打开协程调试器一看——好家伙,三个协程都在等待同一个网络请求,而且那个请求因为超时设置不合理,挂了 30 秒才返回。要不是协程调试器把依赖关系画得清清楚楚,我根本想不到是这种「串行等待」的问题。
小技巧:调试时可以在协程的挂起点打上断点。比如在 delay()、withContext()、await() 这些函数上打断点,IDE 会帮你自动识别协程上下文。
协程的日志与跟踪
调试器虽好,但线上环境没法用。这时候就得靠日志了。我个人的习惯是,在协程的创建和关键节点都加上日志,并且带上协程的唯一标识。
Kotlin 协程库提供了一个非常实用的属性:coroutineContext[CoroutineName]。你可以给每个协程起个名字,然后在日志里打印出来。
// 给协程命名
viewModelScope.launch(CoroutineName("LoadUserProfile") + Dispatchers.IO) {
Log.d("CoroutineTrace", "开始加载用户资料")
val user = userRepository.getUser()
Log.d("CoroutineTrace", "用户资料加载完成")
withContext(Dispatchers.Main) {
// 更新 UI
}
}
但这样手动加日志太累了。我推荐用 CoroutineExceptionHandler 结合自定义拦截器来做统一日志。
val coroutineLogger = CoroutineExceptionHandler { _, throwable ->
Log.e("CoroutineError", "协程异常: ${throwable.message}", throwable)
}
// 在 ViewModel 中统一使用
viewModelScope.launch(coroutineLogger) {
// ...
}
更高级的做法是使用 kotlinx-coroutines-android 提供的 CoroutineScope 工厂方法,配合自定义的 CoroutineContext 元素,实现全局的协程生命周期跟踪。
注意:线上环境不要打印过多的协程日志,尤其是高频协程(比如搜索防抖)。我见过有人把日志打印到 Firebase 里,结果一个月流量费多了好几千。建议只在 Debug 构建下开启详细日志。
协程的堆栈分析
协程的堆栈跟线程堆栈不一样。线程堆栈是「实时的」,而协程堆栈是「快照式」的。当协程挂起时,它的局部变量和执行位置会被保存到 Continuation 对象里。
所以当你看到协程的堆栈时,你看到的其实是它挂起时的状态,而不是当前线程的状态。这一点非常关键。
我画了一张图,帮你理解协程堆栈的构成:
看明白了吗?左边是线程的调用栈,右边是协程挂起时保存的快照。当协程挂起时,线程栈会被「压缩」成一个 Continuation 对象,里面包含了恢复执行所需的所有信息。
所以在分析协程堆栈时,你要关注的是:
- 挂起点在哪里——哪个 suspend 函数导致了挂起
- 协程的创建位置——谁启动了它
- 协程的上下文——Dispatcher、Job、CoroutineName 等
我曾经遇到过一个诡异的 bug:某个协程在特定条件下会「凭空消失」。我翻遍了日志,就是找不到它被取消的记录。后来我用 Thread.getStackTrace() 配合协程的 coroutineContext[Job] 打印了完整的协程层级,才发现是父协程被取消了,子协程跟着被级联取消。而父协程的取消原因,是因为一个超时任务触发了 withTimeout 异常。
调试利器:在协程内部调用 coroutineContext[Job]?.children?.forEach { ... } 可以遍历所有子协程。配合 CoroutineName,你能清晰地看到整个协程树的结构。
实战:协程死锁的排查
协程死锁比线程死锁更难发现。因为线程死锁时,JVM 的线程转储会直接告诉你「线程 A 持有锁 1 等待锁 2,线程 B 持有锁 2 等待锁 1」。但协程死锁呢?它可能只是两个协程互相等待对方完成,而线程本身是空闲的。
我分享一个我排查过的案例:
// 错误示例:协程死锁
scope.launch {
val result1 = async { fetchData1() }
val result2 = async { fetchData2() }
// 这里死锁了!
val combined = result1.await() + result2.await()
}
suspend fun fetchData1(): String {
delay(100)
// 等待 fetchData2 的结果
return "Data1"
}
suspend fun fetchData2(): String {
delay(100)
// 等待 fetchData1 的结果
return "Data2"
}
这个例子看起来简单,但实际项目中,这种循环依赖往往藏在多层嵌套的协程里。排查方法就是:
- 在协程调试器里查看所有协程的状态,看哪些协程长期处于 Suspended 状态
- 查看每个挂起协程的挂起点,看它们都在等什么
- 检查是否有循环等待的依赖关系
说白了,协程调试的核心就是「把隐式的控制流显式化」。无论是用调试器、日志还是堆栈分析,目的都是把协程的挂起、恢复、取消这些操作变得可见、可追踪。
总结一下我的调试三板斧:
- 开发阶段:用 Android Studio 协程调试器,打断点看挂起点
- 测试阶段:加 CoroutineName 和日志,用 CoroutineExceptionHandler 捕获异常
- 线上阶段:用自定义的协程跟踪器,记录协程的创建、挂起、完成事件,上报到监控平台
嗯,协程调试这块内容就这么多。你只要把这三板斧用熟了,大部分协程问题都能快速定位。记住一点:协程不是魔法,它只是把异步逻辑包装成了顺序代码。调试的时候,把它当成「有挂起能力的函数调用」来理解,思路就清晰了。
公众号:蓝海资料掘金营,微信deep3321