Jetpack Compose重组原理(一):快照系统如何精准追踪状态变化

前言

在上一篇文章《Jetpack Compose重组优化:机制剖析与性能提升策略》中,介绍了 Compose 重组的基本机制以及常见的优化方式。

不过,光知道怎么优化可能还不够。要搞清楚 为什么 Compose 能做到按需重组 ,就得了解它的内部原理,而 快照系统(Snapshot) 可以说是最关键的一部分。

本文是《Jetpack Compose 重组原理》系列的第一篇,将介绍 Snapshot 的运行机制。后续还将介绍 SlotTableRecomposer,最终把整个重组过程完整串起来。

注意:本文出现的源码基于版本 1.9.0。

一、快照是什么

我们可以将所有快照状态(Snapshot State)对象(如通过 mutableStateOf 创建的状态)想象成一份共享的文档。快照(Snapshot) 则相当于在某个时间点拍摄的这份文档的 副本 。在自己的 快照副本 中进行的修改不会立即影响全局状态,只有在明确调用 提交(apply) 后,修改才会生效。

快照并非 Compose 独有,在维基百科中是这样描述的:

在电脑系统中,快照 (英语:snapshot)是整个系统在某个时间点上的状态。这个名词是由摄影中借用而来。它储存了系统映象(System image),让电脑系统在出现问题时,可以快速恢复到未出问题前的状况。

二、快照的基本操作

2.1 只读快照: Snapshot.takeSnapshot

kotlin 复制代码
val state = mutableStateOf(1)
val snapshot = Snapshot.takeSnapshot()
state.value = 2
snapshot.enter {
    println("child: state.value: ${state.value}") // 打印 1
}
println("Global: state.value: ${state.value}") // 打印 2
snapshot.dispose()

Snapshot.takeSnapshot()会创建一个只读快照,无法在此快照中进行写操作。

2.2 读写快照: Snapshot.takeMutableSnapshot

kotlin 复制代码
val state = mutableStateOf(1)
val snapshot = Snapshot.takeMutableSnapshot()
snapshot.enter {
    println("child: state.value: ${state.value}") // 打印 1
    state.value = 3
}
println("Global apply before: ${state.value}") // 打印 1
snapshot.apply()
println("Global apply after:${state.value}") // 打印 3
snapshot.dispose()

Snapshot.takeMutableSnapshot() 会创建一个可读写快照,但修改不会立即影响全局,只有调用 apply() 后,才会合并到全局。

2.3 读写感知:readObserver/writeObserver

kotlin 复制代码
val state = mutableStateOf(1)

val readObserver: (Any) -> Unit = { readState ->
    if (readState == state) {
        println("readObserver: $readState") // 打印 1。触发了对状态对象 state 的读取操作,其值为快照创建时的值
    }
}
// 监听状态写操作
val writeObserver: (Any) -> Unit = { writtenState ->
    if (writtenState == state) {
        println("writeObserver: $writtenState") // 打印 2 。触发了对状态对象 state 的写入操作,其新值在当前快照隔离环境内已变为 2
    }
}

val snapshot = Snapshot.takeMutableSnapshot(
    readObserver = readObserver,
    writeObserver = writeObserver
)

snapshot.enter {
    // 写操作,触发 writeObserver 回调
    state.value = 2

    // 读操作,触发 readObserver 回调
    val value = state.value

    println(value) // 打印 2
}
snapshot.apply()

snapshot.dispose()

通过 takeMutableSnapshot(readObserver, writeObserver) 创建的子快照,可以在执行块中实时捕捉到读写事件。观察者作用于当前创建的(子)快照的执行环境,这些观察最终会作用于 GlobalSnapshot

快照系统的核心可归纳为三点:

  1. 读写隔离 :提供读写隔离 环境,子快照的修改需显式 apply() 才能生效。
  2. 读写感知 :通过读写观察者(Observer) 在组合期间自动追踪状态访问,从而精准建立依赖关系与触发失效标记。
  3. 底层实现 :其底层由 StateRecord 链表 实现,采用多版本并发控制(MVCC,Multi-Version Concurrency Control) 策略,每个快照通过版本ID访问对其可见的最新状态记录,这是所有功能得以实现的根本。

