在 Kotlin 协程的世界里,Repository 是否应该启动协程,是一个常见但危险的误区。很多项目为了“方便”,直接在 Repository 中 launch {},但这其实是一种典型的 反模式。比如下面的例子。
之前我写过一篇repo重构文章Kotlin 协程合理管理协程作用域:从 CoroutineScope 到 suspend 函数的重构实践
真正的最佳实践是:
Repository 不启动协程,只提供挂起函数(suspend)或 Flow,由上层(如 ViewModel)来决定何时、在哪个作用域执行协程。
为什么这是“教科书级”的设计?为什么 Repository 启动协程是反模式?我们从职责、结构化并发、异常处理、并发控制等角度逐一拆解。
🧱 一、Repository 的职责必须“纯粹”
Repository 的使命只有一个:
提供一致的数据访问接口,屏蔽数据源细节。
它应该只关心:
- “怎么写文件”
- “在哪个线程写文件”
而不应该关心:
- 谁来启动协程
- 协程何时结束
- 协程是否需要取消
- 生命周期如何管理
- 并发策略如何控制
Repository 没有自己的 CoroutineScope,如果它擅自启动协程,就会产生无法管理的“野协程”,这是架构的大忌。
🧵 二、正确做法流程图(教科书级)
下面是 正确架构 的流程图,展示了 ViewModel 如何作为“决策者”启动协程,而 Repository 只负责执行挂起函数:
特点:
- 生命周期由 ViewModel 管理
- 线程切换由 Repository 内部保证
- 协程结构清晰、可控
- 完全符合结构化并发
🧵 三、主线程安全(Main-safety)天然具备
正确写法:
ViewModel 调用:
即使 launch 在主线程,执行到 withContext(Dispatchers.IO) 时会自动挂起并切换线程,UI 不会卡顿。
⚠️ 四、异常处理(
Repository 内部捕获异常
优点:
- Repository 对自己的行为负责
- 异常不会被吞掉
- 调用者能明确知道失败原因
🧪 五、可测试性极强
Repository 不启动协程后,测试变得非常简单:
不需要 delay,不需要等待协程
💡 六、为什么 Repository 启动协程是“反模式”?
❌ 1. 违反结构化并发(Structured Concurrency)
协程世界的核心理念是:
所有协程都必须有明确的父作用域,生命周期必须可控。
Repository 启动协程意味着:
- 它创建了一个“无父协程”
- 生命周期不可控
- 调用者无法取消
- 异常无法冒泡
这直接破坏了结构化并发的根基。
❌ 2. 责任链断裂:Repository 僭越了“决策者”的角色
在清晰的架构中:
- Repository:提供操作
- ViewModel / UseCase:编排操作、决定何时执行、在哪个作用域执行
如果 Repository 自己启动协程,它就越权了:
- 它决定了任务何时执行
- 它决定了任务在哪个线程执行
- 它决定了任务是否可取消
这让上层完全失去控制权。
❌ 3. 调用者被“蒙在鼓里”
一个好的函数应该诚实地告诉调用者:
“我是一个耗时操作,请在你的作用域里管理我。”
suspend / Flow 就是这种诚实的表达方式。
但如果 Repository 自己启动协程:
- 调用者不知道它是耗时操作
- 调用者不知道它是否已经执行完
- 调用者无法等待结果
- 调用者无法组合多个异步任务
调用链变得不透明、不可靠。
❌ 4. 并发行为不可控(隐藏的巨大风险)
这是很多人忽略但非常致命的问题。
当 Repository 自己启动协程时:
- 调用者不知道它什么时候开始
- 调用者不知道它什么时候结束
- 调用者不知道它是否会被取消
- 调用者不知道它是否会被合并、串行化、限流
- 调用者不知道它是否会与其他任务竞争资源
这意味着:
Repository 内部的协程是“黑箱并发”,完全不受上层调度策略的约束。
举个例子
ViewModel 调用三次:
执行顺序完全不可预测:
- A → B → C
- C → A → B
- 三个同时写,互相覆盖
更糟的是:
- 无法串行
- 无法限流
- 无法取消
- 无法等待完成
这就是典型的并发灾难。
⚠️ 七、反模式流程图(Repository 自己启动协程)
🧭 八、总结:为什么这是“教科书级”的设计?
因为它同时满足:
- 🧱 职责纯粹
- 🧵 主线程安全
- ⚠️ 异常可控
- 🧪 可测试性强
- 💡 遵守结构化并发
- 🔗 调用链透明
- 🔥 并发行为完全可控
💡 九、进阶:Repository 如何优雅地支持并发?
有人会问:“如果 Repository 需要同时请求两个接口来合并数据,不启动协程怎么实现并发?”
这正是很多人误入歧途的原因——他们分不清 CoroutineScope(作用域实例) 和 coroutineScope(作用域构建器) 的区别。
1. 错误的并发实现:使用外部 Scope
2. 正确的并发实现:使用 coroutineScope 挂起函数
如果你需要内部并发,应该使用 coroutineScope { ... } 块。它是一个 挂起函数,会等待内部所有子任务完成后再返回。
3. 为什么 coroutineScope 是完美的补充?
- 对外表现一致:它依然是一个
suspend函数,符合“Repository 只提供挂起函数”的原则。 - 结构化并发:如果 ViewModel 取消了调用这个函数的协程,
coroutineScope内部的所有async任务也会被自动取消,绝不浪费资源。
🎯 终极闭环总结
在设计架构时,请死守这条红线:
- Repository 绝不持有生命周期(CoroutineScope) :它不该决定任务“活多久”。
- Repository 内部可以利用
coroutineScope { ... }实现并发:但它必须等所有任务结束才返回,做一个“诚实”的执行者。 - 决策权上交给 ViewModel:由 ViewModel 决定是在
viewModelScope中并行启动多个 Repository 方法,还是顺序调用。
只有当 Repository 是“被动”且“诚实”的,你的整个架构才是可预测、健壮且易于测试的。
一句话
Repository 不启动协程,是为了让整个应用的生命周期、异常、并发、线程调度都保持可控、透明、可维护。