Android 架构七条铁律:架构师真正设计的不是代码,而是秩序

1 阅读7分钟

前段时间,和一个年轻开发者聊 Android 架构。

他谈起 MVVM。

谈起 MVI。

谈起 Clean Architecture。

也谈起各种设计模式。

他说了很多。

我听了很久。

然后问了一个问题。


"为什么 Repository 不应该随意在内部启动协程?"


他沉默了。


很多人学架构。

学的是工具。

学的是框架。

学的是分层。

学的是各种图。

但很少有人真正思考:

这些规则究竟是为了解决什么问题?


项目崩溃。

不是因为少了一张架构图。

而是因为责任不清。

任务失控。

依赖混乱。

状态失去了唯一来源。


而架构师真正要做的事情。

从来不是画图。

而是在混乱出现之前。

制定规则。

让每一种行为都有边界。

让每一份责任都有归属。


第一条铁律:Repository 必须 Main Safe

一个成熟的系统。

不应该把自己的问题交给别人。


如果调用 Repository 的时候。

还需要思考:

withContext(Dispatchers.IO)

应该放在哪里。

那么问题已经出现了。


调用方只应该关心业务。

而不是 Repository 内部如何执行。

例如:

suspend fun getUser(): User

无论内部使用:

  • Room
  • Retrofit
  • 文件读写
  • JNI
  • 其他阻塞式接口

Repository 都应该保证这个接口可以安全地从主线程调用。

例如:

class UserRepository(
    private val api: UserApi,
    private val ioDispatcher: CoroutineDispatcher
) {

    suspend fun getUser(): User =
        withContext(ioDispatcher) {
            api.getUser()
        }
}

当然,不是所有操作都应该机械地切换到 IO。

CPU 密集型任务应该考虑 Default。

本身已经具备主线程安全能力的挂起 API,也不需要重复切换线程。

真正重要的是:

Repository 对自己的执行策略负责,而不是把线程调度的负担交给调用方。


第二条铁律:Repository 不得随意启动脱离调用方的任务

很多线上问题。

都来自一句代码。

scope.launch {
    // 执行业务任务
}

任务启动了。

却没人知道:

  • 谁负责结束?
  • 谁负责异常?
  • 谁负责等待结果?
  • 谁负责处理重复任务?

看起来很方便。

实际上已经脱离控制。


例如:

class UserRepository {

    fun refresh() {
        CoroutineScope(Dispatchers.IO).launch {
            // 刷新用户数据
        }
    }
}

调用方甚至不知道任务什么时候完成。

更不知道它什么时候结束。


所以规则很简单:

谁发起任务,谁拥有任务。

Repository 提供能力。

调用方决定任务生命周期。

class UserViewModel(
    private val repository: UserRepository
) : ViewModel() {

    fun refresh() {
        viewModelScope.launch {
            repository.refresh()
        }
    }
}

这里,任务属于 ViewModel 的生命周期。

ViewModel 销毁。

任务也会随之取消。

当然,应用级同步任务可以由应用级作用域管理。

但必须明确任务的所有者。

不要让任务成为一个无人管理的孤儿。


第三条铁律:状态必须有明确的唯一事实来源

混乱的开始。

往往来自多个真相。


Activity 改状态。

Fragment 改状态。

Service 改状态。

Repository 也维护一份状态。

最后。

每个人都说自己是正确的。

系统却不知道该相信谁。


这就是为什么我们需要:

Single Source Of Truth(SSOT)。

例如用户信息:

             UI
              |
              v
          ViewModel
              |
              v
          Repository
              |
              v
             Room

Room 作为本地用户数据的唯一事实来源。

网络请求成功后。

Repository 更新 Room。

UI 通过 Flow 观察 Room。

数据发生变化。

UI 自动更新。


这里需要注意:

SSOT 并不是说整个应用只能有一个状态对象。

而是对于同一份业务数据,必须有明确、可信的事实来源。

一个事实,不应该有多个互相竞争的主人。


第四条铁律:一次性操作使用 suspend,持续数据使用 Flow

单次动作。

和持续变化。

本来就不是同一种东西。


例如登录:

suspend fun login(
    account: String,
    password: String
): User

它是一次操作。

调用一次。

等待一个结果。


观察用户信息:

fun observeUser(): Flow<User>

它是持续的数据流。

数据可能发生多次变化。

订阅者持续接收更新。


两者的语义完全不同。

如果所有异步操作都使用 Callback。

调用关系容易逐渐变得复杂。

如果所有操作都包装成 Flow。

又会让一次性操作失去原本简单的语义。