理解了这些核心概念,我们就可以看看Compose内部是如何通过具体的代码架构来实现的。

三、Compose中的快照

3.1 状态对象的底层机制:StateRecord 链表

在理解 Compose 快照之前,我们首先要了解 Compose 中状态对象的本质及其底层存储机制。整个快照系统建立在两个核心抽象之上:StateObjectStateRecord ,其关系如下图所示:

图示:StateObjectStateRecord 的类图结构。请注意,value: T 字段通常在 StateRecord 的具体子类(如 StateStateRecord)中。

3.1.1 StateObject 接口:状态记录链表的管理者

java 复制代码
public interface StateObject {
    // 状态记录链表的头节点
    public val firstStateRecord: StateRecord
    
    // 在链表头部添加新的状态记录
    public fun prependStateRecord(value: StateRecord)

    // 解决状态冲突的合并方法
    public fun mergeRecords(
        previous: StateRecord,
        current: StateRecord,
        applied: StateRecord,
    ): StateRecord? = null
}

StateObject 是任何可被快照系统管理的状态的标识接口。其核心在于持有一个 StateRecord 构成的单向链表,并定义了如何操作这个链表

  • val firstStateRecord: StateRecord:当前状态记录的链表头。
  • fun prependStateRecord(value: StateRecord) :将一个新的 StateRecord 原子性 地添加到链表头部,使其成为新的头节点。
  • fun mergeRecords(...): StateRecord? :一个用于解决快照合并冲突的策略方法。(对于大多数基本类型,此方法通常直接选择 current 记录)。

可以把 StateRecord 理解为一个状态值的版本快照。每一次在某个快照环境中的写入,都会生成一个新的 StateRecord 并添加到链表的头部。

这种 多版本并发控制(MVCC) 机制是快照系统实现隔离的基础:

  • 读操作 :根据当前快照的ID,遍历状态记录链表,在所有对此快照可见的 (即 snapshotId ≤ 当前快照ID并且未被标记为无效)记录中,选择 snapshotId 最大的那个记录(即最新的有效版本)。
  • 写操作 :所有快照在创建时都会被赋予一个全局递增的 id,在当前快照中创建一个新的 StateRecord (其快照ID为当前快照的ID),并将其插入链表头部。这个新记录在提交(apply)前,只对当前快照及其子快照可见。

3.1.2 StateRecord 抽象类:状态记录链表的节点

java 复制代码
public abstract class StateRecord(
    internal var snapshotId: SnapshotId
) {
    // 指向链表中下一个 StateRecord 的指针
    internal var next: StateRecord? = null
    ...
}

StateRecord 是状态值版本的载体,它是一个抽象类,构成了单向链表节点。其核心属性包括:

  • val snapshotId: Int :该记录所属快照的唯一ID。这是判断记录对某个快照是否可见的唯一依据。
  • var next: StateRecord? :指向链表中下一个 StateRecord 的指针。
    它通常还包含 assign() 和 create() 等方法,用于记录复制和创建。

3.1.3 子类实现:具体值的存储(如StateStateRecord)

StateRecord 的具体子类负责存储真实的值。例如,mutableStateOf() 返回的 SnapshotMutableStateImpl 内部使用的就是 StateStateRecord 类,它简单地包装了一个 value: T

java 复制代码
private class StateStateRecord<T>(snapshotId: SnapshotId, myValue: T) :
    StateRecord(snapshotId) {

    var value: T = myValue // 真正的值在这里存储
    ...
}

3.2 SnapshotMutableStateImpl 的读写逻辑

如上图所示,当我们使用 mutableStateOf() 创建一个状态时,它返回的实际上是一个 SnapshotMutableStateImpl 对象。这个类是连接状态值与快照系统的桥梁,它实现了 StateObject 接口 ,这意味着它内部持有一个由 StateStateRecord 构成的单向链表,并负责管理它(实现了 firstStateRecordprependStateRecord 方法)。

3.2.1 get() 方法:遍历链表找可见版本,触发读观察者

