Logo SagesTool

Kotlin 协程崩溃与主线程卡顿排查指南

Kotlin 协程崩溃与主线程卡顿排查指南

很多人刚把项目从回调、RxJava 或旧线程模型迁到 Kotlin 协程时,第一感觉都很好:代码终于看起来像同步逻辑了。

但跑一段时间后,问题就开始冒出来:

  • 明明用了协程,为什么界面还是卡?
  • 为什么一个子协程抛异常,整个页面甚至整个 App 都一起崩了?
  • 为什么 CoroutineExceptionHandler 有时候像生效了,有时候又像没生效?

这些问题通常都和两个核心点有关:

  1. 线程切换没有做对
  2. 异常边界没有划清楚

一、suspend 不等于自动切后台

这是最常见的误区。

很多人看到 suspend,会下意识以为这段代码一定不在主线程上跑。其实不是。suspend 只表示这个函数可以挂起,它并不会帮你自动切换 Dispatcher。

class ProfileViewModel : ViewModel() {
    fun loadProfile() {
        viewModelScope.launch {
            val profile = fetchProfile()
            render(profile)
        }
    }

    private suspend fun fetchProfile(): Profile {
        Thread.sleep(3000)
        return Profile("Sage")
    }
}

这里的 viewModelScope.launch 默认就在 Dispatchers.Main 上执行。fetchProfile() 虽然是 suspend,但内部用了阻塞调用,结果还是把主线程直接卡死了。

正确做法

耗时任务要在函数内部自行保证“主线程安全”:

private suspend fun fetchProfile(): Profile = withContext(Dispatchers.IO) {
    api.getProfile()
}

这样调用方不需要关心线程细节,主线程也不会被拖住。

Dispatcher 怎么选

  • Dispatchers.Main:更新 UI、处理轻量交互
  • Dispatchers.IO:网络、数据库、文件读写
  • Dispatchers.Default:CPU 密集计算,如 JSON 解析、图片处理、复杂排序

如果你的 suspend 函数里做的是重运算,而不是 I/O,应该切到 Default,而不是一股脑都扔去 IO


二、为什么一个子协程失败会“连坐”

协程的异常传播和普通方法调用不完全一样,它遵循结构化并发。

默认情况下,子协程抛出的未处理异常会一路向上传递给父协程,父协程收到异常后,会取消它的其他子任务,并继续向上抛。

这就解释了为什么下面的代码会把整个作用域一起带崩:

viewModelScope.launch {
    launch {
        throw RuntimeException("network failed")
    }

    launch {
        delay(1000)
        println("I may never finish")
    }
}

第一个子协程失败后,第二个子协程也会被取消。


三、为什么 try-catch 没抓住异常

这是另一个常见坑:

try {
    viewModelScope.launch {
        api.load()
    }
} catch (e: Exception) {
    // 抓不到
}

原因很简单:launch 本身几乎是立即返回的,真正出错的是异步执行体内部,不在这个 try-catch 的同步调用栈里。

正确做法

try-catch 放进协程体内部:

viewModelScope.launch {
    try {
        val result = withContext(Dispatchers.IO) { api.load() }
        render(result)
    } catch (e: Exception) {
        showError(e.message ?: "unknown error")
    }
}

这才是最稳定、也最容易维护的方式。


四、CoroutineExceptionHandler 什么时候能用

CoroutineExceptionHandler 适合做兜底日志和统一上报,但它不是替代 try-catch 的通用方案。

val handler = CoroutineExceptionHandler { _, throwable ->
    Log.e("Coroutine", "Unhandled error", throwable)
}

viewModelScope.launch(handler) {
    loadData()
}

要注意两点:

  1. 它更适合挂在“根协程”上。
  2. 对于你希望在业务上恢复的异常,还是应该在协程体内部就地处理。

换句话说:

  • 想给用户展示错误提示,用 try-catch
  • 想统一记录未处理异常,用 CoroutineExceptionHandler

五、如何隔离并行任务的失败

很多页面会并行加载多个模块,比如:

  • 用户资料
  • 推荐列表
  • 通知摘要

这时候如果其中一个失败,你通常不希望另外两个也跟着取消。默认作用域做不到这一点,这时就该用 supervisorScopeSupervisorJob

viewModelScope.launch {
    supervisorScope {
        launch {
            loadProfile()
        }

        launch {
            loadNotifications()
        }

        launch {
            loadRecommendations()
        }
    }
}

supervisorScope 中,一个子协程失败不会自动取消兄弟协程。

如果需要更长期的隔离作用域,也可以自己建立:

private val supervisorScope = CoroutineScope(
    Dispatchers.Main + SupervisorJob()
)

但记得在生命周期结束时取消它,避免泄漏。


六、推荐的协程使用原则

如果要把协程用稳,我建议项目里统一遵守这几条:

1. 所有 suspend 函数都要主线程安全

不要把切线程的责任甩给调用方。耗时逻辑应该在函数内部用 withContext 处理掉。

2. 业务异常优先用局部 try-catch

错误提示、重试、降级这些逻辑,都应该放在业务协程体内部处理。

3. 并行任务默认评估是否需要隔离

如果几个任务是互相独立的,就优先考虑 supervisorScope

4. 不要在协程里偷偷做阻塞调用

Thread.sleep()、同步网络库、重 CPU 解析,如果没切对 Dispatcher,协程也照样会把主线程卡死。


七、结论

协程本身不是问题,真正的问题是我们容易因为“写起来像同步代码”而忽略了底层线程和异常传播模型。

把下面三件事守住,协程就会稳定很多:

  • 明确 Dispatcher,不要让耗时逻辑跑在主线程
  • 明确异常边界,不要指望外围 try-catch 自动兜底
  • 明确并发关系,需要隔离时使用 SupervisorJob / supervisorScope

一旦这些边界划清楚,协程就不再是“偶尔很玄学”的黑盒,而会真正成为 Android 项目里最可靠的异步基础设施之一。

相关工具