Compose Snapshot 系统详解

205 阅读11分钟

Compose Snapshot 系统详解

目标:把「为什么读一下 state.value 就自动订阅了」这件事,从心智模型讲到源码,再讲到你日常遇到的每一个奇怪现象。


目录

  1. 先建立心智模型
  2. 三个必须分开看的问题
  3. 手写一个迷你 Snapshot 系统
  4. 订阅是怎么发生的:readObserver 全链路
  5. 通知是怎么发生的:apply 全链路
  6. 冲突检测与合并
  7. 现象案例集
  8. 调试技巧

一、先建立心智模型

别把它想成"观察者模式"

大部分人第一反应是:mutableStateOf 内部有个 listener 列表,setValue 的时候遍历通知。

不是的。 State 对象自己完全不知道谁在观察它。它只是一个"能存多个版本的格子"。

真正的模型是 数据库的 MVCC(多版本并发控制):

数据库概念Compose 对应
事务Snapshot
事务 IDSnapshot.id(单调递增)
行的多版本StateRecord 链表
快照读readable()
事务提交Snapshot.apply()
写写冲突SnapshotApplyResult.Failure

而 订阅,是数据库不做、Compose 额外加的一层:事务在执行期间,记录自己读过哪些行。这层就是 readObserver。

一句话总结全局

「在一个装了 readObserver 的 Snapshot 里读过某个 State」= 订阅。

订阅关系存在 Snapshot 的观察者 里,不存在 State 里。


二、三个必须分开看的问题

初学者容易把三件事糊成一团,导致怎么看都看不懂。请把它们彻底分开:

┌─────────────────────────────────────────────────────┐
│ 问题 A:隔离                                          │
│   不同线程/不同事务读到的值为什么互不干扰?             │
│   → 靠 StateRecord 多版本链表 + snapshotId 可见性规则   │
├─────────────────────────────────────────────────────┤
│ 问题 B:订阅                                          │
│   谁读了谁,是怎么记下来的?                            │
│   → 靠 Snapshot.readObserver 回调                     │
├─────────────────────────────────────────────────────┤
│ 问题 C:通知                                          │
│   改了值之后,怎么找到该重组的 scope?                  │
│   → 靠 modified 集合 + applyObserver + 反查依赖表       │
└─────────────────────────────────────────────────────┘

A 和 B 完全正交。你可以只实现 A(就是个 MVCC 存储),也可以只实现 B(就是个依赖收集器)。Compose 把两者叠在一起,才有了「事务内一致读 + 自动依赖收集」。


三、手写一个迷你 Snapshot 系统

这是理解全部原理最快的路。下面这份代码可以直接跑,只有约 100 行,覆盖了 A/B/C 三个问题的核心。

3.1 版本记录

/** 一个版本。真实源码里叫 StateRecord */
abstract class Record {
    var snapshotId: Int = 0     // 这个版本是哪个快照写的
    var next: Record? = null    // 单链表,头部最新
}

class IntRecord(var value: Int) : Record()

3.2 快照

关键点在 invalid 集合:它装的是"对我不可见的快照 id",也就是所有在我创建时还没提交的快照。

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 状态对象

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 跑一下

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 读的入口

// SnapshotState.kt
override var value: T
    get() = next.readable(this).value
// 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():

// 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:

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 编译器插桩、且可跳过的函数。这解释了一个高频困惑:

@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:

@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 写的入口

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 提交与全局通知

Snapshot.registerApplyObserver { changed: Set<Any>, snapshot: Snapshot -> ... }

Recomposer 在 runRecomposeAndApplyChanges() 里注册:

val unregisterApplyObserver = Snapshot.registerApplyObserver { changed, _ ->
    synchronized(stateLock) {
        if (_state.value >= State.Idle) {
            snapshotInvalidations.add(changed)
            deriveStateLocked()          // 唤醒 recompose 协程
        }
    }
}

然后:

changed: Set<Any>
   ↓ 反查 composition.observations
Set<RecomposeScope>
   ↓ scope.invalidate()
标记 SlotTable 中对应区间为待重组
   ↓ withFrameNanos 到来
只重组这些 scope

5.3 为什么点击回调里改 state 也能生效