kotlin 复制代码
override var value: T
    get() {
        // 1. 触发读观察者回调
        Snapshot.current.readable(this)
        
        // 2. 遍历状态记录链表:
        //    从链表头(next)开始,查找对当前快照可见的最新记录
        //    可见条件:记录快照ID ≤ 当前快照ID 并且未被标记为无效
        return next.readable(this).value
    }

get() 中,它并不直接遍历链表 ,而是将当前状态对象(this)和查找请求委托给当前快照的 readable() 方法去处理,由此触发了读观察者。

3.2.2 set() 方法:创建新记录加头部,触发写观察者

kotlin 复制代码
    set(newValue) {
        // 通知快照系统:当前线程准备写入了,这会进行快照隔离判断,并触发 writeObserver 回调。
        val snapshot = Snapshot.current
        snapshot.writable(this, ...) { state ->
            // 此处为逻辑示意。实际过程是:
            // 1. 创建一个新的 StateStateRecord 实例,其 value 为 newValue
            // 2. 调用 state.prependStateRecord(newRecord) 将其原子性地添加到链表头部
            // 以下两行示意代码模拟了这个过程:
            val newRecord = StateStateRecord(snapshot.id, newValue)
            state.prependStateRecord(newRecord)
        }
        // 写入完成后,快照系统会自动处理后续的"脏标记"和调度流程
    }

set() 中,它同样将状态对象和修改请求委托给当前快照的 writable() 方法 。该方法会执行快照隔离检查,并回调一个 block ,在这个 block 中,会调用 prependStateRecord 来创建一个包含新值的新记录,并将其添加到链表头部,由此触发了写观察者。

简单来说:

  • get() -> 调用 readable() -> 触发 readObserverOf -> 记录 状态-作用域 的依赖关系。
  • set() -> 调用 writable() -> 触发 writeObserverOf -> 根据依赖关系查找并 标记 所有需要重组的作用域为

因此,SnapshotMutableStateImpl 本身并不包含复杂的链表查找和版本控制逻辑,它更像是一个"代理" ,将所有的读写操作都 转发给了快照系统 去处理。

3.3 Recomposer 的观察者处理与脏标记机制

本节将分析 Recomposer 如何通过 composing 方法创建一个 带观察者的快照环境 ,并解读 readObserverOfwriteObserverOf 这两个回调如何实现 依赖追踪失效标记 的核心逻辑。

3.3.1 Recomposer#composing 逻辑分析

现在我们知道了状态对象如何发出读写信号,Compose运行时则会通过 Recomposer.composing 方法接收并处理这些信号。代码如下:

kotlin 复制代码
private inline fun <T> composing(
        composition: ControlledComposition,
        modifiedValues: MutableScatterSet<Any>?,
        block: () -> T,
    ): T {
        val snapshot =
            Snapshot.takeMutableSnapshot(
                readObserverOf(composition),
                writeObserverOf(composition, modifiedValues), // 1
            )
        try {
            return snapshot.enter(block) // 2
        } finally {
            applyAndCheck(snapshot) // 3
        }
    }  

流程解读:

  1. 创建一个带有 readObserverwriteObserver 的可变快照。
  2. enter {} 内执行组合代码,期间的所有读写都会被捕获。
  3. finally 中调用 applyAndCheck(snapshot),这个方法是对 snapshot.apply() 的封装,会将修改提交到全局。

3.3.2 readObserverOf

readObserverOf执行的流程如下: 为了关注主要的逻辑,此处简化readObserverOf代码:

kotlin 复制代码
override fun recordReadOf(value: Any) {
    composer.currentRecomposeScope?.let { scope ->
        ...
        observations.add(value, scope)
        ...
    }
}

作用:

  • 将"某个状态 → 当前重组作用域"的关系,添加到 依赖关系映射表(observations) 中。
  • 这一步就是建立 依赖关系

3.3.3 writeObserverOf

writeObserverOf执行的流程如下:

kotlin 复制代码
override fun recordWriteOf(value: Any) =
	synchronized(lock) {
		invalidateScopeOfLocked(value) 
		derivedStates.forEachScopeOf(value) { invalidateScopeOfLocked(it) }
}

