Kotlin协程异常处理:10个让你深夜加班的陷阱与避坑指南

avatar
小码哥IP属地:上海
02026-02-09:22:17:01字数 7281阅读 0

深度实战|源码级解析|附可运行测试代码|Android/JVM通用
协程让异步编程优雅,但异常处理稍有不慎——轻则静默失败,重则应用崩溃。本文直击10个高频陷阱,助你构建健壮协程系统!


🌪️ 为什么协程异常如此“狡猾”?

传统线程异常:UncaughtExceptionHandler 全局兜底
协程异常:作用域传播 + 结构化并发 + 上下文隔离 → 异常流向复杂如迷宫

💡 核心认知

  • launch:异常立即抛出(需主动处理)
  • async:异常延迟到await()(易被忽略)
  • 异常处理器仅对根协程生效(子协程需特殊处理)
  • SupervisorJob ≠ Job(异常传播规则天壤之别)

⚠️ 陷阱1:launch中未捕获异常 → 整个作用域崩溃

❌ 错误示范

val scope = CoroutineScope(Job())
scope.launch {
    throw RuntimeException("崩溃!")
}
scope.launch {
    delay(100)
    println("这行永远不会执行") // 作用域已取消!
}

现象:第一个协程抛异常 → 作用域内所有协程被取消(包括后续启动的)

✅ 正确方案(三选一)

// 方案1:局部try-catch(推荐简单场景)
scope.launch {
    try { riskyWork() } catch (e: Exception) { log(e) }
}

// 方案2:SupervisorJob(子协程异常不波及其他)
val scope = CoroutineScope(SupervisorJob()) // 关键!

// 方案3:CoroutineExceptionHandler(全局兜底)
val handler = CoroutineExceptionHandler { _, e ->
    FirebaseCrashlytics.recordException(e) // 上报监控
}
scope.launch(handler) { riskyWork() }

🔍 原理:普通Job遵循“失败即取消”原则;SupervisorJob子协程相互隔离。