点击回调运行时你在 GlobalSnapshot 里,没人会调 apply()。靠 GlobalSnapshotManager 兜底:

// 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 一次自救机会:

val merged = state.mergeRecords(previous, current, applied)

SnapshotMutableStateImpl 的实现:

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

@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 / 异常时回滚):

Snapshot.withMutableSnapshot {
    account.balance -= 100
    order.status = PAID
    // 中途抛异常 → 两个修改全部作废,UI 永远不会看到中间态
}

6.4 案例:子线程安全地读写

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()) 不刷新

var list by remember { mutableStateOf(mutableListOf<String>()) }
Button(onClick = { list.add("x") }) { ... }   // ❌ 不刷新

list.add() 根本没走 value 的 setter,modified 集合是空的,没有任何写入被记录。

解法(按推荐度):

// ✅ 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 到底省了什么

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:上次的计算结果

外层读它时:

  1. 用自己的 readObserver 收集依赖
  2. 校验所有 dependency 的当前值是否变化;没变则直接返回缓存
  3. 变了才重算;重算结果若与旧结果 ==,不通知外层

所以「上游高频变化、下游结果低频变化」正是它的适用场景。反之如果结果和上游同频变化,derivedStateOf 只是纯开销。

案例 3:graphicsLayer { } 为什么能跳过重组

// ❌ 每帧重组整个 composable
Box(Modifier.offset(x = animatedX.dp))

// ✅ 完全不重组,只重跑 layer 属性
Box(Modifier.graphicsLayer { translationX = animatedX })

graphicsLayer 的 lambda 不在组合阶段执行,而在 绘制阶段 由 SnapshotStateObserver 包裹执行:

observer.observeReads(scope, onValueChangedForScope) { block() }

读 animatedX 建立的依赖,指向的是 "重绘这个 layer" 这个 scope,而不是某个 RecomposeScope。值变了 → 只触发 invalidateDraw(),组合和布局阶段完全跳过。

同理还有:

  • Modifier.drawBehind { } → 只 invalidate draw
  • Modifier.layout { } 里读 state → 只 invalidate layout
  • Modifier.offset { IntOffset(...) }(lambda 版)→ 只 invalidate layout

通用原则:把 state 的读取推迟到尽可能晚的阶段。

案例 4:为什么 lambda 传参能减少重组

// ❌ 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 不订阅

val x = remember { someState.value }   // ⚠️ 只在第一次组合时读,之后永远是旧值

它确实订阅了(读发生在组合的快照里),所以 someState 变化时 会 触发重组。但 remember 的 calculation 不会重新执行,x 还是旧的。要么用 remember(someState.value),要么用 derivedStateOf。

案例 6:snapshotFlow 是什么

LaunchedEffect(Unit) {
    snapshotFlow { listState.firstVisibleItemIndex }
        .distinctUntilChanged()
        .collect { analytics.log(it) }
}

它就是把 SnapshotStateObserver 包成 Flow:在 Snapshot.takeSnapshot(readObserver) 里执行 block 收集依赖,注册 applyObserver,依赖变化就重新执行 block 并 emit。

只在依赖真的变化时才重跑,比在 composable 里写 LaunchedEffect(value) 更精确,且不占用组合阶段。

案例 7:为什么组合中读了又写会死循环

@Composable
fun Bad() {
    var n by remember { mutableStateOf(0) }
    Text("$n")
    n++          // ❌ 读了 n(订阅),又写了 n(触发失效)→ 无限重组
}

组合用的是 takeMutableSnapshot,写入被 writeObserverOf 记录进 modifiedValues,apply 后立刻反查到刚建立的依赖 → 立刻失效 → 再组合。这就是 Compose 反复强调"组合必须无副作用"的机制层原因。


八、调试技巧

查看某次变更影响了谁

Snapshot.registerApplyObserver { changed, _ ->
    changed.forEach { Log.d("Snapshot", "changed: $it") }
}

观察全局写

Snapshot.registerGlobalWriteObserver {
    Log.d("Snapshot", "global write: $it", Throwable())  // 带堆栈,定位是谁改的
}

编译器重组计数

Gradle 里开:

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 列,能直接看到哪个节点重组次数异常。


附:一页速查

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