架构避坑:为什么 Repository 不该启动协程?

665 阅读5分钟

在 Kotlin 协程的世界里,Repository 是否应该启动协程,是一个常见但危险的误区。很多项目为了“方便”,直接在 Repository 中 launch {},但这其实是一种典型的 反模式。比如下面的例子。

image.png

之前我写过一篇repo重构文章Kotlin 协程合理管理协程作用域:从 CoroutineScope 到 suspend 函数的重构实践

真正的最佳实践是:

Repository 不启动协程,只提供挂起函数(suspend)或 Flow,由上层(如 ViewModel)来决定何时、在哪个作用域执行协程。

为什么这是“教科书级”的设计?为什么 Repository 启动协程是反模式?我们从职责、结构化并发、异常处理、并发控制等角度逐一拆解。

🧱 一、Repository 的职责必须“纯粹”

Repository 的使命只有一个:

提供一致的数据访问接口,屏蔽数据源细节。

它应该只关心:

  • “怎么写文件”
  • “在哪个线程写文件”

而不应该关心:

  • 谁来启动协程
  • 协程何时结束
  • 协程是否需要取消
  • 生命周期如何管理
  • 并发策略如何控制

Repository 没有自己的 CoroutineScope,如果它擅自启动协程,就会产生无法管理的“野协程”,这是架构的大忌。

🧵 二、正确做法流程图(教科书级)

下面是 正确架构 的流程图,展示了 ViewModel 如何作为“决策者”启动协程,而 Repository 只负责执行挂起函数:

mermaid_20260102_4aca34.png 特点:

  • 生命周期由 ViewModel 管理
  • 线程切换由 Repository 内部保证
  • 协程结构清晰、可控
  • 完全符合结构化并发

🧵 三、主线程安全(Main-safety)天然具备

正确写法:

image.png

ViewModel 调用:

image.png

即使 launch 在主线程,执行到 withContext(Dispatchers.IO) 时会自动挂起并切换线程,UI 不会卡顿。

⚠️ 四、异常处理(

Repository 内部捕获异常

image.png

优点:

  • Repository 对自己的行为负责
  • 异常不会被吞掉
  • 调用者能明确知道失败原因

🧪 五、可测试性极强

Repository 不启动协程后,测试变得非常简单:

image.png

不需要 delay,不需要等待协程

💡 六、为什么 Repository 启动协程是“反模式”?

❌ 1. 违反结构化并发(Structured Concurrency)

协程世界的核心理念是:

所有协程都必须有明确的父作用域,生命周期必须可控。

Repository 启动协程意味着:

  • 它创建了一个“无父协程”
  • 生命周期不可控
  • 调用者无法取消
  • 异常无法冒泡

这直接破坏了结构化并发的根基。

❌ 2. 责任链断裂:Repository 僭越了“决策者”的角色

在清晰的架构中:

  • Repository:提供操作
  • ViewModel / UseCase:编排操作、决定何时执行、在哪个作用域执行

如果 Repository 自己启动协程,它就越权了:

  • 它决定了任务何时执行
  • 它决定了任务在哪个线程执行
  • 它决定了任务是否可取消

这让上层完全失去控制权。

❌ 3. 调用者被“蒙在鼓里”

一个好的函数应该诚实地告诉调用者:

“我是一个耗时操作,请在你的作用域里管理我。”

suspend / Flow 就是这种诚实的表达方式。

但如果 Repository 自己启动协程:

  • 调用者不知道它是耗时操作
  • 调用者不知道它是否已经执行完
  • 调用者无法等待结果
  • 调用者无法组合多个异步任务

调用链变得不透明、不可靠。

❌ 4. 并发行为不可控(隐藏的巨大风险)

这是很多人忽略但非常致命的问题。

当 Repository 自己启动协程时:

  • 调用者不知道它什么时候开始
  • 调用者不知道它什么时候结束
  • 调用者不知道它是否会被取消
  • 调用者不知道它是否会被合并、串行化、限流
  • 调用者不知道它是否会与其他任务竞争资源

这意味着:

Repository 内部的协程是“黑箱并发”,完全不受上层调度策略的约束。

举个例子

image.png

ViewModel 调用三次:

image.png

执行顺序完全不可预测:

  • A → B → C
  • C → A → B
  • 三个同时写,互相覆盖

更糟的是:

  • 无法串行
  • 无法限流
  • 无法取消
  • 无法等待完成

这就是典型的并发灾难。

⚠️ 七、反模式流程图(Repository 自己启动协程)

mermaid_20260102_2fb241.png

🧭 八、总结:为什么这是“教科书级”的设计?

因为它同时满足:

  • 🧱 职责纯粹
  • 🧵 主线程安全
  • ⚠️ 异常可控
  • 🧪 可测试性强
  • 💡 遵守结构化并发
  • 🔗 调用链透明
  • 🔥 并发行为完全可控

💡 九、进阶:Repository 如何优雅地支持并发?

有人会问:“如果 Repository 需要同时请求两个接口来合并数据,不启动协程怎么实现并发?”

这正是很多人误入歧途的原因——他们分不清 CoroutineScope(作用域实例)coroutineScope(作用域构建器) 的区别。

1. 错误的并发实现:使用外部 Scope

image.png

2. 正确的并发实现:使用 coroutineScope 挂起函数

如果你需要内部并发,应该使用 coroutineScope { ... } 块。它是一个 挂起函数,会等待内部所有子任务完成后再返回。

image.png

3. 为什么 coroutineScope 是完美的补充?

  • 对外表现一致:它依然是一个 suspend 函数,符合“Repository 只提供挂起函数”的原则。
  • 结构化并发:如果 ViewModel 取消了调用这个函数的协程,coroutineScope 内部的所有 async 任务也会被自动取消,绝不浪费资源。

🎯 终极闭环总结

在设计架构时,请死守这条红线:

  1. Repository 绝不持有生命周期(CoroutineScope) :它不该决定任务“活多久”。
  2. Repository 内部可以利用 coroutineScope { ... } 实现并发:但它必须等所有任务结束才返回,做一个“诚实”的执行者。
  3. 决策权上交给 ViewModel:由 ViewModel 决定是在 viewModelScope 中并行启动多个 Repository 方法,还是顺序调用。

只有当 Repository 是“被动”且“诚实”的,你的整个架构才是可预测、健壮且易于测试的。

一句话

Repository 不启动协程,是为了让整个应用的生命周期、异常、并发、线程调度都保持可控、透明、可维护。