// 注意:派生状态 (derivedStateOf) 的特殊处理**
// 上面代码中 derivedStates.forEachScopeOf... 这一行是针对 derivedStateOf 的特殊逻辑。
// derivedStateOf` 会创建一个"派生状态",它本身也会追踪其依赖的作用域。
// 因此,当一个基础状态变化时,系统不仅需要失效直接依赖它的作用域,还需递归地找到所有依赖了派生状态的作用域并一并失效。
// 关键点:被标记为"脏"的是最终依赖了派生状态的 Composable 作用域,而非派生状态对象本身。这保证了整个依赖链都能正确更新。
kotlin 复制代码
private fun invalidateScopeOfLocked(value: Any) {
    // 从observations中拿到之前读操作存进去的重组作用域
    observations.forEachScopeOf(value) { scope ->
        // 标记修改了的重组作用域
        if (scope.invalidateForResult(value) == InvalidationResult.IMMINENT) {
            // If we process this during recordWriteOf, ignore it when recording modifications
            observationsProcessed.add(value, scope)
        }
    }
}

流程:

  1. 依赖关系映射表(observations) 中找到依赖该状态的作用域。
  2. 调用 invalidateForResult 标记它为
  3. 将已处理的 scope 记录到 observationsProcessed,避免重复标记。

我们具体来看看invalidateForResult方法的实现

kotlin 复制代码
fun invalidateForResult(value: Any?): InvalidationResult =
    owner?.invalidate(this, value) ?: InvalidationResult.IGNORED

调用 invalidate(), 进入核心标记逻辑。

kotlin 复制代码
override fun invalidate(scope: RecomposeScopeImpl, instance: Any?): InvalidationResult {
    ...
    // 执行标记为脏的核心操作
    return invalidateChecked(scope, anchor, instance).also {
        if (it != InvalidationResult.IGNORED) {
            observer()?.onScopeInvalidated(scope, instance)
        }
    }
}

具体逻辑还是在invalidateChecked方法中,查看代码:

kotlin 复制代码
private fun invalidateChecked(...): InvalidationResult {
    // 核心目标:标记作用域为脏(失效)
    if (delegate == null) {  // 无委托时直接处理
        // 核心步骤:将作用域记录到失效映射表(invalidations)
        invalidations.set(scope, ScopeInvalidated)  // 标记为脏的核心操作
    }
    ...
    // 通知父组合器触发重组调度
    parent.invalidate(this)
    return InvalidationResult.SCHEDULED  // 实际会根据isComposing返回SCHEDULED/DEFERRED,但不影响脏标记
}

组合正在进行的过程中(即正在执行 composing 块) ,如果某个作用域被标记为脏,Compose可能会选择 推迟 它的重组(InvalidationResult.DEFERRED),而不是立即调度。这是为了避免在同一帧内对同一作用域进行多次重组。

四、快照在重组流程中的协作

下图展示了Compose重组过程中,状态(State)、快照(Snapshot)、依赖记录(Observations)与重组器(Recomposer)之间协同工作的完整流程。

  • State (SnapshotMutableStateImpl) : 状态对象,重写了get/set方法,是读写操作的源头。
  • Snapshot: 带有观察者的快照环境,负责拦截读写操作并触发回调。
  • Observations: 依赖关系映射表,用于记录状态与重组作用域的对应关系。
  • Recomposer: 重组协调器,负责创建快照环境、接收回调信号、调度重组。

