📌 本文面向有 Android 基础、了解协程基本用法,但尚未接触 Clean Architecture 的读者。
前言
Learn-Kotlin-Coroutines 原本是 Amit Shekhar 的一个 Android 协程教学项目(仓库地址)。Amit Shekhar 本身是一位优秀的 Android 实战性讲师,这个项目在大约四年前的语境下,是一个质量不错、很适合入门的示例工程。
它的示例场景覆盖相当完整:
ui/basic:基础协程示例ui/retrofit/*:单次、串行、并行网络请求ui/room:本地数据库ui/errorhandling/*:异常处理相关示例ui/task/*、ui/timeout:长任务、超时等协程场景
这种组织方式非常适合入门------页面直接,学习路径清楚,每个示例基本可以单独理解。
但一旦目标从「演示协程用法」变成「作为可继续演进的 Android 项目基础」,问题就开始放大了。
这篇文章不是在否定原项目的教学价值,而是在保留它示例价值的前提下,对它做一次现代化、工程化的重构,并把这个过程完整记录下来。
一眼看清改动全貌
在进入细节之前,先用一张表把改造前后的差异说清楚:
| 维度 | 重构前 | 重构后 |
|---|---|---|
| 依赖管理 | 手动 Factory / 页面传递 | Koin 统一注入 |
| ViewModel 职责 | 既调数据源,又管异常和状态 | 只协调 UseCase 与 StateFlow<UiState> |
| 业务逻辑位置 | 散落在 ViewModel | 下沉到 UseCase |
| 数据驱动方式 | 主动拉取、一次性请求 | Flow 持续发射,UI 订阅状态 |
| 异常处理 | 每个页面单独 try-catch |
Repository 统一封装 Resource |
| 工程结构 | 示例堆叠 | UI → Domain → Data 分层 |
一、先把方法论说清楚:这次重构遵守了哪些结构原则
如果以后再遇到类似的 Android 示例项目,应该按什么原则去重构。
原则 1:依赖方向必须固定
UI → Domain → Data
UI依赖DomainDomain依赖抽象,不依赖具体数据实现Data负责实现这些抽象
一旦这个方向固定,层与层之间的职责就不会继续相互渗透。
原则 2:UI 层只做三件事
✅ 订阅状态
✅ 触发行为
✅ 渲染界面
❌ 不创建依赖
❌ 不编排业务
❌ 不处理底层异常策略
原则 3:Domain 层是稳定边界
Domain 层承担「结构稳定器」的角色:
UseCase:表达业务动作Repository接口:隔离数据实现Resource:统一业务结果模型
UI 怎么变、数据源怎么变,都不应该直接冲击到 Domain 的边界表达。
原则 4:Data 层不是搬运层,而是策略层
Data 层不只是「调接口 + 查数据库」,它还负责:
- 缓存优先级
- 刷新策略
- 错误传播策略
- 连续数据流建模
RepositoryImpl 是数据策略协调器,而不是简单的 DAO/Api 包装器。
原则 5:状态应该被订阅,而不是被手动推送
UI 不再主动驱动数据,而是订阅已经存在的状态流。
这也是为什么这次重构最终会落到 Flow → StateFlow → repeatOnLifecycle + collect 这一整条链路上。
二、原项目的问题在哪里
原始版本是一种传统的、偏扁平化的 MVVM 示例结构:

当页面少时,链路短、容易讲清楚。但结构问题很快会暴露:
症状一:手动拼装依赖
原始的 ViewModelFactory:

- 每新增一个
ViewModel,都要改 Factory - Factory 变成越来越大的分发中心
- 测试时很难优雅地替换真实依赖
症状二:ViewModel 知道太多底层细节

症状三:异常处理散落各处

每个页面都在重复写几乎一样的错误处理逻辑,错误建模不统一。
💡 代码的问题不在于它不能运行,而在于它无法以低成本继续演进。说白了,项目大了就难以维护了。
三、第一刀:把依赖图从 UI 层收回来(引入 Koin)
没有 DI 的本质问题不是「代码多」,而是依赖关系被泄露到了 UI 层。
一旦 UI 知道依赖如何构造,它就同时承担了两种职责:展示 + 组装系统。这才是结构逐渐失控的根源。
重构后:集中声明依赖

统一在 Application 中初始化:

页面层终于可以回到它该有的样子:

这一步的工程收益
- ✅ 去掉了手动拼装依赖的重复劳动
- ✅ 给后续分层改造提供了稳定注入入口
- ✅ 让「依赖关系」第一次变成了一个可以集中维护的系统
DI 解决的不只是「怎么创建对象」,而是「谁应该知道依赖关系」。
四、真正的重构:按 Clean Architecture 重划边界
引入 Koin 之后,依赖创建问题解决了。但核心耦合关系还没有消失。
这次改造把职责重新拆成了三层:

ViewModel 解耦:只依赖 UseCase
重构前:

重构后:

对应的业务逻辑下沉到 UseCase:

五、把错误处理下沉到 Repository
目标: 从「防御式写法」转向「结果驱动写法」。
重构后:Repository 统一发射 Flow<Resource<T>>

这里的 Repository 已经不只是「封装数据来源」,而是在承担数据策略协调器的角色,负责:
- 📦 数据优先级(本地还是远程)
- 🔄 刷新策略(何时更新缓存)
- ❌ 错误传播方式(什么时候报错,什么时候吞掉)
- 🌊 数据流是否连续(一次性请求 vs 持续状态流)
ViewModel 此时只负责把结果流映射成 UI 状态流,不再写一行 try-catch:

六、协程不只是会写就行:关键是写对层
串行请求 UseCase
使用 flatMapConcat,保证第一个请求完成后才发起第二个,不会并发执行中间流,符合串行语义:

并行请求 UseCase
使用 combine,两个 Flow 同时订阅,任意一个发射新值时重新合并:

💡 好的示例项目,不应该只教「怎么 launch / async」,还应该教「这些协程代码应该写在哪一层」。
七、重构后的完整结构与数据流
目录结构
bash
app/src/main/java/me/amitshekhar/learn/kotlin/coroutines
├── data
│ ├── api
│ ├── local
│ └── repository
├── di
│ └── module
├── domain
│ ├── base
│ ├── repository
│ └── usecase
├── ui
│ ├── base
│ ├── basic
│ ├── errorhandling
│ ├── retrofit
│ ├── room
│ ├── task
│ └── timeout
└── CoroutinesApp.kt
以前是「功能按页面堆起来」,现在是「职责按层次分开」。
依赖关系与数据流

Flow 在三层中的不同角色
| 层级 | Flow 的角色 | 具体形态 |
|---|---|---|
| Repository | 连续数据流建模 | Flow<Resource<T>>,先发本地,再发远程 |
| ViewModel | 定义 UI 应该消费什么状态 | StateFlow<UiState<T>>,映射 Resource |
| UI | 在正确的生命周期里消费状态 | repeatOnLifecycle + collect |
这是这次重构里最底层的变化:

UI 不再驱动数据,而是消费状态。
八、如果今天重新学习这个项目,推荐按这个顺序看
ui/basic--- 理解最基础的协程用法single / series / parallel--- 理解不同请求模型domain/usecase--- 理解这些协程逻辑为什么被放在这里data/repository/UserRepositoryImpl.kt--- 理解本地缓存、远程刷新和结果封装- 各个 Activity 中的
repeatOnLifecycle + collect--- 理解 UI 如何订阅状态流 di/module/AppModule.kt和CoroutinesApp.kt--- 把依赖注入链路串起来
九、现在这个项目里有哪些示例入口
首页 MainActivity 目前提供了这些入口:
| 示例 | 路径 |
|---|---|
| 基础协程示例 | ui/basic |
| 单次网络请求 | ui/retrofit/single |
| 串行网络请求 | ui/retrofit/series |
| 并行网络请求 | ui/retrofit/parallel |
| Room 数据库读取 | ui/room |
| 超时控制 | ui/timeout |
| try-catch 异常处理 | ui/errorhandling/trycatch |
| SupervisorJob / 错误隔离 | ui/errorhandling/supervisor |
| 单个长任务 | ui/task/onetask |
| 两个长任务 | ui/task/twotasks |
最后:这次重构到底改变了什么
这次重构的本质,不是引入了多少新技术,而是完成了三件更关键的事情:
- 让依赖关系从「隐式分散」变成「显式集中」
- 让业务逻辑从「UI 层泄露」回归到「稳定边界(UseCase)」
- 让数据流从「一次性请求」升级为「可持续状态流(Flow)」
最终结果不是代码更复杂,而是:
架构实现了高度解耦,新需求的介入不再引发全身性的震荡。
如果你也有一个「能跑,但结构开始变重」的 Android 示例项目,这条优化路径值得参考。
参考: 从送外卖看Android Clean架构:为什么老板不需要知道外卖员开什么车