STATE ⇄ UI
从用户输入 · 到数据改变 · 到 UI 自动更新

把 Compose 的
响应式这台机器
拆开给你看

你已经会用了,缺的是底层为什么自己就跑起来了。这份教程绕过 API 罗列,直接讲清楚那条隐形的闭环:事件往上走状态往下流,而 UI 的"自动更新"其实是一套 MVCC 快照系统——和你 751 学的 Snapshot Isolation 是同一套思想。

UI / Composable 读状态 → 显示 Event 事件 onClick / onValueChange ViewModel 处理 → 改 state State 状态 StateFlow / MutableState
━━ 事件向上 (event up)  ·  ━━ 状态向下 (state down)

这就是单向数据流 (UDF)。整份教程就是在解释这个环里每一段是怎么自动接上的——尤其是从 State 改变UI 重新画 之间那段"魔法"。

01

心智模型:状态向下,事件向上

先把命令式的旧习惯戒掉。在 View 时代你是主动推:拿到 TextView 引用,textView.text = "..."。在 Compose 里你不碰 UI——你只改一个状态,UI 是状态的一个纯函数UI = f(state)。状态变了,Compose 负责重新求值 f

这套约束有个名字叫状态提升 (state hoisting):一个 composable 自己不持有状态,而是接收"当前值"+"改值的回调"。这样它变成无状态、可预览、可测试的纯渲染器。

StatelessCounter.kt
// 无状态:值进来(向下),事件出去(向上)。这就是 UDF 的最小单元
@Composable
fun Counter(
    count: Int,                 // ↓ state 向下
    onIncrement: () -> Unit      // ↑ event 向上
) {
    Button(onClick = onIncrement) {
        Text("点了 $count 次")
    }
}
关键转变你不再"更新 UI"。你只修改状态,并相信框架会把变化传导到屏幕。下面整份教程都在拆解"相信"这两个字背后的机器。
02

真正的问题:Compose 凭什么知道要重组?

这才是底层的核心。Vue 用 Proxy 拦截、React 靠你手动 setState 然后 diff 整棵虚拟树。Compose 两者都不是——它做的是自动依赖追踪 (automatic dependency tracking):

注意:订阅关系是在读取的瞬间建立的,不是声明的。你没读它,改它跟你无关;你读了它,它一变你就重跑。这种"读即订阅"靠的就是下一节的快照系统

先建立直觉mutableStateOf 返回的不是一个普通容器。它是一个能感知"谁在什么快照里读过/写过我"的可观察状态对象。普通 var x = 0 改了不会触发任何重组——它没接进这套观察机制。
03

快照系统:Compose 内置的 MVCC 数据库

这是大多数人用了很久也没拆开的一层。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)。这就是"读即订阅"的真身。

snapshot-observers.kt
// 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 就变"——中间隔着这一道提交通知。

和你 751 学的对应关系

数据库概念 (COMPSCI 751)Compose 快照系统里的对应物
多版本 MVCC每个 StateObjectStateRecord 版本链
时间戳 / 版本号每条 record 上的 snapshotId(单调递增)
快照隔离 (Snapshot Isolation)每个 Snapshot 看到一个一致的版本切面,不被未提交的写干扰
乐观并发 OCC + first-committer-winsapply() 时做写写冲突检测,先提交者胜,冲突方重试/合并
读集合 / 验证重组作用域记录的 read set,提交通知后用来反查失效目标
一句话锁死"UI 自动更新"= 写状态 → 提交快照 → apply 观察者反查读集合 → 命中的作用域失效 → 下一帧重组。它根本不是事件回调,而是一个 MVCC 提交触发的失效传播。
04

重组:只重跑读过那个状态的那一小块

失效之后真正发生的事叫重组 (recomposition)——重新执行被标记失效的重组作用域。重点是粒度:不是整棵树重画,而是精确到读过该状态的最小可重启作用域。

Slot Table:重组的底层数据结构

Compose 不保留一棵 React 式虚拟 DOM。它把整个 composition 存进一个槽表 (slot table)——一个用间隙缓冲区 (gap buffer) 实现的扁平数组,存"组(group)"与"槽(slot)"。编译器在每个调用点插入稳定的 group key,做位置记忆化 (positional memoization):同一个调用点跨重组对应同一段槽,于是 remember 的值、上次的参数都能原地取回比较。重组时用 SlotReader 顺读、SlotWriter 改写,gap buffer 让"在当前位置插入/删除"接近 O(1)。

跳过与稳定性 (skipping & stability)

就算父作用域重组了,子 composable 也可能被跳过:如果它可跳过(skippable) 且这次所有入参跟上次 equals 相等,Compose 直接复用上次结果。"可跳过"取决于参数类型是否稳定:

你离开期间的变化 · 建议核对版本Kotlin 2.0 / Compose 2.x 编译器起默认开启了强跳过模式 (strong skipping):即使参数不稳定也能跳过(对不稳定参数改用实例相等比较),并且会自动 remember 传入的 lambda。你 2025-03 之后没正经写,这点值得在自己项目里确认一下编译器版本与配置。

三个阶段:在对的阶段读状态能省掉重组

一帧分三相:Composition(决定显示什么)→ Layout(测量+摆放)→ Drawing(画)。状态读取登记在哪个阶段,失效就只回退到哪个阶段。把"频繁变的值"推迟到 Layout/Draw 的 lambda 里读,可以完全跳过重组,只重新布局或重绘——动画性能的关键招。

defer-state-read.kt
// 反例:在 Composition 阶段读 offset,每帧都触发重组
Box(Modifier.offset(x = animatedX.dp))

