25、协程与协程调试:协程的调试技巧、Android Studio的协程调试器、协程的日志与跟踪、协程的堆栈分析

协程写起来很爽,但一旦出问题,调试起来就让人头疼了。说实话,我刚开始用协程那会儿,遇到个死锁问题,愣是盯着代码看了两个小时没找到原因。后来我才意识到——协程的调试,跟普通线程调试完全是两码事。

为什么?因为协程是「挂起」的。线程的调用栈是连续的,你打断点就能看到谁调了谁。但协程呢?它可以在 A 线程挂起,在 B 线程恢复。你想想看,传统的断点调试在这种场景下基本就废了。

所以这一章,我就把我在项目中积累的协程调试经验,系统地给你梳理一遍。从 Android Studio 的协程调试器,到日志跟踪,再到堆栈分析,咱们一个一个来。

Android Studio 的协程调试器

从 Android Studio 4.1 开始,官方就内置了协程调试器。这个工具我几乎每天都在用。它藏在 Debug 面板的「Coroutines」标签里。

打开它,你会看到当前所有活跃的协程,按作用域分组。每个协程都标明了状态:RunningSuspendedCompletedCancelled

核心用法:

  • 点击某个协程,可以看到它的创建位置和当前挂起点
  • 双击协程,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 对象里。

所以当你看到协程的堆栈时,你看到的其实是它挂起时的状态,而不是当前线程的状态。这一点非常关键。

我画了一张图,帮你理解协程堆栈的构成:

协程堆栈结构示意图 线程调用栈 main() → 调用 launch DispatchedContinuation.dispatch() BaseContinuationImpl.resumeWith() 协程挂起 → 堆栈被压缩 协程挂起栈(Continuation) suspend fun fetchData() → await() 挂起点 → 局部变量快照 → 恢复点标签 挂起时 线程栈是连续的,协程栈是快照式的

看明白了吗?左边是线程的调用栈,右边是协程挂起时保存的快照。当协程挂起时,线程栈会被「压缩」成一个 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"
}

这个例子看起来简单,但实际项目中,这种循环依赖往往藏在多层嵌套的协程里。排查方法就是:

  1. 在协程调试器里查看所有协程的状态,看哪些协程长期处于 Suspended 状态
  2. 查看每个挂起协程的挂起点,看它们都在等什么
  3. 检查是否有循环等待的依赖关系

说白了,协程调试的核心就是「把隐式的控制流显式化」。无论是用调试器、日志还是堆栈分析,目的都是把协程的挂起、恢复、取消这些操作变得可见、可追踪。

总结一下我的调试三板斧:

  • 开发阶段:用 Android Studio 协程调试器,打断点看挂起点
  • 测试阶段:加 CoroutineName 和日志,用 CoroutineExceptionHandler 捕获异常
  • 线上阶段:用自定义的协程跟踪器,记录协程的创建、挂起、完成事件,上报到监控平台

嗯,协程调试这块内容就这么多。你只要把这三板斧用熟了,大部分协程问题都能快速定位。记住一点:协程不是魔法,它只是把异步逻辑包装成了顺序代码。调试的时候,把它当成「有挂起能力的函数调用」来理解,思路就清晰了。


公众号:蓝海资料掘金营,微信deep3321