前言
之前写过一篇: Android 架构指南之 Data 层不要再暴露 start/stop 了:用 Flow 接管生命周期
那篇文章讨论的是一个比较常见的问题:
scss
repository.start()
repository.stop()
为什么一个 Data 层对象的生命周期,需要由调用方显式管理?
如果 Repository 提供的是持续变化的数据,Flow 本身就可以承担这件事情:
markdown
repository.observeUser()
.collect { ... }
调用方的 coroutine 被取消,collect 被取消,Flow 上游自然也就结束了。
这其实已经解决了一大类生命周期问题。
但还有一种情况,Flow 并不是最合适的表达方式。
比如:
kotlin
suspend fun init()
这个操作可能不是简单地创建几个对象。
比如:
- 初始化比较重的资源
- 创建并持有一些需要主动释放的对象
- 初始化底层组件或连接
- 完成一些耗时的初始化工作
这些资源初始化完成之后,页面可能还会继续使用。
但当页面关闭、ViewModel 被销毁时,这些资源通常也需要及时释放。
于是生命周期变成了:
csharp
init
↓
初始化资源
↓
页面继续使用
↓
页面关闭
↓
coroutine 被取消
↓
释放资源
问题来了:
如果 init() 本身是一个 suspend 操作,它应该怎么感知到调用方的生命周期结束,并在取消时释放这些资源?
很多时候,我们第一反应还是:
kotlin
suspend fun init()
fun cancel()
然后:
scss
viewModelScope.launch {
repository.init()
}
// 页面关闭
repository.cancel()
但如果 init() 本身就在:
csharp
viewModelScope.launch {
repository.init()
}
里面运行,那么这个 cancel() 真的还需要存在吗?
其实不需要。
不需要手动取消,这就是 Structured Concurrency 的优势。
我们来看,一个负责初始化重资源的 suspend 操作,如何响应调用方的 cancellation,并在页面生命周期结束时完成资源释放。
一、先看一个最简单的例子
很多 Android 项目的 Repository,会设计成这样:
kotlin
interface UserRepository {
suspend fun init()
fun cancel()
}
调用方:
scss
viewModelScope.launch {
repository.init()
}
// 页面销毁
repository.cancel()
看起来没什么问题。
但仔细想想:
为什么调用方需要知道 Repository 什么时候该取消?
如果 Repository 本身运行在 viewModelScope 中,那么 ViewModel 销毁的时候,viewModelScope 本来就会自动取消。
既然如此,Repository 为什么还需要额外提供一个 cancel()?
这里其实已经暴露出了一个问题:
我们正在用一个额外的 API,手动管理本来就属于 coroutine 的生命周期。
这篇文章从一个很小的例子开始,看看能不能把这件事情做得更自然。
二、先把问题说清楚:初始化完成,不代表资源生命周期结束
假设我们有一个 Repository:
kotlin
class MyRepository {
suspend fun init() {
setup()
}
private fun setup() {
println("setup")
}
}
ViewModel:
kotlin
class MyViewModel : ViewModel() {
fun initRepository() {
viewModelScope.launch {
repository.init()
}
}
}
这里没有任何问题。
init() 执行:
scss
ViewModel
↓
viewModelScope
↓
launch
↓
repository.init()
↓
setup()
↓
init() 完成
但是这里马上出现一个问题。
如果 init() 很快就执行完了:
csharp
viewModelScope.launch {
repository.init()
}
这个 coroutine 里的任务也就结束了。
之后页面关闭:
markdown
ViewModel 被销毁
↓
viewModelScope.cancel()
这时候已经没有什么 Repository 的 coroutine 可以取消了。
这本身并不是问题。
因为一个已经完成的操作,本来就不需要再响应取消。
真正的问题是另一种情况:
初始化阶段结束了,但初始化出来的资源生命周期还没有结束。
例如:
csharp
init
↓
初始化重资源
↓
资源初始化完成
↓
页面继续使用
↓
页面关闭
↓
释放资源
这时候:
kotlin
suspend fun init()
如果执行完马上返回,那么 Repository 就失去了一个非常重要的东西:
资源生命周期。
我们真正需要解决的不是:
"初始化怎么做?"
而是:
"初始化完成之后,如何让这个资源的生命周期继续跟随调用方?"
三、能不能让 init() 一直等到取消?
可以。
Kotlin 协程提供了一个非常适合这个场景的函数:
scss
awaitCancellation()
例如:
kotlin
suspend fun init() {
setup()
awaitCancellation()
}
它的意思不是:
"阻塞线程,等着取消。"
而是:
"挂起当前协程,一直等到它被取消。"
这是一个非常重要的区别。
这里的 awaitCancellation() 可以理解成:
lua
初始化完成
↓
资源已经准备好
↓
但是资源生命周期还没有结束
↓
当前 coroutine 继续保持
↓
直到调用方取消
所以:
kotlin
suspend fun init() {
setup()
awaitCancellation()
}
表达的并不是:
初始化永远不会结束。
而是:
初始化已经结束,但这个生命周期型操作还没有结束。
四、awaitCancellation() 会不会阻塞线程?
不会。
例如:
kotlin
suspend fun init() {
setup()
awaitCancellation()
}
执行到:
scss
awaitCancellation()
之后:
scss
Coroutine
│
├── setup()
│
↓
awaitCancellation()
│
│
│ 协程挂起
│
↓
等待 Cancellation
它不会一直占用当前线程。
这和:
bash
Thread.sleep(...)
完全不是一回事。
Thread.sleep():
线程
↓
被占用
↓
等待
↓
继续执行
而 awaitCancellation():
协程
↓
挂起
↓
线程可以去干其他事情
↓
收到取消
↓
进入取消流程
所以它不会把主线程卡住。
这一点非常重要。
因为我们想要的是:
保持 coroutine 的生命周期,而不是占住一个线程。
五、现在问题就变得有意思了
我们把 Repository 改成:
kotlin
class MyRepository {
suspend fun init() {
setup()
awaitCancellation()
}
private fun setup() {
println("setup")
}
}
ViewModel:
kotlin
fun initRepository() {
viewModelScope.launch {
repository.init()
}
}
现在整个生命周期就变成了:
scss
ViewModel
│
↓
viewModelScope
│
↓
launch
│
↓
repository.init()
│
├── setup()
│
└── awaitCancellation()
│
│
│ 等待
│
↓
页面继续使用
│
↓
ViewModel 销毁
│
↓
viewModelScope.cancel()
│
↓
init() 被取消
这里其实发生了一件非常重要的事情:
我们没有手动取消 Repository。
没有:
scss
repository.cancel()
也没有:
scss
repository.stop()
甚至 Repository 根本不需要知道:
"页面什么时候关闭?"
因为:
csharp
repository.init()
本身就是 viewModelScope 中 coroutine 的一部分。
当父 coroutine 被取消时,子 coroutine 会自然地跟着取消。
这就是 Structured Concurrency 的优势。
可以把它理解成:
lua
父 coroutine
│
├── 子 coroutine A
│
├── 子 coroutine B
│
└── repository.init()
父协程取消:
markdown
Parent cancel
↓
Child cancel
↓
cleanup
所以:
取消不是 Repository 额外提供的一种能力,而是 coroutine 生命周期自然产生的结果。
这也是 Structured Concurrency 和传统手动生命周期管理最大的区别之一。
以前我们可能习惯:
scss
init()
// 页面关闭
cancel()
需要自己记住什么时候 cancel()。
现在则变成:
csharp
viewModelScope.launch {
repository.init()
}
谁创建 coroutine,谁就拥有这个 coroutine 的生命周期。
页面销毁:
scss
ViewModel
↓
viewModelScope.cancel()
↓
repository.init() 自动收到取消
不需要手动取消。
当然,这里还有一个问题:
init() 为什么没有在 setup() 完成之后立即返回?
因为我们的场景并不是:
初始化
↓
完成
↓
结束
而是:
初始化资源
↓
资源初始化完成
↓
页面继续使用
↓
页面关闭
↓
资源释放
所以这里才需要:
scss
awaitCancellation()
它负责的不是"取消"。
取消由 Structured Concurrency 负责传播。
awaitCancellation() 只是让这个生命周期型的 suspend 操作继续存在,直到父 coroutine 被取消。
这两个概念一定要分清:
scss
Structured Concurrency
↓
负责生命周期和取消传播
awaitCancellation()
↓
让 init() 保持到取消发生
两者结合起来,才形成完整的资源生命周期管理。
六、取消是怎么进入 Repository 的?
现在我们已经知道:
csharp
viewModelScope.launch {
repository.init()
}
这里不需要:
scss
repository.cancel()
因为:
csharp
viewModelScope
↓
repository.init()
本身就建立了父子关系。
当 ViewModel 被销毁:
scss
viewModelScope.cancel()
↓
child coroutine 被取消
↓
repository.init()
↓
CancellationException
那么 Repository 怎么处理这个取消?
很简单:
kotlin
class MyRepository {
suspend fun init() {
try {
setup()
awaitCancellation()
} catch (e: CancellationException) {
cleanup()
throw e
}
}
private fun setup() {
println("setup")
}
private fun cleanup() {
println("cleanup")
}
}
整个过程:
scss
viewModelScope.cancel()
↓
init() 收到 cancellation
↓
CancellationException
↓
cleanup()
↓
throw e
↓
取消继续向上传播
这里最重要的一点就是:
Repository 不需要主动发起取消。
它只需要:
响应调用方已经发生的取消。
这就是 Structured Concurrency 带来的一个非常大的好处:
调用方负责拥有生命周期,子任务负责响应生命周期。
因此,我们不需要再设计一套额外的:
scss
start()
stop()
init()
cancel()
来同步两个生命周期。
生命周期已经通过 coroutine 的父子关系连接起来了。
七、为什么一定要 throw e?
这里有一个很容易踩的坑。
不要这样:
scss
catch (e: CancellationException) {
cleanup()
}
因为这样实际上把 CancellationException 吃掉了。
正确做法:
scss
catch (e: CancellationException) {
cleanup()
throw e
}
原因很简单:
取消不是普通业务异常。
它是协程生命周期的一部分。
取消发生之后,Repository 可以做清理,但不应该阻止取消继续向上传播。
所以可以把它理解成:
markdown
CancellationException
│
├── Repository 做 cleanup
│
└── 继续向上抛
而不是:
markdown
CancellationException
│
↓
Repository 吞掉
│
↓
取消状态被隐藏
所以:
scss
catch (e: CancellationException) {
cleanup()
throw e
}
这三个动作其实分别对应三个职责:
收到取消
↓
释放资源
↓
继续传播取消
八、还有一个细节:cleanup 如果需要挂起怎么办?
假设:
kotlin
suspend fun cleanup() {
// some suspend work
}
这时候:
scss
catch (e: CancellationException) {
cleanup()
throw e
}
可能存在问题。
因为当前 coroutine 已经处于取消状态。
如果 cleanup() 内部还有需要挂起的操作,那么它也可能立即响应取消。
例如:
scss
catch (e: CancellationException) {
cleanup()
throw e
}
如果 cleanup() 内部:
kotlin
suspend fun cleanup() {
someSuspendFunction()
}
这个挂起操作也可能因为当前 coroutine 已经取消而无法正常完成。
如果这个 cleanup 必须保证执行完成,可以使用:
scss
catch (e: CancellationException) {
withContext(NonCancellable) {
cleanup()
}
throw e
}
意思就是:
当前协程已经被取消,但这段必要的清理工作必须执行完。
然后再:
arduino
throw e
继续把取消传播出去。
不过不要滥用 NonCancellable。
它适合的是:
- 必须释放的资源
- 必须执行的 unregister
- 必须完成的状态恢复
- 必须保证一致性的清理操作
而不是拿来让一个已经取消的任务继续干一大堆工作。
NonCancellable 应该是一个非常小的保护区域。
九、为什么这一切都成立?Structured Concurrency
看到这里,其实已经可以看出:
真正重要的并不是:
scss
awaitCancellation()
而是它背后的生命周期关系。
我们来看一个错误的写法:
kotlin
class MyRepository {
fun init() {
CoroutineScope(Dispatchers.IO).launch {
doSomething()
}
}
}
然后:
csharp
viewModelScope.launch {
repository.init()
}
表面上看起来生命周期绑定了。
实际上没有。
因为:
scss
viewModelScope
│
└── repository.init()
│
└── CoroutineScope(IO).launch
│
└── doSomething()
Repository 又创建了一个新的 CoroutineScope。
这个新的 Job 并不是 viewModelScope 的 child。
所以:
lua
ViewModel 销毁
↓
viewModelScope.cancel()
↓
ViewModel 相关 coroutine 被取消
↓
Repository 自己创建的 coroutine
↓
还在运行
这就是生命周期脱节。
真正理想的结构是:
scss
ViewModel
│
↓
viewModelScope
│
↓
launch
│
↓
Repository.init()
│
├── setup()
│
└── awaitCancellation()
也就是说:
Repository 使用调用方提供的 coroutine 上下文,而不是自己偷偷创建一个新的生命周期。
这就是 Structured Concurrency 的核心思想之一:
父协程负责子协程的生命周期。
父取消:
markdown
Parent cancel
↓
Child cancel
↓
Child cleanup
所以 Repository 不需要自己维护:
ini
private val scope = CoroutineScope(...)
也不需要维护:
csharp
private var job: Job? = null
更不需要额外暴露:
kotlin
fun cancel()
如果这个操作本来就属于调用方的生命周期,那么让 coroutine 结构表达这种关系,通常更加自然。
十、这也解释了为什么 Repo 不需要 cancel()
如果 Repository API 是:
kotlin
interface Repository {
suspend fun init()
}
调用方:
csharp
viewModelScope.launch {
repository.init()
}
那么:
csharp
ViewModel
│
└── viewModelScope
│
└── repository.init()
Repository 根本不需要知道:
"你什么时候页面关闭?"
它只需要遵守协程的取消规则。
页面生命周期:
markdown
ViewModel destroyed
↓
viewModelScope.cancel()
自然就会传递到:
csharp
repository.init()
这就是一个非常漂亮的职责分离。
调用方决定:
这个操作属于谁的生命周期?
Repository 决定:
资源初始化、使用以及释放应该怎么做?
两者通过 coroutine 的 cancellation 自然连接起来。
十一、调用方只负责生命周期
调用方:
csharp
viewModelScope.launch {
repository.init()
}
负责的是:
这个操作属于谁的生命周期?
Repository:
kotlin
suspend fun init() {
...
}
负责的是:
我要怎么初始化和管理这些资源?
而不是:
scss
repository.init()
repository.cancel()
让调用方同时管理:
- 启动
- 生命周期
- 取消
- 清理
这样职责就混在一起了。
更自然的结构是:
markdown
调用方
↓
决定生命周期
↓
coroutine
Repository
↓
执行初始化
↓
持有资源
↓
响应 cancellation
↓
释放资源
所以:
调用方不需要管理 Repository 的取消,它只需要管理自己的 coroutine 生命周期。
十二、一个完整的例子
Repository:
kotlin
class MyRepository {
suspend fun init() {
try {
log("init start")
setup()
log("setup finished")
awaitCancellation()
} catch (e: CancellationException) {
log("received cancellation")
cleanup()
throw e
}
}
private fun setup() {
log("setup")
}
private fun cleanup() {
log("cleanup")
}
private fun log(message: String) {
println(
"[${Thread.currentThread().name}] $message"
)
}
}
ViewModel:
kotlin
class MyViewModel : ViewModel() {
fun initRepository() {
viewModelScope.launch {
repository.init()
}
}
}
运行过程:
scss
[main] init start
[main] setup
[main] setup finished
↓
awaitCancellation()
↓
协程挂起
↓
页面继续使用
↓
页面关闭
↓
viewModelScope.cancel()
↓
[main] received cancellation
[main] cleanup
这里最重要的是:
awaitCancellation() 并没有占用线程。
它只是让这个 coroutine 保持生命周期。
而真正让 init() 在页面关闭时结束的,是:
scss
viewModelScope.cancel()
以及 Structured Concurrency 带来的取消传播:
csharp
viewModelScope
↓
child coroutine
↓
repository.init()
↓
CancellationException
所以:
Repository 没有主动取消自己,也没有被调用方手动取消。
它只是响应父 coroutine 的 cancellation。
十三、这个模式并不是让所有 Repo 都这么写
这里需要特别强调:
scss
awaitCancellation()
不是一个"Repository 标准模板"。
如果你的 Repository 方法只是一次性的:
kotlin
suspend fun loadUser(): User
那么完全不需要:
scss
awaitCancellation()
正常:
ini
viewModelScope.launch {
val user = repository.loadUser()
}
就够了。
因为:
scss
loadUser()
↓
完成
↓
coroutine 结束
这就是正确的生命周期。
awaitCancellation() 适合的是:
初始化完成之后,资源本身还需要继续存在,并且应该随着调用方 coroutine 的生命周期结束而释放。
例如:
- 初始化比较重的资源
- 创建并持有需要主动释放的对象
- 初始化底层组件
- 建立需要主动关闭的连接
- 持有某些必须在生命周期结束时释放的资源
这时候才有必要考虑:
scss
awaitCancellation()
所以不要看到 suspend 就条件反射地加:
scss
awaitCancellation()
它解决的是一个非常具体的问题:
资源初始化完成了,但资源生命周期还没有结束。
十四、如果本身就是持续的数据,Flow 依然更自然
这里也需要和上一篇文章联系起来。
如果 Repository 本身提供的是持续变化的数据:
kotlin
fun observeUser(): Flow<User>
那么通常不需要自己:
scss
awaitCancellation()
调用方:
sql
viewModelScope.launch {
repository.observeUser().collect { user ->
// update UI
}
}
当 ViewModel 销毁:
scss
viewModelScope.cancel()
↓
collect cancelled
↓
Flow 上游取消
↓
Repository 停止工作
这其实也是 Structured Concurrency。
所以:
Flow 更适合表达持续的数据流;而
awaitCancellation()更适合表达一个资源初始化完成之后,仍然需要继续持有资源的生命周期型suspend操作。
两者解决的问题并不完全一样。
可以简单理解成:
scss
持续的数据
↓
Flow
↓
collect 生命周期
资源型操作
↓
suspend
↓
awaitCancellation()
↓
coroutine 生命周期
所以上一篇文章讲:
Data 层不要再暴露 start/stop,用 Flow 接管生命周期。
这一篇则继续往下讨论:
如果这个场景不是 Flow,而是一个生命周期型
suspend操作,它又应该如何响应取消?
答案还是同一个:
让 coroutine 自己管理生命周期,而不是额外设计一套 start/stop 或 init/cancel。
十五、回到那个最初的问题
为什么 Repository 要不要提供:
scss
cancel()
真正的问题并不是:
"有没有必要提供一个 cancel 方法?"
而是:
这个 Repository 的 coroutine 到底属于谁?
如果它属于:
ViewModel
↓
viewModelScope
那么:
scss
repository.cancel()
通常就是多余的。
因为调用方已经拥有生命周期。
正确的设计是:
csharp
viewModelScope.launch {
repository.init()
}
Repository:
kotlin
suspend fun init() {
try {
setup()
awaitCancellation()
} catch (e: CancellationException) {
cleanup()
throw e
}
}
最终形成:
scss
ViewModel
│
↓
viewModelScope
│
↓
launch
│
↓
Repository
│
↓
init()
│
↓
初始化重资源
│
↓
awaitCancellation()
│
│
页面继续使用
│
↓
页面关闭 / VM 清除
│
↓
viewModelScope.cancel()
│
↓
CancellationException
│
↓
cleanup()
没有:
scss
repository.cancel()
没有:
GlobalScope
没有:
scss
CoroutineScope(Dispatchers.IO)
也没有额外的生命周期同步代码。
生命周期已经存在于 coroutine 的父子关系里。
最后
上一篇文章讲的是:
不要让调用方通过
start/stop管理 Data 层的生命周期,让 Flow 接管它。
这一次,我们继续往下走了一步。
当一个操作本身适合用 suspend 表达时,也不应该重新退回到:
scss
init()
cancel()
而应该让 coroutine 自己携带生命周期。
调用方决定:
csharp
viewModelScope.launch {
repository.init()
}
Repository 决定:
kotlin
suspend fun init() {
try {
setup()
awaitCancellation()
} catch (e: CancellationException) {
cleanup()
throw e
}
}
于是:
lua
谁创建 coroutine
↓
谁拥有生命周期
↓
父协程取消
↓
子协程自动取消
↓
Repository 响应 cancellation
↓
释放自己持有的资源
这里最值得记住的,其实不是:
scss
awaitCancellation()
而是:
不需要手动取消,这就是 Structured Concurrency 的优势。
awaitCancellation() 只是技术手段。
它解决的是:
初始化完成了,但资源生命周期还没有结束。
而 Structured Concurrency 解决的是更大的问题:
这个资源到底属于谁的生命周期?
如果它属于 viewModelScope,那么就让它成为 viewModelScope 的一部分。
页面销毁:
scss
ViewModel
↓
viewModelScope.cancel()
↓
child coroutine 自动取消
↓
Repository 响应 CancellationException
↓
cleanup()
整个过程没有:
scss
repository.cancel()
没有:
scss
repository.stop()
也不需要调用方额外记住:
"页面销毁的时候,我是不是还漏掉了一个资源释放?"
因为生命周期已经进入了 coroutine 的结构。
以前我们习惯:
scss
init()
...
cancel()
现在则是:
lua
创建 coroutine
↓
资源属于这个 coroutine
↓
父协程结束
↓
子协程自动取消
↓
资源清理
取消不是一个额外的业务 API,而是 coroutine 生命周期自然产生的结果。
这就是 Structured Concurrency 真正漂亮的地方。
好的架构,有时候不是增加一个 cancel()。
而是:
让这个
cancel()根本不需要存在。