前段时间,和一个年轻开发者聊 Android 架构。
他谈起 MVVM。
谈起 MVI。
谈起 Clean Architecture。
也谈起各种设计模式。
他说了很多。
我听了很久。
然后问了一个问题。
"为什么 Repository 不应该随意在内部启动协程?"
他沉默了。
很多人学架构。
学的是工具。
学的是框架。
学的是分层。
学的是各种图。
但很少有人真正思考:
这些规则究竟是为了解决什么问题?
项目崩溃。
不是因为少了一张架构图。
而是因为责任不清。
任务失控。
依赖混乱。
状态失去了唯一来源。
而架构师真正要做的事情。
从来不是画图。
而是在混乱出现之前。
制定规则。
让每一种行为都有边界。
让每一份责任都有归属。
第一条铁律:Repository 必须 Main Safe
一个成熟的系统。
不应该把自己的问题交给别人。
如果调用 Repository 的时候。
还需要思考:
scss
withContext(Dispatchers.IO)
应该放在哪里。
那么问题已经出现了。
调用方只应该关心业务。
而不是 Repository 内部如何执行。
例如:
kotlin
suspend fun getUser(): User
无论内部使用:
- Room
- Retrofit
- 文件读写
- JNI
- 其他阻塞式接口
Repository 都应该保证这个接口可以安全地从主线程调用。
例如:
kotlin
class UserRepository(
private val api: UserApi,
private val ioDispatcher: CoroutineDispatcher
) {
suspend fun getUser(): User =
withContext(ioDispatcher) {
api.getUser()
}
}
当然,不是所有操作都应该机械地切换到 IO。
CPU 密集型任务应该考虑 Default。
本身已经具备主线程安全能力的挂起 API,也不需要重复切换线程。
真正重要的是:
Repository 对自己的执行策略负责,而不是把线程调度的负担交给调用方。
第二条铁律:Repository 不得随意启动脱离调用方的任务
很多线上问题。
都来自一句代码。
arduino
scope.launch {
// 执行业务任务
}
任务启动了。
却没人知道:
- 谁负责结束?
- 谁负责异常?
- 谁负责等待结果?
- 谁负责处理重复任务?
看起来很方便。
实际上已经脱离控制。
例如:
kotlin
class UserRepository {
fun refresh() {
CoroutineScope(Dispatchers.IO).launch {
// 刷新用户数据
}
}
}
调用方甚至不知道任务什么时候完成。
更不知道它什么时候结束。
所以规则很简单:
谁发起任务,谁拥有任务。
Repository 提供能力。
调用方决定任务生命周期。
kotlin
class UserViewModel(
private val repository: UserRepository
) : ViewModel() {
fun refresh() {
viewModelScope.launch {
repository.refresh()
}
}
}
这里,任务属于 ViewModel 的生命周期。
ViewModel 销毁。
任务也会随之取消。
当然,应用级同步任务可以由应用级作用域管理。
但必须明确任务的所有者。
不要让任务成为一个无人管理的孤儿。
第三条铁律:状态必须有明确的唯一事实来源
混乱的开始。
往往来自多个真相。
Activity 改状态。
Fragment 改状态。
Service 改状态。
Repository 也维护一份状态。
最后。
每个人都说自己是正确的。
系统却不知道该相信谁。
这就是为什么我们需要:
Single Source Of Truth(SSOT)。
例如用户信息:
yaml
UI
|
v
ViewModel
|
v
Repository
|
v
Room
Room 作为本地用户数据的唯一事实来源。
网络请求成功后。
Repository 更新 Room。
UI 通过 Flow 观察 Room。
数据发生变化。
UI 自动更新。
这里需要注意:
SSOT 并不是说整个应用只能有一个状态对象。
而是对于同一份业务数据,必须有明确、可信的事实来源。
一个事实,不应该有多个互相竞争的主人。
第四条铁律:一次性操作使用 suspend,持续数据使用 Flow
单次动作。
和持续变化。
本来就不是同一种东西。
例如登录:
kotlin
suspend fun login(
account: String,
password: String
): User
它是一次操作。
调用一次。
等待一个结果。
观察用户信息:
kotlin
fun observeUser(): Flow<User>
它是持续的数据流。
数据可能发生多次变化。
订阅者持续接收更新。
两者的语义完全不同。
如果所有异步操作都使用 Callback。
调用关系容易逐渐变得复杂。
如果所有操作都包装成 Flow。
又会让一次性操作失去原本简单的语义。
所以:
- 一次性操作,使用 suspend。
- 持续数据变化,使用 Flow。
- 当前 UI 状态,通常使用 StateFlow。
- 一次性 UI 效果,根据场景选择合适的事件传递机制。
不要为了统一 API。
而牺牲 API 本身的语义。
好的接口设计,不是形式统一,而是表达准确。
第五条铁律:禁止用全局事件总线隐藏业务依赖
最危险的依赖。
往往是看不见的依赖。
发送者不知道谁接收。
接收者不知道谁发送。
两者之间没有明确的关系。
刚开始很方便。
后来越来越混乱。
例如:
less
FlowBus.post(UserLoginEvent())
另一个地方:
arduino
FlowBus.subscribe<UserLoginEvent> {
// 更新用户信息
}
代码看起来很简单。
但问题来了:
- 谁发送了这个事件?
- 谁订阅了这个事件?
- 订阅者什么时候开始工作?
- 订阅者什么时候结束?
- 多个订阅者之间有没有顺序要求?
出了问题以后。
所有人都在问:
这个事件是谁发的?
事件机制本身没有错。
广播、系统通知、跨模块事件,都有合理的使用场景。
真正的问题是:
使用全局事件总线,隐藏原本应该明确的业务依赖关系。
能够通过明确的接口表达的依赖。
就不要随意改造成全局事件。
看不见的关系。
最难维护。
第六条铁律:异常必须有明确的处理责任
很多系统不是死于异常。
而是死于异常消失。
这样的代码很常见:
php
try {
repository.getUser()
} catch (e: Exception) {
}
什么都没有发生。
却什么都已经发生了。
调用方不知道失败。
日志里没有记录。
系统继续运行。
然后慢慢腐烂。
还有一种更隐蔽的问题。
在 Kotlin 协程中:
php
try {
repository.getUser()
} catch (e: Exception) {
// 处理异常
}
这里可能连 CancellationException 一起捕获。
这会破坏协程的取消语义。
正确的处理方式:
php
try {
repository.getUser()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
// 处理业务异常
}
不同层次应该承担不同的责任:
- Data 层:处理底层异常,必要时转换成业务错误。
- Domain 层:定义业务操作的失败语义。
- UI 层:决定如何向用户展示错误。
异常可以被转换。
可以被处理。
但不能被随意隐藏。
错误不可怕,失去错误的可见性才可怕。
第七条铁律:异步等待优先使用挂起,而不是阻塞线程
很多人已经使用协程。
思维却还停留在线程时代。
他们仍然喜欢:
ini
Future.get();
CountDownLatch.await();
BlockingQueue.take();
这些 API 都可能让线程陷入阻塞等待。
但协程提供了另一种选择。
kotlin
suspend fun getUser(): User {
return suspendCancellableCoroutine { continuation ->
api.getUser { user ->
continuation.resume(user)
}
}
}
等待结果的时候。
协程可以挂起。
执行线程不必一直被占用。
等结果到达。
协程再恢复执行。
这里必须区分两个概念:
| 方式 | 等待时发生什么 |
|---|---|
| 线程阻塞 | 线程被占用 |
| 协程挂起 | 协程暂停,线程可以执行其他任务 |
当然。
协程并不会自动把所有阻塞 API 变成非阻塞 API。
例如:
kotlin
suspend fun getUser(): User =
withContext(Dispatchers.IO) {
blockingApi.getUser()
}
这里仍然存在阻塞。
只是把阻塞操作隔离到了 IO Dispatcher。
协程真正的价值。
不是简单地切换线程。
而是:
在等待结果的时候,不必无谓地占用执行线程。
等待结果是业务需求。
阻塞线程只是实现方式之一。
尾声:架构师真正设计的是什么?
后来那个年轻人又来了。
我还是问他同一个问题。
"架构到底是什么?"
这次他没有提 MVVM。
没有提 MVI。
也没有提任何框架。
他说:
"架构是在项目越来越复杂的时候。"
"仍然让所有人知道。"
"数据从哪里来。"
"状态归谁管理。"
"任务谁负责。"
"异常谁处理。"
"对象活多久。"
"模块应该如何依赖。"
我点了点头。
因为直到这一刻。
他终于明白。
Clean Architecture 帮助我们建立代码边界。
工程实践中的架构规则。
帮助我们建立行为边界。
前者解决依赖关系。
后者解决责任归属。
两者共同决定系统能否长期保持稳定。
架构不是技术名词。
不是框架。
也不是一张漂亮的分层图。
架构是一套规则。
规则形成边界。
边界明确责任。
责任形成秩序。
而秩序。
才是大型系统能够长期存活的原因。
架构师真正设计的不是代码。
而是代码背后的秩序。