Android 中coroutineScope 和 supervisorScope 到底怎么选?先搞清楚 Exception 和 Result

1 阅读6分钟

很多开发者都会遇到这些困惑:

  • coroutineScopesupervisorScope 到底应该怎么选择?
  • 异常应该在哪里处理?
  • Data 层应该直接抛异常,还是返回 Result
  • 为什么用了 Result 后,发现 coroutineScopesupervisorScope 好像没有区别?

这些问题背后,其实是一种架构选择:

我们到底应该选择 Exception 驱动模型,还是 Result 驱动模型?

coroutineScopesupervisorScope,只是这个问题在协程层面的表现。


一、先理解协程异常传播机制

Kotlin 协程的异常传播,本质依赖:

CoroutineContext


       Job


异常传播关系

例如:

viewModelScope.launch {
    loadUser()
}

如果:

suspend fun loadUser() {
    throw IOException()
}

异常传播:

loadUser()


当前协程


viewModelScope Job


CoroutineExceptionHandler


Crash

二、coroutineScope:所谓子任务失败,整个任务组失败

先看:

viewModelScope.launch {

    coroutineScope {
        launch {
            delay(500)
            throw RuntimeException("任务A失败")
        }

        launch {
            delay(2000)
            println("任务B完成")
        }

    }

}

结构:

          coroutineScope

              |

      ----------------

      |              |

    任务A          任务B

    失败            运行

当任务 A 抛异常:

任务A失败

↓

coroutineScope 感知

↓

取消整个子协程树

↓

任务B收到 CancellationException

这就是 coroutineScope 的核心:

子任务失败,整个任务组失败。

但是注意:

如果异常没有捕获:

viewModelScope.launch {

    coroutineScope {
        launch {
            throw RuntimeException()
        }

    }

}

异常仍然会继续向上传播:

Child Coroutine

↓

Parent Job

↓

CoroutineExceptionHandler

↓

Crash !!!

coroutineScope 并不会自动帮你处理异常。


三、supervisorScope:所谓子任务失败,不自动取消兄弟任务

换成:

viewModelScope.launch {
    supervisorScope {
        launch {
            delay(500)
            throw RuntimeException("任务A失败")
        }

        launch {
            delay(2000)
            println("任务B完成")
        }

    }

}

结果:

任务A失败

X

任务B继续执行(这里你看不到,想想为什么)

原因:

supervisorScope 改变了子协程之间的取消传播关系。

普通 Job:

Child失败

↓

取消兄弟

Supervisor:

Child失败

↓

不影响兄弟

但是:

如果:

launch {
    throw RuntimeException()
}

没有处理异常:

异常仍然会进入:

CoroutineExceptionHandler

没有处理时,Android 环境依然导致 Crash。


四、Exception 驱动模型的问题

到这里会发现:

直接使用 Exception 管理业务失败,会进入两个极端。

情况一:不捕获

repository.getUser()

异常直接传播:

Exception

↓

Coroutine Job

↓

Crash


情况二:到处 try-catch

例如:

viewModelScope.launch {
    try {
        repository.getUser()
    } catch(e: Exception){
        showError()

    }

}

随着业务增加:

try-catch

try-catch

try-catch

最终:

业务代码被异常处理逻辑淹没。


如果我们选择 Exception 驱动模型,那么异常最终总要在某一层被捕获。

但在协程世界里,异常并不总是像普通函数调用那样,沿着调用栈直接进入外层的 try-catch

尤其是当我们使用 launch 创建新的子协程后,异常传播就会受到 Job 父子关系的影响。

这也是很多开发者在实际开发中遇到的困惑:

明明外面已经写了 try-catch,为什么子协程里的异常还是捕获不到?

要回答这个问题,我们需要先理解 launch 创建的新协程,以及它背后的异常传播机制。


五、为什么 launch 外层 try-catch 捕获不到?

很多人会写:

viewModelScope.launch {
    try {
        launch {
            throw RuntimeException()
        }
    } catch(e: Exception){
        println("捕获成功")
    }
}

结果:

捕获不到。

原因:

launch 创建了新的协程。

异常传播:

Child Coroutine

↓

Child Job

↓

Parent Job

↓

CoroutineExceptionHandler

不是普通函数调用:

throw

↓

函数栈

↓

catch

所以:

外层 try-catch 不会因为 launch 创建了子协程,就自动捕获子协程抛出的异常。


六、什么时候 try-catch 可以生效?

如果异常发生在当前协程调用链:

viewModelScope.launch {

    try {
        repository.getUser()
    } catch(e: Exception){
        println("捕获成功")
    }

}

