【Kotlin 协程修仙录 · 筑基境 · 巅峰】 | 异常天网:CoroutineExceptionHandler 与 SupervisorJob 的防火墙之

0 阅读6分钟

image_11.png

前言

筑基境后阶已过,你已驯服 Dispatchers 四大护法。MainIODefaultUnconfined 各司其职,withContext 的线程切换在你手中如行云流水。你自信满满,将协程应用于生产环境。

然而,一个诡异的崩溃报告打破了你的宁静:

“用户在商品详情页快速点击收藏按钮,应用崩了。日志显示 IllegalStateException,但我在 collect 里明明写了 try-catch!” “我用了 viewModelScope.launch,里面启动了 3 个子协程并发加载数据。其中一个子协程网络超时抛异常,结果整个页面的其他数据也全没了!” “我在 GlobalScope 里加了 CoroutineExceptionHandler,为什么协程崩溃后它根本没被调用?”

这些困惑指向协程中最容易被误解的领域——异常处理机制。协程的异常传播不是简单的 try-catch,而是一套结合了结构化并发Job、以及 SupervisorJob 防火墙的精密体系。

本讲是筑基境的最终章。你将彻底掌握协程异常处理的天网系统:

  • 理解异常在 Job 树上的传播路径——为什么“一个子协程崩溃,全家遭殃”。
  • 掌握 CoroutineExceptionHandler 的安装位置与生效条件。
  • 看清 SupervisorJob 如何一键构筑异常防火墙。
  • 学会 supervisorScope 的隔离之道——让局部失败不影响全局。
  • 在 Android 实战中构建“永不崩溃”的 UI 协程架构。

准备好构筑异常天网,让协程的崩溃无处遁形了吗?我们开始。

千曲而后晓声,观千剑而后识器。虐它千百遍方能通晓其真意


异常传播的铁律:沿 Job 树上行,直到根部

在结构化并发的体系中,异常传播遵循一条不可动摇的铁律:

子协程未捕获的异常,会沿着 Job 树向上传播,逐层取消父 Job,直到抵达树根(Root Job)。传播过程中,所有被取消的父协程会同步取消其所有子协程。

deepseek_mermaid_20260502_34923b.png

关键规则

  1. 异常沿着 Job 的父子链向上传播(子 → 父 → 根)。
  2. 一旦父 Job 被取消,它会向下级联取消所有其他子协程。
  3. 异常最终到达根 Job 时,如果没有被 CoroutineExceptionHandler 处理,应用崩溃。

CoroutineExceptionHandler:异常处理的最后一道防线

CoroutineExceptionHandlerCoroutineContext 的一个可选元素,用于处理未捕获的异常。它只在根协程上生效——即直接由 CoroutineScope 启动的协程(Scope 的直接子协程)。子协程上的 CoroutineExceptionHandler 会被忽略。

val handler = CoroutineExceptionHandler { _, exception ->
    println("捕获异常:${exception.message}")
}

val scope = CoroutineScope(Job() + Dispatchers.Main + handler)

scope.launch {
    // 这是根协程,handler 会生效
    throw RuntimeException("根协程异常") // 被 handler 捕获
}

scope.launch {
    launch {
        // 这是子协程,即使加了 handler 也会被忽略
        throw RuntimeException("子协程异常") // 向上传播到根
    }
}
flowchart TD
    subgraph Scope[CoroutineScope]
        Handler[CoroutineExceptionHandler 置于此]
        Root[根协程 launch]
        Child[子协程 launch]
    end
    
    Child -->|异常向上传播| Root
    Root -->|触发| Handler
    Handler -->|处理或忽略| Result[应用不崩溃]
    
    Child2[子协程上的 Handler] -.->|被忽略| Ignored
    
    style Handler fill:#ffb74d,stroke:#e65100,stroke-width:2px
    style Root fill:#c8e6c9,stroke:#2e7d32
    style Child fill:#e3f2fd,stroke:#1976d2
    style Ignored fill:#ef9a9a

核心结论

  • CoroutineExceptionHandler 必须安装在根协程(直接由 Scope 启动)上。
  • 子协程上的 Handler 形同虚设——异常会继续向上传播。
  • 如果异常到达根部仍无 Handler 处理,则应用崩溃