⚠️ 陷阱2:async异常被静默忽略(直到await()

❌ 致命错误

val deferred = scope.async { 
    throw IOException("网络失败") 
}
// 无await()!异常永远不触发,内存泄漏风险!

现象

  • await() → 异常永久挂起(Deferred对象持有引用)
  • await()但未try-catch → 异常在调用处抛出(易定位错误)

✅ 安全写法

// 方案1:立即await + try-catch
try {
    val result = scope.async { apiCall() }.await()
} catch (e: Exception) { handle(e) }

// 方案2:使用async(start = CoroutineStart.LAZY) + 显式启动
val deferred = scope.async(start = CoroutineStart.LAZY) { apiCall() }
deferred.start() // 手动启动
// ...后续处理

💡 黄金法则async必须配await(),且await()需包裹异常处理!


⚠️ 陷阱3:try-catch包裹launch无法捕获内部异常

❌ 无效代码

try {
    scope.launch { throw Exception("抓不到我!") } // 异常在协程内部抛出
} catch (e: Exception) {
    println("永远进不来") // ❌ 无效!
}

原因launch立即返回,异常在后续异步执行时抛出,try-catch已退出作用域

✅ 正确姿势

// 方案1:在launch内部try-catch(最直接)
scope.launch {
    try { riskyWork() } catch (e: Exception) { ... }
}

// 方案2:使用await() + async(将异步转同步)
try {
    scope.async { riskyWork() }.await()
} catch (e: Exception) { ... }

⚠️ 陷阱4:多个子协程异常 → 仅第一个被抛出

❌ 隐藏风险

supervisorScope {
    launch { throw Exception("错误A") }
    launch { throw Exception("错误B") } // 被抑制!
    launch { throw Exception("错误C") } // 被抑制!
}

现象:仅"错误A"被抛出,B/C异常丢失(日志无记录,排查困难)

✅ 完整捕获方案

supervisorScope {
    val exceptions = mutableListOf<Throwable>()
    val jobs = listOf(
        launch { try { work1() } catch (e: Throwable) { exceptions += e } },
        launch { try { work2() } catch (e: Throwable) { exceptions += e } }
    )
    jobs.joinAll()
    if (exceptions.isNotEmpty()) throw CompositeException(exceptions)
}

// 自定义复合异常(便于上报)
class CompositeException(val causes: List<Throwable>) : Exception() {
    override val message: String = "多个异常: ${causes.size}个"
}

📌 Android实践:使用kotlinx-coroutines-androidCoroutineExceptionHandler收集所有异常。


⚠️ 陷阱5:混淆JobSupervisorJob的异常传播

场景Job(默认)SupervisorJob
子协程异常取消所有兄弟协程仅取消自身
适用场景任务强关联(如事务)任务独立(如列表加载)
陷阱一个Item失败 → 整个列表加载中断需手动处理每个异常

✅ 选择指南

// 场景:加载用户资料(头像+简介+动态)→ 任一失败整体失败
val scope = CoroutineScope(Job()) // 用Job

// 场景:首页多个卡片独立加载 → 一个失败不影响其他
val scope = CoroutineScope(SupervisorJob()) // 用SupervisorJob

⚠️ 陷阱6:非结构化并发中异常“消失”

❌ 危险写法

fun loadData() {
    GlobalScope.launch { // ❌ 严禁使用GlobalScope!
        apiCall() // 异常无作用域处理,可能静默失败
    }
}

后果

  • 无父作用域 → 异常处理器失效
  • 协程生命周期失控 → 内存泄漏
  • Android中配置变更(旋转屏幕)导致重复请求

✅ 正确实践

// Android ViewModel中
class MainViewModel : ViewModel() {
    private val viewModelScope = ViewModelScope() // androidx.lifecycle 2.1+
    
    fun loadData() {
        viewModelScope.launch { // 自动绑定生命周期
            try { apiCall() } catch (e: Exception) { showError(e) }
        }
    }
}

🌐 通用方案:使用lifecycleScope(Activity/Fragment)或自定义CoroutineScope绑定生命周期。


⚠️ 陷阱7:自定义CoroutineExceptionHandler被覆盖

❌ 无效设置

val scope = CoroutineScope(Job() + CoroutineExceptionHandler { _, _ -> 
    println("我的处理器") 
})
scope.launch(CoroutineExceptionHandler { _, _ -> 
    println("父作用域的处理器会覆盖我!") // ❌ 不会执行
}) { ... }

规则

  • 子协程不能设置自己的CoroutineExceptionHandler
  • 异常由父作用域的处理器处理

✅ 正确用法

// 全局设置(Application初始化时)
Thread.setDefaultUncaughtExceptionHandler { _, e ->
    FirebaseCrashlytics.recordException(e)
}

// 作用域级设置(推荐)
val appScope = CoroutineScope(SupervisorJob() + CoroutineExceptionHandler { _, e ->
    when (e) {
        is IOException -> retryNetwork()
        else -> logAndReport(e)
    }
})

⚠️ 陷阱8:Android主线程异常 → 应用崩溃

❌ 崩溃现场

lifecycleScope.launch(Dispatchers.Main) {
    val data = withContext(Dispatchers.IO) { apiCall() }
    textView.text = data // 若data为null → NullPointerException!
}

后果:主线程抛异常 → App直接闪退(用户感知极差)

✅ 防崩方案

lifecycleScope.launch {
    try {
        val data = withContext(Dispatchers.IO) { safeApiCall() }
        textView.text = data ?: "加载失败"
    } catch (e: Exception) {
        showErrorToast("服务异常,请重试")
        logError(e) // 上报监控
    }
}

📱 Android专属

  • 使用kotlinx-coroutines-androidMainScope()
  • 配合lifecycle-runtime-ktxrepeatOnLifecycle管理生命周期

⚠️ 陷阱9:withContext切换上下文丢失异常上下文

❌ 隐蔽问题

launch {
    try {
        withContext(Dispatchers.IO) {
            throw IOException("网络错误")
        }
    } catch (e: Exception) {
        // 能捕获,但堆栈丢失原始协程上下文!
        e.printStackTrace() // 难定位业务场景
    }
}

痛点:异常堆栈缺失业务关键信息(如用户ID、操作步骤)

✅ 增强方案

// 方案1:自定义异常包装
class BusinessException(
    val userId: String,
    val operation: String,
    cause: Throwable
) : Exception("用户[$userId]操作[$operation]失败", cause)

// 使用
withContext(Dispatchers.IO) {
    try { apiCall() } 
    catch (e: IOException) { 
        throw BusinessException("U123", "加载首页", e) 
    }
}

// 方案2:使用StructuredLogging(推荐)
launch {
    val context = mapOf("userId" to "U123", "page" to "Home")
    try { ... } 
    catch (e: Exception) { 
        logger.error("业务异常", e, context) // 日志带上下文
    }
}

⚠️ 陷阱10:Flow中未处理异常 → 整个流终止

❌ 流中断

flow {
    emit(1)
    throw IOException("中断!")
    emit(2) // 永远不会执行
}.collect { println(it) } // 仅输出1,然后崩溃

后果:Flow遇到未处理异常 → 立即终止,后续数据丢失

✅ 安全收集方案

// 方案1:catch操作符(推荐)
flow { ... }
    .catch { e -> 
        if (e is IOException) emit(-1) // 降级数据
        else throw e // 非预期异常继续抛出
    }
    .collect { ... }

// 方案2:retryWhen(网络重试)
flow { apiCall() }
    .retryWhen { cause, attempt ->
        if (cause is IOException && attempt < 3) {
            delay(1000 * attempt)
            true // 重试
        } else false
    }
    .catch { showError(it) }
    .collect { ... }

💡 Flow黄金法则

  • catch处理可恢复异常
  • retry处理临时故障
  • 保留不可恢复异常向上抛出

🛡️ 协程异常处理最佳实践清单

场景推荐方案工具/库
全局兜底CoroutineExceptionHandler + 监控上报Firebase Crashlytics
子协程隔离SupervisorJob + 局部try-catchkotlinx-coroutines
async安全必须配await() + try-catch-
Android生命周期viewModelScope / lifecycleScopeandroidx.lifecycle
Flow异常catch + retryWhen组合kotlinx-coroutines-flow
异常上下文自定义异常包装 + 结构化日志Timber, SLF4J
测试验证runTest + assertThrowskotlinx-coroutines-test

🔚 结语:异常不是敌人,是系统的哨兵

协程异常处理的精髓不在“消灭异常”,而在:
🔹 精准捕获:知道异常该在哪里处理
🔹 清晰传播:让异常携带足够上下文
🔹 优雅降级:保障核心流程不中断
🔹 可观测性:每个异常都可追溯、可分析

记住

  • SupervisorJob隔离非关键任务
  • async必配await()+异常处理
  • Android中永远绑定生命周期作用域
  • Flow用操作符而非try-catch处理异常

🌱 行动建议
1️⃣ 检查现有项目:是否有GlobalScopeasync是否漏await
2️⃣ 为ViewModel添加统一异常处理器
3️⃣ 在Flow中加入catch降级逻辑

下期预告:《Kotlin Flow实战:从冷流到热流,构建响应式数据管道》
—— 深入Flow操作符、状态管理、与LiveData对比

总资产 0
暂无其他文章

热门文章

暂无热门文章