所以:

  • 一次性操作,使用 suspend。
  • 持续数据变化,使用 Flow。
  • 当前 UI 状态,通常使用 StateFlow。
  • 一次性 UI 效果,根据场景选择合适的事件传递机制。

不要为了统一 API。

而牺牲 API 本身的语义。

好的接口设计,不是形式统一,而是表达准确。


第五条铁律:禁止用全局事件总线隐藏业务依赖

最危险的依赖。

往往是看不见的依赖。


发送者不知道谁接收。

接收者不知道谁发送。

两者之间没有明确的关系。


刚开始很方便。

后来越来越混乱。

例如:

FlowBus.post(UserLoginEvent())

另一个地方:

FlowBus.subscribe<UserLoginEvent> {
    // 更新用户信息
}

代码看起来很简单。

但问题来了:

  • 谁发送了这个事件?
  • 谁订阅了这个事件?
  • 订阅者什么时候开始工作?
  • 订阅者什么时候结束?
  • 多个订阅者之间有没有顺序要求?

出了问题以后。

所有人都在问:

这个事件是谁发的?


事件机制本身没有错。

广播、系统通知、跨模块事件,都有合理的使用场景。

真正的问题是:

使用全局事件总线,隐藏原本应该明确的业务依赖关系。

能够通过明确的接口表达的依赖。

就不要随意改造成全局事件。

看不见的关系。

最难维护。


第六条铁律:异常必须有明确的处理责任

很多系统不是死于异常。

而是死于异常消失。


这样的代码很常见:

try {
    repository.getUser()
} catch (e: Exception) {

}

什么都没有发生。

却什么都已经发生了。

调用方不知道失败。

日志里没有记录。

系统继续运行。

然后慢慢腐烂。


还有一种更隐蔽的问题。

在 Kotlin 协程中:

try {
    repository.getUser()
} catch (e: Exception) {
    // 处理异常
}

这里可能连 CancellationException 一起捕获。

这会破坏协程的取消语义。

正确的处理方式:

try {
    repository.getUser()
} catch (e: CancellationException) {
    throw e
} catch (e: Exception) {
    // 处理业务异常
}

不同层次应该承担不同的责任:

  • Data 层:处理底层异常,必要时转换成业务错误。
  • Domain 层:定义业务操作的失败语义。
  • UI 层:决定如何向用户展示错误。

异常可以被转换。

可以被处理。

但不能被随意隐藏。

错误不可怕,失去错误的可见性才可怕。


第七条铁律:异步等待优先使用挂起,而不是阻塞线程

很多人已经使用协程。

思维却还停留在线程时代。


他们仍然喜欢:

Future.get();

CountDownLatch.await();

BlockingQueue.take();

这些 API 都可能让线程陷入阻塞等待。


但协程提供了另一种选择。

suspend fun getUser(): User {
    return suspendCancellableCoroutine { continuation ->
        api.getUser { user ->
            continuation.resume(user)
        }
    }
}

等待结果的时候。

协程可以挂起。

执行线程不必一直被占用。

等结果到达。

协程再恢复执行。


这里必须区分两个概念:

方式等待时发生什么
线程阻塞线程被占用
协程挂起协程暂停,线程可以执行其他任务

当然。

协程并不会自动把所有阻塞 API 变成非阻塞 API。

例如:

suspend fun getUser(): User =
    withContext(Dispatchers.IO) {
        blockingApi.getUser()
    }

这里仍然存在阻塞。

只是把阻塞操作隔离到了 IO Dispatcher。


协程真正的价值。

不是简单地切换线程。

而是:

在等待结果的时候,不必无谓地占用执行线程。

等待结果是业务需求。

阻塞线程只是实现方式之一。


尾声:架构师真正设计的是什么?

后来那个年轻人又来了。

我还是问他同一个问题。


"架构到底是什么?"


这次他没有提 MVVM。

没有提 MVI。

也没有提任何框架。


他说:


"架构是在项目越来越复杂的时候。"

"仍然让所有人知道。"

"数据从哪里来。"

"状态归谁管理。"

"任务谁负责。"

"异常谁处理。"

"对象活多久。"

"模块应该如何依赖。"


我点了点头。


因为直到这一刻。

他终于明白。


Clean Architecture 帮助我们建立代码边界。

工程实践中的架构规则。

帮助我们建立行为边界。

前者解决依赖关系。

后者解决责任归属。

两者共同决定系统能否长期保持稳定。


架构不是技术名词。

不是框架。

也不是一张漂亮的分层图。

架构是一套规则。

规则形成边界。

边界明确责任。

责任形成秩序。

而秩序。

才是大型系统能够长期存活的原因。


架构师真正设计的不是代码。

而是代码背后的秩序。