SupervisorJob:构筑异常防火墙

在默认的 Job 树中,“一个子协程崩溃,全家遭殃”。但很多场景下你需要隔离子协程的异常——一个子协程失败,不应影响兄弟协程。

这就是 SupervisorJob 的用武之地。

SupervisorJobJob 的一种特殊实现。它重写了 childCancelled 方法,使其不将子协程的取消事件传播给自身或其他子协程。本质上,它构筑了一道单向防火墙——异常到此为止,不再向上也不向旁传播。

val supervisor = SupervisorJob()
val scope = CoroutineScope(Dispatchers.Main + supervisor)

scope.launch {
    // 子协程 A
    launch {
        delay(100)
        throw RuntimeException("A 崩溃了") // 只影响 A 自身
    }
    // 子协程 B
    launch {
        delay(200)
        println("B 依然执行") // 不受 A 影响
    }
}
graph TD
    subgraph NormalJob[普通 Job 树 连坐制]
        NJ[父 Job] --> NC1[子协程 A]
        NJ --> NC2[子协程 B]
        NC1 -.->|崩溃| NJ
        NJ -.->|取消| NC2
    end
    
    subgraph SupervisorJob[SupervisorJob 树 隔离制]
        SJ[SupervisorJob] --> SC1[子协程 A]
        SJ --> SC2[子协程 B]
        SC1 -.->|崩溃| SJ
        SJ -.->|不传播| SC2
    end
    
    style NormalJob fill:#ffcdd2,stroke:#b71c1c,stroke-width:2px
    style SupervisorJob fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px
    style NJ fill:#e57373
    style SJ fill:#81c784

Android 中的重要事实viewModelScopelifecycleScope 底层都使用 SupervisorJob。这意味着你在 viewModelScope.launch 中启动的多个根协程,它们之间天然是隔离的。


supervisorScope:结构化并发的可配置隔离区

supervisorScopeSupervisorJob 的语法糖。它创建一个使用 SupervisorJob 的临时作用域,内部子协程彼此隔离。

suspend fun loadProductData() = supervisorScope {
    val productDeferred = async { loadProduct() }      // 必须成功
    val reviewsDeferred = async { loadReviews() }      // 必须成功
    val recommendsDeferred = async {                    // 可失败
        try {
            loadRecommends()
        } catch (e: Exception) {
            emptyList()
        }
    }
    
    Triple(
        productDeferred.await(),
        reviewsDeferred.await(),
        recommendsDeferred.await()
    )
}

supervisorScope vs coroutineScope

对比维度coroutineScopesupervisorScope
内部使用的 Job普通 JobSupervisorJob
子协程异常行为一个失败,全体取消一个失败,互不影响
自身异常传播子协程未捕获异常向上抛出子协程未捕获异常向上抛出
适用场景关键任务组合,必须全部成功部分任务可失败,不影响整体

实战:商品详情页的异常隔离架构

class ProductDetailViewModel : ViewModel() {

    sealed class UiState {
        object Loading : UiState()
        data class Success(
            val product: Product,
            val reviews: List<Review>,
            val recommends: List<Product>,
            val recommendError: String? = null
        ) : UiState()
        data class Error(val message: String) : UiState()
    }

    var uiState by mutableStateOf<UiState>(UiState.Loading)
        private set