// 正例:把读取塞进 lambda,推迟到 Layout 阶段 -> 跳过重组,只重布局
Box(Modifier.offset { IntOffset(animatedX.roundToInt(), 0) })
05

动手:重组追踪器

把上面三节连起来亲手感受一遍。左边是状态对象(改它会"提交快照"),右边是四个模拟 composable,各自有不同的读集合。改某个状态,看谁被失效重组(蓝色脉冲)、谁被跳过(变灰)。注意 Footer 谁都不读——它永远不重组;Parity 在开启 derivedStateOf 后,只有奇偶真的翻转时才重组。

Recomposition Tracer// 模拟 Compose 运行时的失效传播
// 状态对象 (StateObject)
count
0
record @snapshot #0
user
"Harry"
record @snapshot #0
// Composable 树
×0
Header()
reads: [user]
×0
Counter()
reads: [count]
×0
Parity()
reads: [count] (derived)
// 改一个状态,看失效传到哪里 …
观察点初次"组合"时四个都跑一次(×1)。之后 count++ 只点亮 Counter(和翻转时的 Parity),Header/Footer 纹丝不动——这正是读集合反查失效的效果,不是全树重绘。
06

派生状态与桥接:让数据流接上其它世界

derivedStateOf:从状态算出的状态,只在结果变了才传播

当一个值由别的状态算出来、而输入变得勤但结果变得少时用它。它内部记录自己的读集合,只有当"重算后的结果"与上次不等才通知下游——这就是上面 demo 里 Parity 的省力来源。

derived.kt
val listState = rememberLazyListState()
// firstVisibleItemIndex 滚动时疯狂变,但我们只关心"是否 > 0"
val showFab by remember {
    derivedStateOf { listState.firstVisibleItemIndex > 0 }
}
// 读 showFab 的作用域,只在 true/false 翻转时才重组

桥接外部世界:Flow / StateFlow → Compose State

ViewModel 通常用 StateFlow 暴露状态(协程世界)。Compose 只认 State<T>,所以需要把 Flow 收集成 State。这一步就是把"协程的世界"接进"快照的世界"。

bridge.kt
// ✅ 推荐:随生命周期收集,UI 不可见时自动停收,省电省算力
val uiState by viewModel.uiState.collectAsStateWithLifecycle()

// collectAsState() 也行,但不感知生命周期,后台仍在收

// 反方向:把"读 State"变成一条冷 Flow
val queryFlow = snapshotFlow { textState.value }
    .debounce(300)
    .collect { search(it) }
07

ViewModel:把整条闭环焊死

现在把 02–06 串成你日常写的样子。ViewModel 持有唯一可信状态源(用 MutableStateFlow<UiState>),只读暴露,事件以方法进来。它还能跨配置变更存活——所以状态不会因为转屏丢失。

CounterViewModel.kt
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) }  // 改唯一状态源
    }
}
CounterScreen.kt
@Composable
fun CounterScreen(vm: CounterViewModel = viewModel()) {
    val state by vm.uiState.collectAsStateWithLifecycle()  // 桥成 State,读它=订阅
    Counter(
        count = state.count,            // ↓ 状态向下
        onIncrement = vm::increment     // ↑ 事件向上
    )
}
为什么不直接在 ViewModel 用 mutableStateOf也能跑,但 StateFlow 不依赖 Compose 运行时:更好测试、能在非 UI 层组合(combine/map/flatMapLatest)、和协程数据层天然衔接。代价是多一步 collectAsStateWithLifecycle 桥接。两种都常见,团队按约定选。
08

副作用:不是"状态"的那部分怎么走

有些事不是纯状态:发网络请求、弹一次性 Snackbar、导航跳转、注册/注销监听。它们要被关进Effect 里,受 composition 生命周期管理,否则会在每次重组时重复触发。

一次性事件别塞进 state导航、Toast 这类"发生一次就完了"的事件,若放进 uiState 会在转屏重组时重放。常见做法:用 Channel/SharedFlow 发一次性事件,或把它建模成"待消费状态 + 消费后清空"。这是 UDF 里最容易踩的坑。
09

一次点击的完整旅程

把所有机器拼起来——用户点一下"+",到屏幕上数字跳动,中间精确发生了这些:

用户点击,事件向上
ButtononClick 触发 → 调到 vm::increment。UI 自己什么都没改。
ViewModel 改唯一状态源
_uiState.update { copy(count+1) }MutableStateFlow 发了一个新的不可变 UiState
桥接层把新值写进快照状态
collectAsStateWithLifecycle 收到新值,写入它内部的 MutableState → 触发快照
快照提交 + 通知
下一帧前 sendApplyNotifications() 冲刷写变更,全局 apply 观察者被唤醒。
反查读集合,定位失效作用域
Compose 找出"读过这个 state 的重组作用域"(读 state.count 的那块),标记 invalid。
下一帧重组,粒度最小
只重跑失效作用域;参数没变的子 composable 被跳过。Slot table 原地更新对应的槽。
Layout → Draw,屏幕更新
若只有文字变,可能跳过 Layout 直接重绘对应节点。你看到数字跳了一下。
闭环合拢你写的代码只出现在第 1、2 步(发事件、改状态)。第 3–7 步全是框架的 MVCC + 失效传播自动完成。这就是"自动更新"四个字的全部含义。
10

给老手的进阶路线:去读源码 & 抓现行

你想要底层,那就直接下到运行时。按这个顺序读/调,性价比最高:

一句收尾当你下次写 state.value = x 时,脑子里应该浮现的不是"赋值",而是:开了一次快照写、等待提交、触发 apply 通知、反查读集合、失效最小作用域、下一帧重组。看穿这条链,Compose 对你就没有魔法了。