Compose Snapshot 系统详解
目标:把「为什么读一下
state.value就自动订阅了」这件事,从心智模型讲到源码,再讲到你日常遇到的每一个奇怪现象。
目录
- 先建立心智模型
- 三个必须分开看的问题
- [手写一个迷你 Snapshot 系统](#手写一个迷你 Snapshot 系统 "#%E4%B8%89%E6%89%8B%E5%86%99%E4%B8%80%E4%B8%AA%E8%BF%B7%E4%BD%A0-snapshot-%E7%B3%BB%E7%BB%9F")
- [订阅是怎么发生的:readObserver 全链路](#订阅是怎么发生的:readObserver 全链路 "#%E5%9B%9B%E8%AE%A2%E9%98%85%E6%98%AF%E6%80%8E%E4%B9%88%E5%8F%91%E7%94%9F%E7%9A%84readobserver-%E5%85%A8%E9%93%BE%E8%B7%AF")
- [通知是怎么发生的:apply 全链路](#通知是怎么发生的:apply 全链路 "#%E4%BA%94%E9%80%9A%E7%9F%A5%E6%98%AF%E6%80%8E%E4%B9%88%E5%8F%91%E7%94%9F%E7%9A%84apply-%E5%85%A8%E9%93%BE%E8%B7%AF")
- 冲突检测与合并
- 现象案例集
- 调试技巧
一、先建立心智模型
别把它想成"观察者模式"
大部分人第一反应是:mutableStateOf 内部有个 listener 列表,setValue 的时候遍历通知。
不是的。 State 对象自己完全不知道谁在观察它。它只是一个"能存多个版本的格子"。
真正的模型是 数据库的 MVCC(多版本并发控制):
| 数据库概念 | Compose 对应 |
|---|---|
| 事务 | Snapshot |
| 事务 ID | Snapshot.id(单调递增) |
| 行的多版本 | StateRecord 链表 |
| 快照读 | readable() |
| 事务提交 | Snapshot.apply() |
| 写写冲突 | SnapshotApplyResult.Failure |
而 订阅 ,是数据库不做、Compose 额外加的一层:事务在执行期间,记录自己读过哪些行 。这层就是 readObserver。
一句话总结全局
「在一个装了 readObserver 的 Snapshot 里读过某个 State」= 订阅。
订阅关系存在 Snapshot 的观察者 里,不存在 State 里。
二、三个必须分开看的问题
初学者容易把三件事糊成一团,导致怎么看都看不懂。请把它们彻底分开:
css
┌─────────────────────────────────────────────────────┐
│ 问题 A:隔离 │
│ 不同线程/不同事务读到的值为什么互不干扰? │
│ → 靠 StateRecord 多版本链表 + snapshotId 可见性规则 │
├─────────────────────────────────────────────────────┤
│ 问题 B:订阅 │
│ 谁读了谁,是怎么记下来的? │
│ → 靠 Snapshot.readObserver 回调 │
├─────────────────────────────────────────────────────┤
│ 问题 C:通知 │
│ 改了值之后,怎么找到该重组的 scope? │
│ → 靠 modified 集合 + applyObserver + 反查依赖表 │
└─────────────────────────────────────────────────────┘
A 和 B 完全正交。你可以只实现 A(就是个 MVCC 存储),也可以只实现 B(就是个依赖收集器)。Compose 把两者叠在一起,才有了「事务内一致读 + 自动依赖收集」。
三、手写一个迷你 Snapshot 系统
这是理解全部原理最快的路。下面这份代码可以直接跑,只有约 100 行,覆盖了 A/B/C 三个问题的核心。
3.1 版本记录
kotlin
/** 一个版本。真实源码里叫 StateRecord */
abstract class Record {
var snapshotId: Int = 0 // 这个版本是哪个快照写的
var next: Record? = null // 单链表,头部最新
}
class IntRecord(var value: Int) : Record()
3.2 快照
关键点在 invalid 集合:它装的是"对我不可见的快照 id",也就是所有在我创建时还没提交的快照。
kotlin
open class MiniSnapshot(
val id: Int,
val invalid: Set<Int>, // 不可见的 id 集合
val readObserver: ((Any) -> Unit)? = null
) {
companion object {
private var nextId = 1
// 当前打开、尚未提交的快照 id
val open = mutableSetOf<Int>()
// 全局快照(默认没人开事务时用它)
var global = MiniSnapshot(0, emptySet())
private val threadLocal = ThreadLocal<MiniSnapshot?>()
val current: MiniSnapshot get() = threadLocal.get() ?: global
fun takeMutable(
readObserver: ((Any) -> Unit)? = null,
writeObserver: ((Any) -> Unit)? = null
): MutableMiniSnapshot {
val id = nextId++
// 我看不见:所有还开着的事务
val snap = MutableMiniSnapshot(id, open.toSet(), readObserver, writeObserver)
open += id
return snap
}
fun <T> enter(snapshot: MiniSnapshot, block: () -> T): T {
val prev = threadLocal.get()
threadLocal.set(snapshot)
try { return block() } finally { threadLocal.set(prev) }
}
// 提交后全局通知
val applyObservers = mutableListOf<(Set<Any>) -> Unit>()
}
}
class MutableMiniSnapshot(
id: Int,
invalid: Set<Int>,
readObserver: ((Any) -> Unit)? = null,
val writeObserver: ((Any) -> Unit)? = null
) : MiniSnapshot(id, invalid, readObserver) {
val modified = mutableSetOf<MiniState>()
fun apply() {
// ★ 提交的本质:把自己的 id 从"未提交"集合里摘掉
// 于是所有 snapshotId == 我的 id 的 record,对后来者立刻可见
open -= id
global = MiniSnapshot(id, open.toSet())
if (modified.isNotEmpty()) {
applyObservers.forEach { it(modified.toSet()) }
}
}
}
这里是全篇最重要的一句话: 提交(apply)不是"把数据写回去",数据早就写在链表里了。 提交是 把自己的 id 从 invalid 集合里移除,让已经写好的版本变得"可见"。 回滚(dispose 不 apply)也不需要撤销数据,因为那些 record 的 id 永远不会离开 invalid 集合,永远看不见。
3.3 状态对象
kotlin
class MiniState(initial: Int) {
private var firstRecord: Record = IntRecord(initial).apply { snapshotId = 0 }
var value: Int
get() {
val snapshot = MiniSnapshot.current
snapshot.readObserver?.invoke(this) // ★ 订阅点(问题 B)
return (readable(snapshot) as IntRecord).value
}
set(v) {
val snapshot = MiniSnapshot.current as? MutableMiniSnapshot
?: error("当前快照只读")
writable(snapshot).value = v // ★ 写新版本(问题 A)
snapshot.modified += this // ★ 记录脏数据(问题 C)
snapshot.writeObserver?.invoke(this)
}
/** 可见性规则:id <= 我的 id,且不在 invalid 里,取最大的那个 */
private fun readable(snapshot: MiniSnapshot): Record {
var candidate: Record? = null
var r: Record? = firstRecord
while (r != null) {
if (r.snapshotId <= snapshot.id && r.snapshotId !in snapshot.invalid) {
if (candidate == null || candidate.snapshotId < r.snapshotId) candidate = r
}
r = r.next
}
return candidate ?: error("没有可读版本")
}
private fun writable(snapshot: MutableMiniSnapshot): IntRecord {
val cur = readable(snapshot) as IntRecord
// 本快照已经写过一次 → 原地改,不再新建版本
if (cur.snapshotId == snapshot.id) return cur
// 否则新建一个版本挂到链表头
return IntRecord(cur.value).also {
it.snapshotId = snapshot.id
it.next = firstRecord
firstRecord = it
}
}
}
3.4 跑一下
kotlin
fun main() {
val count = MiniState(0)
// ---- 问题 A:隔离 ----
val snap = MiniSnapshot.takeMutable()
MiniSnapshot.enter(snap) { count.value = 100 }
println(count.value) // 0 ← 外面看不见未提交的修改
println(MiniSnapshot.enter(snap) { count.value }) // 100 ← 事务内看得见
snap.apply()
println(count.value) // 100 ← 提交后可见
// ---- 问题 B + C:订阅与通知 ----
val deps = mutableMapOf<MiniState, MutableList<String>>()
val scope = "MyComposable"
val obsSnap = MiniSnapshot.takeMutable(
readObserver = { state ->
deps.getOrPut(state as MiniState) { mutableListOf() } += scope
}
)
MiniSnapshot.enter(obsSnap) {
println("组合中读到 count = ${count.value}") // 这一读 → 建立订阅
}
obsSnap.apply()
MiniSnapshot.applyObservers += { changed ->
changed.forEach { s -> println("需要重组:${deps[s]}") }
}
val w = MiniSnapshot.takeMutable()
MiniSnapshot.enter(w) { count.value = 200 }
w.apply() // 输出:需要重组:[MyComposable]
}
跑通这段,你就把 Compose Snapshot 的骨架吃透了。剩下的都是工程化细节:性能优化、并发安全、冲突合并、嵌套快照。
四、订阅是怎么发生的:readObserver 全链路
4.1 读的入口
kotlin
// SnapshotState.kt
override var value: T
get() = next.readable(this).value
kotlin
// Snapshot.kt
fun <T : StateRecord> T.readable(state: StateObject): T {
val snapshot = Snapshot.current
snapshot.readObserver?.invoke(state) // ★★★ 唯一的订阅点
return readable(this, snapshot.snapshotId, snapshot.invalid) ?: readError()
}
注意传给 observer 的是 StateObject 本身 (也就是那个 MutableState 实例),不是它的值。所以依赖表的 key 是对象引用。
4.2 组合期间谁装了 observer
Recomposer / CompositionImpl.composeContent():
kotlin
// CompositionImpl
private fun <T> composing(block: () -> T): T {
val snapshot = Snapshot.takeMutableSnapshot(
readObserverOf(this), // ★ 装上
writeObserverOf(this, modifiedValues)
)
try {
return snapshot.enter(block)
} finally {
applyAndCheck(snapshot)
}
}
readObserverOf 最终走到 Composer.recordReadOf:
kotlin
internal fun recordReadOf(value: Any) {
if (!areChildrenComposing) {
composer.currentRecomposeScope?.let { scope ->
scope.used = true // 标记这个 scope 真的读了东西
observations.add(value, scope) // 建立 state → scope 映射
}
}
}
4.3 订阅粒度 = RecomposeScope
currentRecomposeScope 是 最近一层被 @Composable 编译器插桩、且可跳过的函数。这解释了一个高频困惑:
kotlin
@Composable
fun Screen() {
var n by remember { mutableStateOf(0) }
Column {
Text("固定标题") // ← 也会被"波及"?
Text("$n") // ← 读 n 的地方在这
Button(onClick = { n++ }) { Text("+") }
}
}
读发生在 Screen 这个 scope 里 (Column 的 content lambda 是 inline 的,不构成独立 scope),所以 n 变化时整个 Screen 重组,Text("固定标题") 也会被重新执行(只是它的参数没变,内部会跳过实际工作)。
想缩小粒度,把读推进一个非 inline 的 composable:
kotlin
@Composable
fun Counter(value: () -> Int) { // lambda 传递,读延后
Text("${value()}") // 读发生在 Counter 的 scope
}
这就是所谓 "defer reads"(延迟读取) 优化的原理。
4.4 存依赖的数据结构
observations: ScopeMap<Any, RecomposeScopeImpl>(旧版 IdentityScopeMap)。
它不是普通 HashMap,特点:
- 用 identityHashCode 而非
equals(避免 data class 的 equals 干扰) - 按 identity hash 排序存数组,二分查找,无节点分配
- 一对多,且支持 反向删除(scope 失效时清掉它的所有依赖项)
五、通知是怎么发生的:apply 全链路
5.1 写的入口
kotlin
override var value: T
set(value) {
val current = withCurrent { this }
if (!policy.equivalent(current.value, value)) { // ★ 相等性策略拦截
next.overwritable(this, current) { this.value = value }
}
}
overwritable → writableRecord → notifyWrite(snapshot, state),把 state 加进 snapshot.modified。
注意 policy.equivalent :默认是 structuralEqualityPolicy()(用 ==)。设同样的值不会触发任何通知。这也是为什么把可变对象(比如 ArrayList)塞进 mutableStateOf 然后原地 add 完全不会刷新------引用没变、equals 可能也没变,写路径压根没走。
三种策略:
| 策略 | 判等方式 | 场景 |
|---|---|---|
structuralEqualityPolicy() |
== |
默认 |
referentialEqualityPolicy() |
=== |
data class 频繁比较开销大时 |
neverEqualPolicy() |
永远不等 | 每次赋值都要触发,比如可变对象 |
5.2 提交与全局通知
kotlin
Snapshot.registerApplyObserver { changed: Set<Any>, snapshot: Snapshot -> ... }
Recomposer 在 runRecomposeAndApplyChanges() 里注册:
kotlin
val unregisterApplyObserver = Snapshot.registerApplyObserver { changed, _ ->
synchronized(stateLock) {
if (_state.value >= State.Idle) {
snapshotInvalidations.add(changed)
deriveStateLocked() // 唤醒 recompose 协程
}
}
}
然后:
sql
changed: Set<Any>
↓ 反查 composition.observations
Set<RecomposeScope>
↓ scope.invalidate()
标记 SlotTable 中对应区间为待重组
↓ withFrameNanos 到来
只重组这些 scope
5.3 为什么点击回调里改 state 也能生效
点击回调运行时你在 GlobalSnapshot 里,没人会调 apply()。靠 GlobalSnapshotManager 兜底:
kotlin
// GlobalSnapshotManager.kt (androidMain)
fun ensureStarted() {
if (started.compareAndSet(false, true)) {
Snapshot.registerGlobalWriteObserver {
if (!commitPending) {
commitPending = true
schedule { // AndroidUiDispatcher,主线程
commitPending = false
Snapshot.sendApplyNotifications()
}
}
}
}
}
sendApplyNotifications() → advanceGlobalSnapshot() → 推进全局快照 id + 触发 applyObservers。
推论 :一个事件回调里连改 10 个 state,只会合并成 一次 通知(commitPending 去重)。
六、冲突检测与合并
多线程/嵌套快照场景下,两个快照可能改了同一个 state。
6.1 检测
MutableSnapshot.apply() 中的 innerApplyLocked:
对 modified 里的每个 state,比较三个版本:
- current:全局最新可见版本
- previous:我 fork 时看到的版本
- applied:我写的版本
若 current !== previous,说明我 fork 之后别人改过 → 冲突。
6.2 合并
先给 state 一次自救机会:
kotlin
val merged = state.mergeRecords(previous, current, applied)
SnapshotMutableStateImpl 的实现:
kotlin
override fun mergeRecords(previous, current, applied): StateRecord? {
return if (policy.equivalent(current.value, applied.value)) current
else policy.merge(previous.value, current.value, applied.value)
?.let { /* 生成新 record */ }
}
默认 policy 的 merge 返回 null → 合并失败 → apply() 返回 SnapshotApplyResult.Failure。
SnapshotStateList / SnapshotStateMap 实现了 merge:它们记录了修改序号,能做类似三方合并的操作,所以并发追加往往能自动合并成功。
6.3 实用案例:事务性 UI
kotlin
@Composable
fun EditForm(user: UserState) {
var draft by remember { mutableStateOf<Snapshot?>(null) }
Button(onClick = {
// 开一个事务,用户编辑期间的修改对外不可见
draft = Snapshot.takeMutableSnapshot()
}) { Text("编辑") }
Button(onClick = {
draft?.apply() // 提交,一次性生效
draft = null
}) { Text("保存") }
Button(onClick = {
draft?.dispose() // 丢弃,自动回滚,无需任何撤销逻辑
draft = null
}) { Text("取消") }
}
或者更常用的语法糖(自动 apply / 异常时回滚):
kotlin
Snapshot.withMutableSnapshot {
account.balance -= 100
order.status = PAID
// 中途抛异常 → 两个修改全部作废,UI 永远不会看到中间态
}
6.4 案例:子线程安全地读写
kotlin
lifecycleScope.launch(Dispatchers.IO) {
val snapshot = Snapshot.takeMutableSnapshot()
try {
snapshot.enter {
// 这里读到的所有 state 都是一致的时间点视图
// 即使主线程同时在改,也不会读到"半个更新"
repository.save(uiState.name, uiState.email)
uiState.syncing = false
}
val result = snapshot.apply()
if (result is SnapshotApplyResult.Failure) {
// 冲突了,重试或提示
}
} finally {
snapshot.dispose() // ★ 必须 dispose,否则 id 永远留在 open 集合,
// 导致后续快照的 invalid 集合无限膨胀(内存泄漏)
}
}
注意 :直接在子线程 state.value = x(不开快照)是可以的------写进全局快照,registerGlobalWriteObserver 会调度到主线程发通知。但你就失去了一致性和原子性。
七、现象案例集
案例 1:为什么 mutableStateOf(mutableListOf()) 不刷新
kotlin
var list by remember { mutableStateOf(mutableListOf<String>()) }
Button(onClick = { list.add("x") }) { ... } // ❌ 不刷新
list.add() 根本没走 value 的 setter,modified 集合是空的,没有任何写入被记录。
解法(按推荐度):
kotlin
// ✅ 1. 用 SnapshotStateList,它自己就是 StateObject
val list = remember { mutableStateListOf<String>() }
list.add("x")
// ✅ 2. 用不可变集合 + 整体替换
var list by remember { mutableStateOf(listOf<String>()) }
list = list + "x"
// ⚠️ 3. neverEqualPolicy + 重新赋值(可行但易错)
var list by remember { mutableStateOf(mutableListOf<String>(), neverEqualPolicy()) }
list.add("x"); list = list
案例 2:derivedStateOf 到底省了什么
kotlin
val listState = rememberLazyListState()
// ❌ 每滚动 1px 都重组
val showButton = listState.firstVisibleItemIndex > 0
// ✅ 只在 true/false 翻转时重组
val showButton by remember {
derivedStateOf { listState.firstVisibleItemIndex > 0 }
}
原理:DerivedSnapshotState 是一个"带缓存的 StateObject"。它的 ResultRecord 存了:
dependencies:计算时读过哪些 state 及它们当时的currentValue快照result:上次的计算结果
外层读它时:
- 用自己的 readObserver 收集依赖
- 校验所有 dependency 的当前值是否变化;没变则直接返回缓存
- 变了才重算;重算结果若与旧结果
==,不通知外层
所以「上游高频变化、下游结果低频变化」正是它的适用场景。反之如果结果和上游同频变化,derivedStateOf 只是纯开销。
案例 3:graphicsLayer { } 为什么能跳过重组
kotlin
// ❌ 每帧重组整个 composable
Box(Modifier.offset(x = animatedX.dp))
// ✅ 完全不重组,只重跑 layer 属性
Box(Modifier.graphicsLayer { translationX = animatedX })
graphicsLayer 的 lambda 不在组合阶段执行,而在 绘制阶段 由 SnapshotStateObserver 包裹执行:
kotlin
observer.observeReads(scope, onValueChangedForScope) { block() }
读 animatedX 建立的依赖,指向的是 "重绘这个 layer" 这个 scope,而不是某个 RecomposeScope。值变了 → 只触发 invalidateDraw(),组合和布局阶段完全跳过。
同理还有:
Modifier.drawBehind { }→ 只 invalidate drawModifier.layout { }里读 state → 只 invalidate layoutModifier.offset { IntOffset(...) }(lambda 版)→ 只 invalidate layout
通用原则:把 state 的读取推迟到尽可能晚的阶段。
案例 4:为什么 lambda 传参能减少重组
kotlin
// ❌ Parent 重组 → Child 参数变 → Child 重组
@Composable fun Child(count: Int) { Text("$count") }
// ✅ Parent 重组,Child 的参数(lambda 引用)不变 → Child 跳过
// 读发生在 Child 内部,只有 Child 重组
@Composable fun Child(count: () -> Int) { Text("${count()}") }
前者读发生在 Parent 的 scope,后者读发生在 Child 的 scope。订阅位置变了,失效范围就变了。
案例 5:为什么 remember 里读 state 不订阅
kotlin
val x = remember { someState.value } // ⚠️ 只在第一次组合时读,之后永远是旧值
它确实订阅了(读发生在组合的快照里),所以 someState 变化时 会 触发重组。但 remember 的 calculation 不会重新执行,x 还是旧的。要么用 remember(someState.value),要么用 derivedStateOf。
案例 6:snapshotFlow 是什么
kotlin
LaunchedEffect(Unit) {
snapshotFlow { listState.firstVisibleItemIndex }
.distinctUntilChanged()
.collect { analytics.log(it) }
}
它就是把 SnapshotStateObserver 包成 Flow:在 Snapshot.takeSnapshot(readObserver) 里执行 block 收集依赖,注册 applyObserver,依赖变化就重新执行 block 并 emit。
只在依赖真的变化时才重跑 ,比在 composable 里写 LaunchedEffect(value) 更精确,且不占用组合阶段。
案例 7:为什么组合中读了又写会死循环
kotlin
@Composable
fun Bad() {
var n by remember { mutableStateOf(0) }
Text("$n")
n++ // ❌ 读了 n(订阅),又写了 n(触发失效)→ 无限重组
}
组合用的是 takeMutableSnapshot,写入被 writeObserverOf 记录进 modifiedValues,apply 后立刻反查到刚建立的依赖 → 立刻失效 → 再组合。这就是 Compose 反复强调"组合必须无副作用"的机制层原因。
八、调试技巧
查看某次变更影响了谁
kotlin
Snapshot.registerApplyObserver { changed, _ ->
changed.forEach { Log.d("Snapshot", "changed: $it") }
}
观察全局写
kotlin
Snapshot.registerGlobalWriteObserver {
Log.d("Snapshot", "global write: $it", Throwable()) // 带堆栈,定位是谁改的
}
编译器重组计数
Gradle 里开:
kotlin
composeCompiler {
reportsDestination = layout.buildDirectory.dir("compose_reports")
metricsDestination = layout.buildDirectory.dir("compose_metrics")
}
产出的 *-classes.txt 会标注每个 composable 是 restartable / skippable,参数是 stable / unstable。不 skippable 的 composable,再精细的订阅粒度也白搭。
Layout Inspector
Android Studio 的 Layout Inspector 有 Recomposition Counts 列,能直接看到哪个节点重组次数异常。
附:一页速查
python
mutableStateOf(x)
└─ SnapshotMutableStateImpl : StateObject
└─ firstStateRecord ──► Record(id=5, v=3) ──► Record(id=2, v=1) ──► ...
最新 较旧
读 value:
Snapshot.current.readObserver?.invoke(stateObject) ← 订阅
找 id <= 当前id 且 不在 invalid 中 的最大 id 的 record
写 value:
policy.equivalent(旧, 新) ? 直接返回 : {
本快照已有 record ? 原地改 : 新建 record 挂链表头
snapshot.modified += stateObject
}
apply():
冲突检测(current !== previous ?)
→ mergeRecords 尝试合并
→ 成功: id 移出 open 集合,推进全局快照,触发 applyObservers
→ 失败: SnapshotApplyResult.Failure
applyObserver 收到 changed: Set<Any>
→ 反查 observations: ScopeMap<Any, RecomposeScope>
→ scope.invalidate()
→ 下一帧 withFrameNanos 只重组这些 scope