    fun loadProduct(productId: String) {
        viewModelScope.launch {
            uiState = UiState.Loading
            uiState = try {
                supervisorScope {
                    val productDeferred = async { productRepo.getProduct(productId) }
                    val reviewsDeferred = async { reviewRepo.getReviews(productId) }
                    val recommendsDeferred = async {
                        try {
                            recommendRepo.getRecommends(productId)
                        } catch (e: Exception) {
                            emptyList()
                        }
                    }
                    UiState.Success(
                        product = productDeferred.await(),
                        reviews = reviewsDeferred.await(),
                        recommends = recommendsDeferred.await()
                    )
                }
            } catch (e: Exception) {
                UiState.Error(e.message ?: "核心数据加载失败")
            }
        }
    }
}
flowchart TD
    Start[用户进入详情页] --> Launch[viewModelScope.launch]
    Launch --> SS[supervisorScope]
    
    SS --> Product[async: 商品信息 必须成功]
    SS --> Reviews[async: 用户评价 必须成功]
    SS --> Recs[async: 推荐列表 可失败]
    
    Recs -->|内部 try-catch| RecOK[返回空列表]
    Product --> Await[await 所有结果]
    Reviews --> Await
    RecOK --> Await
    
    Await -->|成功| ShowContent[展示完整页面]
    Await -->|核心数据失败| ShowError[展示错误信息]
    
    style Start fill:#e3f2fd,stroke:#1976d2,stroke-width:2px
    style SS fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px
    style ShowContent fill:#a5d6a7
    style ShowError fill:#ef9a9a

常见错误与避坑指南

错误 1:在子协程上安装 CoroutineExceptionHandler

scope.launch {
    launch(CoroutineExceptionHandler { _, _ -> }) { 
        throw Exception() // Handler 不生效,异常向上传播
    }
}

正确做法:Handler 必须安装在根协程或 Scope 初始化时。

错误 2:用 try-catch 包裹 launch 期望捕获内部异常

try {
    scope.launch {
        throw Exception() // 不会被外部 try-catch 捕获
    }
} catch (e: Exception) { }

正确做法launch 内部的异常需在协程体内部 try-catch,或使用 CoroutineExceptionHandler

错误 3:在 supervisorScope 中不处理 async 的异常

supervisorScope {
    val d = async { throw Exception() }
    d.await() // 异常仍会抛出,导致 supervisorScope 失败
}

正确做法:对可能失败的 async,在内部 try-catch 或在 await 处包裹。


最佳实践

  1. 为根 Scope 安装 CoroutineExceptionHandler:作为最后的兜底防线。
  2. UI 层使用 viewModelScope + SupervisorJob 的天然隔离
  3. supervisorScope 用于挂起函数内部的隔离边界
  4. 对可能失败的子任务,在 async 内部 try-catch,不让异常传播。
  5. 核心任务与辅助任务分离:核心用 coroutineScope,辅助用 supervisorScope

总结与下回预告

恭喜,你已构筑异常天网,筑基境大圆满!

本讲核心收获

  • 异常沿 Job 树向上传播,CoroutineExceptionHandler 只在根协程生效。
  • SupervisorJob 通过空实现 childCancelled 构筑防火墙。
  • supervisorScope 是创建隔离边界的最佳工具。
  • Android 中 viewModelScope 底层使用 SupervisorJob,天然隔离。

在下一讲 【金丹境·初阶】 中,我们将踏入 async/await 的并发艺术。你将学会如何用 Deferred 并发组合多个请求,用 LAZY 实现条件启动,以及结构化并发在 async 场景下的优雅应用。


【当前境界修为面板】

  • 当前境界[筑基境 · 巅峰]筑基境大圆满
  • 下一突破[金丹境 · 初阶] (需领悟:async/await 并发组合、Deferred 结果等待、CoroutineStart.LAZY)
  • 修炼进度[████████████████████░░░░] 80%
  • 本讲获得法器异常传播天网诀SupervisorJob 防火墙术supervisorScope 隔离结界

【本讲思考题】

1、表象题:以下代码中,CoroutineExceptionHandler 会生效吗?为什么?

scope.launch {
    launch(CoroutineExceptionHandler { _, _ -> println("捕获") }) {
        throw RuntimeException()
    }
}

2、场景题:你在 ViewModel 中并发加载 3 个接口数据。其中商品接口和评价接口必须全部成功才能展示,推荐接口失败则只展示留空。如何用协程实现这个需求?

3、原理题SupervisorJobchildCancelled 方法为什么是空实现?请从异常传播链的角度简述。


道友,筑基四境已全部通关。你已打通全身经脉,协程的基本功炉火纯青。下一讲,我们将踏入金丹境,学习并发组合的高阶技法。金丹境·初阶见。

欢迎一键四连关注 + 点赞 + 收藏 + 评论