路径:

当前协程

↓

suspend函数

↓

throwcatch

可以捕获。

但是:

如果所有业务都依赖这种方式:

最终还是:

上层充满 try-catch。


七、Result 模型:让业务失败成为数据

前面我们看到,Exception 驱动模型有一个天然的问题:

业务失败一旦通过异常传播,就会进入协程的异常传播体系。

这意味着一个本质上属于业务层面的失败,可能进一步影响:

  • Job 的状态;
  • 父子协程关系;
  • 兄弟协程的取消;
  • 异常处理边界。

因此,一个更值得思考的问题是:

业务失败,真的应该通过 Exception 来表达吗?

我的理解是:

业务失败应该尽量显式建模,不要让所有业务失败都依赖异常传播。

因此,我们可以让业务失败显式建模为 Result 或业务状态,而不是通过异常传播。 例如,可以在合适的架构层将底层技术异常转换为业务结果。

例如:

interface UserRepository {
    suspend fun getUser(): Result<User>
}

实现:

class UserRepositoryImpl(
    private val api: UserApi
): UserRepository {

    override suspend fun getUser(): Result<User> {
        return try {
            val user = api.getUser()
            Result.success(user)
        } catch(e: CancellationException) {
            throw e
        } catch(e: Exception) {
            Result.failure(e)
        }

    }

}

上层:

viewModelScope.launch {
    val result = repository.getUser()
    result
        .onSuccess {
            showUser(it)
        }
        .onFailure {
            showError()
        }

}

业务代码:

不需要 try-catch。


八、Result 最大的变化:协程感知不到失败

这是整个设计的关键。

例如:

coroutineScope {
    launch {
        repository.getUser()
    }
    launch {
        repository.getBanner()

    }

}

如果:

getUser()

返回:

Result.failure()

那么:

协程看到:

任务A

↓

正常返回 Result.failure()

↓

没有抛出异常

对协程的异常传播机制来说,Result.failure() 只是一个正常的返回值。

它不会触发 Job 的异常传播,也不会因为业务失败自动取消兄弟协程。

而不是:

任务A失败

↓

throw Exception

因此:

不会触发:

  • Job 取消;
  • 兄弟任务取消;
  • 异常传播。

所以:

此时:

coroutineScope

和

supervisorScope

在业务失败场景下:

表现完全一致。


九、Result 模型下 Scope 的职责变化

异常处理方式coroutineScopesupervisorScope
未捕获异常 throw取消兄弟,并继续传播不取消兄弟,并继续传播
子协程内部捕获没区别没区别
返回 Result没区别没区别
CancellationException必须传播必须传播

十、CancellationException:它不是业务异常,而是取消信号

错误:

try {
    api.getUser()
}catch(e: Exception){
    Result.failure(e)
}

问题:

因为:

CancellationException

继承

Exception

所以会被捕获。

例如:

用户退出页面:

ViewModel销毁

↓

Coroutine取消

↓

CancellationException

↓

被转换成Result.failure

↓

协程继续执行

导致:

  • 浪费资源;
  • 生命周期失控。

正确:

try {
    api.getUser()
} catch (e: CancellationException) {
    throw e
} catch (e: Exception) {
    Result.failure(e)
}

原则:

CancellationException 永远向上传播。


十一、Android 架构最佳实践

                UI / ViewModel

                     |

              消费 UiState / Result


                     |

              Coroutine Scope

                     |

       生命周期 + 并发 + 取消管理


                     |

              Repository


                     |

       捕获技术异常,转换业务结果


                     |

             Retrofit / Room


Data 层职责

负责:

  • IOException
  • 网络错误
  • 数据解析错误

转换:

Exception

↓

Result.failure

不要:

Repository throw IOException

↓

ViewModel try-catch

Coroutine Scope 职责

负责:

  • 生命周期
  • 并发关系
  • 取消传播
  • 资源释放

不是负责:

  • 网络错误展示;
  • 用户提示;
  • 业务失败。

十二、最后

Kotlin 协程异常设计的核心,并不是选择:

coroutineScope

还是

supervisorScope

而是先明确:

不同类型的失败,应该由不同机制负责。


业务失败属于数据流

例如:

  • 网络失败;
  • 服务端错误;
  • 数据不存在。

应该:

Result

或者

业务状态模型

传递。


协程取消属于控制流

例如:

  • 页面销毁;
  • ViewModel 清理;
  • 超时取消。

必须:

CancellationException

继续传播。

源码:SupervisorScopeVsCoroutineScope