你已经会用了,缺的是底层为什么自己就跑起来了。这份教程绕过 API 罗列,直接讲清楚那条隐形的闭环:事件往上走,状态往下流,而 UI 的"自动更新"其实是一套 MVCC 快照系统——和你 751 学的 Snapshot Isolation 是同一套思想。
这就是单向数据流 (UDF)。整份教程就是在解释这个环里每一段是怎么自动接上的——尤其是从 State 改变 到 UI 重新画 之间那段"魔法"。
先把命令式的旧习惯戒掉。在 View 时代你是主动推:拿到 TextView 引用,textView.text = "..."。在 Compose 里你不碰 UI——你只改一个状态,UI 是状态的一个纯函数:UI = f(state)。状态变了,Compose 负责重新求值 f。
这套约束有个名字叫状态提升 (state hoisting):一个 composable 自己不持有状态,而是接收"当前值"+"改值的回调"。这样它变成无状态、可预览、可测试的纯渲染器。
// 无状态:值进来(向下),事件出去(向上)。这就是 UDF 的最小单元 @Composable fun Counter( count: Int, // ↓ state 向下 onIncrement: () -> Unit // ↑ event 向上 ) { Button(onClick = onIncrement) { Text("点了 $count 次") } }
这才是底层的核心。Vue 用 Proxy 拦截、React 靠你手动 setState 然后 diff 整棵虚拟树。Compose 两者都不是——它做的是自动依赖追踪 (automatic dependency tracking):
State,Compose 偷偷记下:"这个重组作用域依赖这个状态对象"。State 被写,Compose 反查"谁读过我",只让那些作用域失效、重新执行。注意:订阅关系是在读取的瞬间建立的,不是声明的。你没读它,改它跟你无关;你读了它,它一变你就重跑。这种"读即订阅"靠的就是下一节的快照系统。
mutableStateOf 返回的不是一个普通容器。它是一个能感知"谁在什么快照里读过/写过我"的可观察状态对象。普通 var x = 0 改了不会触发任何重组——它没接进这套观察机制。这是大多数人用了很久也没拆开的一层。androidx.compose.runtime.snapshots.Snapshot 本质是一个多版本并发控制 (MVCC) 引擎,提供快照隔离 (Snapshot Isolation)。是的,就是你在 COMPSCI 751 学的那个。
mutableStateOf(0) 创建一个 StateObject,它内部挂着一条 StateRecord 链表。每次写不一定原地改,而是为"当前快照"维护一条带 snapshotId 的记录——这正是 MVCC 的"一个数据项多个版本,每版打时间戳"。
class SnapshotMutableStateImpl<T> : StateObject { // 一条按 snapshotId 串起来的版本链 var firstRecord: StateRecord // record { snapshotId, value } -> next -> ... } // 读: 沿链找出"对当前快照可见的最新版本"(id ≤ 我的快照 且未失效) // 写: 在当前可变快照里建/改属于本快照的版本,别的快照看不见
快照可以挂两个钩子:读观察者和写观察者。Compose 的运行时(Recomposer/Composer)在执行某个重组作用域时,装上读观察者——于是该作用域里每一次状态读取,都被登记进它的读集合 (read set)。这就是"读即订阅"的真身。
// Compose 大致这样把"读"绑到"作用域"上 Snapshot.observe( readObserver = { stateObj -> currentScope.recordRead(stateObj) }, writeObserver = { stateObj -> markModified(stateObj) } ) { scope.composeContent() // 期间所有 State 读取都被记账 }
UI 线程的常规写落在全局快照。后台线程可以 takeMutableSnapshot() 开一个隔离的可变快照,改完 apply()。apply() 做乐观冲突检测(像 OCC 的提交期校验):若两个并发快照改了同一对象就冲突,可合并的类型(如 SnapshotStateList)会尝试合并,否则失败重试。提交成功后,全局 apply 观察者被通知 → Compose 反查读集合 → 把命中的作用域标记失效 → 安排下一帧重组。
每帧前还会调一次 Snapshot.sendApplyNotifications(),把全局快照里攒着的写变更冲刷给观察者。所以"我改了 state,下一帧 UI 就变"——中间隔着这一道提交通知。
| 数据库概念 (COMPSCI 751) | Compose 快照系统里的对应物 |
|---|---|
| 多版本 MVCC | 每个 StateObject 的 StateRecord 版本链 |
| 时间戳 / 版本号 | 每条 record 上的 snapshotId(单调递增) |
| 快照隔离 (Snapshot Isolation) | 每个 Snapshot 看到一个一致的版本切面,不被未提交的写干扰 |
| 乐观并发 OCC + first-committer-wins | apply() 时做写写冲突检测,先提交者胜,冲突方重试/合并 |
| 读集合 / 验证 | 重组作用域记录的 read set,提交通知后用来反查失效目标 |
失效之后真正发生的事叫重组 (recomposition)——重新执行被标记失效的重组作用域。重点是粒度:不是整棵树重画,而是精确到读过该状态的最小可重启作用域。
Compose 不保留一棵 React 式虚拟 DOM。它把整个 composition 存进一个槽表 (slot table)——一个用间隙缓冲区 (gap buffer) 实现的扁平数组,存"组(group)"与"槽(slot)"。编译器在每个调用点插入稳定的 group key,做位置记忆化 (positional memoization):同一个调用点跨重组对应同一段槽,于是 remember 的值、上次的参数都能原地取回比较。重组时用 SlotReader 顺读、SlotWriter 改写,gap buffer 让"在当前位置插入/删除"接近 O(1)。
就算父作用域重组了,子 composable 也可能被跳过:如果它可跳过(skippable) 且这次所有入参跟上次 equals 相等,Compose 直接复用上次结果。"可跳过"取决于参数类型是否稳定:
@Immutable:构造后字段永不变(如全 val 的 data class、基本类型)。@Stable:可变,但变了一定通过 Compose 状态通知(如 State<T> 本身)。List<T>(接口,可能底层可变)、带 var 的类、来自其它模块未标注的类型。remember 传入的 lambda。你 2025-03 之后没正经写,这点值得在自己项目里确认一下编译器版本与配置。一帧分三相:Composition(决定显示什么)→ Layout(测量+摆放)→ Drawing(画)。状态读取登记在哪个阶段,失效就只回退到哪个阶段。把"频繁变的值"推迟到 Layout/Draw 的 lambda 里读,可以完全跳过重组,只重新布局或重绘——动画性能的关键招。
// 反例:在 Composition 阶段读 offset,每帧都触发重组 Box(Modifier.offset(x = animatedX.dp)) // 正例:把读取塞进 lambda,推迟到 Layout 阶段 -> 跳过重组,只重布局 Box(Modifier.offset { IntOffset(animatedX.roundToInt(), 0) })
把上面三节连起来亲手感受一遍。左边是状态对象(改它会"提交快照"),右边是四个模拟 composable,各自有不同的读集合。改某个状态,看谁被失效重组(蓝色脉冲)、谁被跳过(变灰)。注意 Footer 谁都不读——它永远不重组;Parity 在开启 derivedStateOf 后,只有奇偶真的翻转时才重组。
count++ 只点亮 Counter(和翻转时的 Parity),Header/Footer 纹丝不动——这正是读集合反查失效的效果,不是全树重绘。当一个值由别的状态算出来、而输入变得勤但结果变得少时用它。它内部记录自己的读集合,只有当"重算后的结果"与上次不等才通知下游——这就是上面 demo 里 Parity 的省力来源。
val listState = rememberLazyListState() // firstVisibleItemIndex 滚动时疯狂变,但我们只关心"是否 > 0" val showFab by remember { derivedStateOf { listState.firstVisibleItemIndex > 0 } } // 读 showFab 的作用域,只在 true/false 翻转时才重组
ViewModel 通常用 StateFlow 暴露状态(协程世界)。Compose 只认 State<T>,所以需要把 Flow 收集成 State。这一步就是把"协程的世界"接进"快照的世界"。
// ✅ 推荐:随生命周期收集,UI 不可见时自动停收,省电省算力 val uiState by viewModel.uiState.collectAsStateWithLifecycle() // collectAsState() 也行,但不感知生命周期,后台仍在收 // 反方向:把"读 State"变成一条冷 Flow val queryFlow = snapshotFlow { textState.value } .debounce(300) .collect { search(it) }
collectAsStateWithLifecycle() 来自 lifecycle-runtime-compose,从 ViewModel 取 StateFlow 时的默认选择。snapshotFlow {} 是反向桥:把快照里的状态读取变成 Flow,常用于把 UI 状态喂给协程逻辑(防抖搜索、埋点)。produceState {} 把一段挂起逻辑的结果"生产"成一个 State,适合一次性加载。现在把 02–06 串成你日常写的样子。ViewModel 持有唯一可信状态源(用 MutableStateFlow<UiState>),只读暴露,事件以方法进来。它还能跨配置变更存活——所以状态不会因为转屏丢失。
data class UiState(val count: Int = 0, val loading: Boolean = false) class CounterViewModel : ViewModel() { private val _uiState = MutableStateFlow(UiState()) val uiState: StateFlow<UiState> = _uiState.asStateFlow() // 只读暴露 fun increment() { // ↑ 事件进来 _uiState.update { it.copy(count = it.count + 1) } // 改唯一状态源 } }
@Composable fun CounterScreen(vm: CounterViewModel = viewModel()) { val state by vm.uiState.collectAsStateWithLifecycle() // 桥成 State,读它=订阅 Counter( count = state.count, // ↓ 状态向下 onIncrement = vm::increment // ↑ 事件向上 ) }
StateFlow 不依赖 Compose 运行时:更好测试、能在非 UI 层组合(combine/map/flatMapLatest)、和协程数据层天然衔接。代价是多一步 collectAsStateWithLifecycle 桥接。两种都常见,团队按约定选。有些事不是纯状态:发网络请求、弹一次性 Snackbar、导航跳转、注册/注销监听。它们要被关进Effect 里,受 composition 生命周期管理,否则会在每次重组时重复触发。
LaunchedEffect(key):进入 composition 时起一个协程,key 变了重启,离开时取消。拉数据、订阅。rememberCoroutineScope():拿一个随 composition 存活的 scope,在事件回调里启动协程(如点击后滚动列表)。DisposableEffect(key):需要清理的副作用(注册/反注册监听器、传感器)。SideEffect {}:每次成功重组后把 Compose 状态同步给非 Compose 对象。uiState 会在转屏重组时重放。常见做法:用 Channel/SharedFlow 发一次性事件,或把它建模成"待消费状态 + 消费后清空"。这是 UDF 里最容易踩的坑。把所有机器拼起来——用户点一下"+",到屏幕上数字跳动,中间精确发生了这些:
Button 的 onClick 触发 → 调到 vm::increment。UI 自己什么都没改。_uiState.update { copy(count+1) } 向 MutableStateFlow 发了一个新的不可变 UiState。collectAsStateWithLifecycle 收到新值,写入它内部的 MutableState → 触发快照写。sendApplyNotifications() 冲刷写变更,全局 apply 观察者被唤醒。state.count 的那块),标记 invalid。你想要底层,那就直接下到运行时。按这个顺序读/调,性价比最高:
androidx.compose.runtime.snapshots.Snapshot 与 SnapshotStateList——把版本链、apply、冲突合并看一遍,MVCC 就实锤了。Composer(怎么发 group key、用 slot table)与 Recomposer(怎么收 apply 通知、调度失效作用域)。reportsDestination 编译参数),它会列出每个函数是否 skippable/restartable、每个类是否 stable。这是排查"为什么它老重组"的终极依据。Modifier.offset {} lambda 里读,用 Inspector 看重组次数差异——亲手验证三阶段那一节。state.value = x 时,脑子里应该浮现的不是"赋值",而是:开了一次快照写、等待提交、触发 apply 通知、反查读集合、失效最小作用域、下一帧重组。看穿这条链,Compose 对你就没有魔法了。