上图的整个流程:

  1. 重组开始Recomposer 在帧信号到来时,准备执行重组。
  2. 创建快照 :调用 composing { ... } ,创建一个携带了 readObserverwriteObserver读写快照
  3. 执行与追踪 :在这个快照的 enter { ... } 块中执行组合代码。
    • 读操作 (state.value) : 触发 readObserver -> 将 (状态, 重组作用域) 添加到了 依赖关系映射表(observations) 中。 (建立依赖关系)
    • 写操作 (state.value = newValue) : 触发 writeObserver -> 从 依赖关系映射表(observations) 中找到所有依赖了该状态的作用域 -> 调用 invalidate 将它们标记为"脏"。 (脏(Dirty)即意味着需要被重新组合)
  4. 提交与应用 :在 Recomposer#composing 方法中,finally 代码块中,applyAndCheck(snapshot) 会调用 snapshot.apply()
    • apply() 过程内部 :该方法会尝试将子快照的更改提交到全局状态。在此过程中,系统会自动处理可能出现的状态冲突 (例如,同一状态在子快照和外部被同时修改)。如果冲突无法自动解决,apply() 会失败。
    • applyAndCheck 的处理:applyAndCheck 方法会检查 apply() 的返回结果。如果失败,则抛出异常。而无论是否成功,最后都会销毁 (dispose) 该快照。
  5. 执行下一帧 :如果发现有 作用域,Recomposer调度 重组。其执行时机取决于当前组合状态:可能立即执行(同步),也可能安排到下一帧执行(异步),后者更为常见。

注意:快照的读写观察在组合阶段实时发生 ,但实际重组执行由 Recomposer 在下一帧统一调度。

五、总结与延伸

回顾前文,Compose 的重组机制之所以能实现 按需重组 ,本质上得益于快照系统与重组调度器的协作。我们可以将 SnapshotMutableStateImplRecomposer#composing 的联系总结如下:

  1. SnapshotMutableStateImpl 负责发出读/写的信号。它是一个"代理",通过 get()set() 方法将所有操作委托给快照系统。

  2. Recomposer 通过 composing 方法 创建了一个携带了观察者的快照环境来捕获这些信号。在这个环境中:

    • 读信号readObserverOf 捕获,转化为 依赖关系 并记录在案。
    • 写信号writeObserverOf 捕获,根据已记录的依赖关系,转化为对具体作用域的 失效标记

简而言之:一个负责广播 ,一个负责监听与处理。 正是这种设计,将状态的变更与UI的更新完美地解耦并高效地连接起来。

再小结下Compose 的快照系统:

  1. 快照隔离 ------ 确保状态修改的安全性。
  2. 读写观察 ------ 精确追踪依赖关系。
  3. 失效标记 ------ 只重组真正受影响的作用域。

保证了 mutableStateOf 的每一次变化,都能 高效、最小化 地映射到 UI 更新。

下一篇文章,我们将探讨这些被标记的 脏作用域 是如何被存储和决策的,这就引出了 Compose 的另一个核心:SlotTable

六、参考资料

  1. Compose Snapshots: we got THE expert to go in-depth - with Chuck Jazdzewski
  2. 一文看懂 Jetpack Compose 快照系统
  3. Jetpack Compose · 快照系统Snapshot
  4. 扒一扒 Jetpack Compose 实现原理
  5. Introduction to the Compose Snapshot system
  6. Unlocking Jetpack Compose: An In-Depth Exploration of Recomposition & the Snapshot System
相关推荐
一枚前端小能手2 小时前
🔥 字符串处理又踩坑了?JavaScript字符串方法全攻略,这些技巧让你效率翻倍
前端·javascript
windliang2 小时前
一文入门 agent:从理论到代码实战
前端·算法
三十_2 小时前
私有 npm 仓库实践:Verdaccio 保姆级搭建教程与最佳实践
前端·npm
叫我詹躲躲2 小时前
别再手写正则了!20 + 证件 / 手机号 / 邮箱验证函数,直接复制能用
前端·javascript·正则表达式
猪哥帅过吴彦祖2 小时前
第 4 篇:赋予表面生命 - WebGL 纹理贴图
前端·javascript·webgl
猪哥帅过吴彦祖2 小时前
Flutter 系列教程:核心概念 - StatelessWidget vs. StatefulWidget
前端·javascript·flutter
郝学胜-神的一滴3 小时前
解析前端框架 Axios 的设计理念与源码
开发语言·前端·javascript·设计模式·前端框架·软件工程
aixfe3 小时前
Ant Design V5 Token 体系颜色处理最佳实践
前端
yanessa_yu3 小时前
前端请求竞态问题
前端