Kotlin 协程崩溃与主线程卡顿排查指南
很多人刚把项目从回调、RxJava 或旧线程模型迁到 Kotlin 协程时,第一感觉都很好:代码终于看起来像同步逻辑了。
但跑一段时间后,问题就开始冒出来:
- 明明用了协程,为什么界面还是卡?
- 为什么一个子协程抛异常,整个页面甚至整个 App 都一起崩了?
- 为什么
CoroutineExceptionHandler有时候像生效了,有时候又像没生效?
这些问题通常都和两个核心点有关:
- 线程切换没有做对
- 异常边界没有划清楚
一、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()
}
要注意两点:
- 它更适合挂在“根协程”上。
- 对于你希望在业务上恢复的异常,还是应该在协程体内部就地处理。
换句话说:
- 想给用户展示错误提示,用
try-catch - 想统一记录未处理异常,用
CoroutineExceptionHandler
五、如何隔离并行任务的失败
很多页面会并行加载多个模块,比如:
- 用户资料
- 推荐列表
- 通知摘要
这时候如果其中一个失败,你通常不希望另外两个也跟着取消。默认作用域做不到这一点,这时就该用 supervisorScope 或 SupervisorJob。
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 项目里最可靠的异步基础设施之一。