承重墙:这部分没变
拆房子之前先看图纸:哪几面墙动不得。这一章讲的全是你 2024 年就会的东西 —— 分层、单向数据流、唯一真相。我不打算把它们重讲一遍来浪费你的时间,而是要做一件更有用的事:告诉你每一条规矩当初是为了防哪个具体的事故。因为接下来三章你会看到,有的项目故意不守其中某一条 —— 只有知道那条规矩在防什么,你才判断得了它们是不是在裸奔。
三层,以及为什么是三层
官方那张分层图你肯定见过。这里重画一遍,但每一层后面我加一句「它到底在替你挡什么」:
注意最后一层。很多人把分层理解成「代码整洁」的品味问题 —— 不是。分层的每一刀,切的都是一条 「谁有权改这个事实」的线。而所有难查的 bug,几乎都长在这条线被踩过的地方。
单向数据流:它在防什么事故
单向数据流(UDF)这个词,拆开只有两句话:状态只朝一个方向流,事件只朝另一个方向流,不许抄近路。
听起来像洁癖。所以我们把它和它的反面放在一起跑一遍。下面这个单步器,让你亲手走完一次「用户点了收藏」: 左边是官方那条七步回路,你也可以切到「双向绑定」—— 那条只要两步的捷径。然后打开「让写库失败」, 看它们在同一个故障下,分别会发生什么。
走完你会发现,整件事的关键不在于「代码分了几层」,而在于一句话:
在 UDF 里,UI 没有权限改自己的状态。它只能发一个事件,然后等着新状态从数据层流回来。 它看起来又笨又绕 —— 点个收藏要穿四层楼。
但这个「笨」买到了一个非常硬的性质:UI 上显示的东西,永远等于库里真正存着的东西。 它想撒谎都没有机会,因为它根本没有改自己的能力。
而双向绑定的两步捷径,把这个能力还给了 UI。于是写库失败的那一刻,UI 说「已收藏」,库说「没有」—— 用户下拉刷新一下,那颗星星就莫名其妙地灭了。这类 bug 极难复现,因为它只在失败路径上出现。
被混用了很多年的两根轴
这里必须停下来澄清一件事,因为它是 Compose 讨论里最常见的一个概念糊涂,而且你在面试里说清楚它会显得很专业。
「状态下行,事件上行」(state down, events up)—— 这句口号说的是哪一根轴?
轴一:composable 树。父在上、子在下。状态作为参数从父传给子(下行), 事件作为 lambda 从子回调给父(上行)。「状态提升」讲的就是这根轴 —— 把 state 从子挪到父那儿去。
轴二:架构分层。UI 在上、数据层在下。用户事件从 UI 往下穿到数据层, 新状态从数据层往上流回 UI。上一个 Demo 画的就是这根轴。
同一个原则,两个尺度,方向标反。所以当有人说「事件上行」时,先问一句他站在哪根轴上 —— 在 composable 树里事件往上走,在分层图里事件往下走。它们并不矛盾,但混着说必然把人绕晕。
状态提升(轴一)长这样,这部分完全没变:
// 坏:状态藏在子 composable 里,外面谁也管不着,也没法测
@Composable
fun BookmarkButton(articleId: String) {
var bookmarked by remember { mutableStateOf(false) } // ← 它自己偷偷持有
IconButton(onClick = { bookmarked = !bookmarked }) { ... }
}
// 好:状态提升上去,子 composable 变成一个纯函数
@Composable
fun BookmarkButton(
bookmarked: Boolean, // ← 状态下行(轴一)
onToggle: () -> Unit, // ← 事件上行(轴一)
) {
IconButton(onClick = onToggle) { ... }
}
提升之后的 BookmarkButton 是无状态的:给什么画什么。它好测(传参、断言)、好预览
(@Preview 里直接给个 true)、好复用。这条规矩 2021 年就立下了,至今一字未改。
唯一真相(SSOT):最容易被跳过的那条
SSOT 的意思是:同一个事实,整个 app 里只有一个权威副本。「这篇文章有没有被收藏」这个问题, 只有一个地方能回答 —— 通常是本地数据库。
为什么是本地库,而不是服务器?这是离线优先架构的核心决定,第 5 章会整章讲。这里先记住那个反面:
// 反面教材:同一个事实,三份副本
class ArticleViewModel : ViewModel() {
private val _bookmarked = MutableStateFlow(false) // 副本 1:VM 里的
// UI 里还有一个 remember { mutableStateOf(...) } // 副本 2:UI 里的
// 服务器上还有一个 // 副本 3:远端的
// 数据库里可能还有一个 // 副本 4……
}
// 现在请回答:用户看到的那颗星星,反映的是哪一份?
// 更狠的问题:当它们四个不一致时,谁是对的?
一旦同一个事实有两份副本,你就必须写同步逻辑。而同步逻辑永远会在某个时序下失败 —— 这就是那类「偶现」bug 的老家。
SSOT 的做法是釜底抽薪:压根不存第二份。UI 不缓存、ViewModel 不缓存,
它们都只是订阅那唯一一份的 Flow。没有第二份,就没有不一致,也就没有同步逻辑要写。
Repository 返回 Flow,而不是返回数据
把上面几条合起来,数据层的形状就固定了:
interface ArticleRepository {
// 注意:不是 suspend fun getArticles(): List<Article>
// 而是返回一条「会持续发新值」的流
fun observeArticles(): Flow<List<Article>>
suspend fun setBookmarked(id: String, bookmarked: Boolean)
suspend fun refresh() // 拉网络 → 写库。它不返回数据!
}
observeArticles(): Flow—— 读是订阅,不是请求。库一变,所有订阅者自动收到。refresh()不返回数据 —— 它的职责只是「把新数据写进库」。数据怎么到 UI?走observeArticles()那条流。 网络永远不直接喂给 UI。setBookmarked是suspend且无返回 —— 写完就完了,新状态照样从Flow流回来。
这套签名逼着你走 UDF。你想抄近路都无从下手 —— 因为 refresh() 根本没东西给你。
好的架构就该是这样:不靠自觉,靠类型系统。
领域层:你多半不需要它
官方分层图里那个中间层(UseCase / Interactor),被 Clean Architecture 的信徒们供得很高。说点实话:
一个只有一行 return repo.observeArticles() 的 GetArticlesUseCase,
什么也没给你 —— 除了多一个文件、多一层跳转、多一次构造注入。这种 UseCase 在真实项目里遍地都是。
它值得存在,只有两种情况:
- 同一段业务逻辑被两个以上的 ViewModel 需要。这时抽出来是真的在消除重复。
- 一段逻辑要合并多个 Repository(比如「把文章流和用户的收藏流 zip 起来」)。这活儿放 VM 里会把 VM 撑肿。
Now in Android 的做法值得抄:它只在真的需要时才建 UseCase, 大部分 ViewModel 直接注入 Repository。官方样板自己都不无脑套三层。
那么,到底什么变了?
回头看这一章:分层、UDF、SSOT、状态提升、Repository 返回 Flow —— 这五条,2026 年一条都没变。
变的是它们底下的东西。同样是「UI 层」,那个 UI 层现在由 Navigation 3 组织,
由一个不再需要你操心稳定性的编译器编译。同样是「状态持有者」,它现在可能是 ViewModel,
也可能是 ScreenModel,还可能是一个返回 state 的 @Composable 函数。同样是「数据层」,
它现在可能整体住在 commonMain 里,同时喂给 Android 和 iOS。
- UI 不是状态的主人,它只是渲染器。它撒不了谎,是因为它没有改自己的权限。
- 「状态下行、事件上行」有两根轴:composable 树(父子)和架构分层(UI/数据)。方向正好相反,别混着说。
- SSOT 不是洁癖,是让「谁说了算」这个问题有唯一答案 —— 从根上消灭同步逻辑。
- Repository 返回
Flow,refresh()不返回数据 —— 用类型系统堵死抄近路的可能。 - 领域层是可选的,连官方样板都不无脑套。
1. 打开你离开前的那个项目,找一处 remember { mutableStateOf(...) },判断它是纯 UI 状态(比如「这个折叠面板展开了没」,该留在 UI)还是业务状态(比如「收藏了没」,该提升到状态持有者)。这条线你划得越准,后面三章越好懂。
2. 找一个 suspend fun getXxx(): List<T> 形式的 Repository 方法,想一想:它逼着调用方做了什么?(答案:调用方必须自己缓存、自己决定何时重新请求 —— 这两个决定本该是数据层的。)
承重结构确认完毕。下一章开始拆地基 —— 先给你一份完整的变更清单,把这 18 个月里 真正会改变你写代码方式的东西,一条一条列出来。第一条就是:你以前为「稳定性」做的那些体操,现在编译器自己做了。