前言
在上一篇文章《Jetpack Compose重组优化:机制剖析与性能提升策略》中,介绍了 Compose 重组的基本机制以及常见的优化方式。
不过,光知道怎么优化可能还不够。要搞清楚 为什么 Compose 能做到按需重组 ,就得了解它的内部原理,而 快照系统(Snapshot) 可以说是最关键的一部分。
本文是《Jetpack Compose 重组原理》系列的第一篇,将介绍 Snapshot 的运行机制。后续还将介绍 SlotTable 与 Recomposer,最终把整个重组过程完整串起来。
注意:本文出现的源码基于版本 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。
快照系统的核心可归纳为三点:
- 读写隔离 :提供读写隔离 环境,子快照的修改需显式 apply() 才能生效。
- 读写感知 :通过读写观察者(Observer) 在组合期间自动追踪状态访问,从而精准建立依赖关系与触发失效标记。
- 底层实现 :其底层由 StateRecord 链表 实现,采用多版本并发控制(MVCC,Multi-Version Concurrency Control) 策略,每个快照通过版本ID访问对其可见的最新状态记录,这是所有功能得以实现的根本。
理解了这些核心概念,我们就可以看看Compose内部是如何通过具体的代码架构来实现的。
三、Compose中的快照
3.1 状态对象的底层机制:StateRecord 链表
在理解 Compose 快照之前,我们首先要了解 Compose 中状态对象的本质及其底层存储机制。整个快照系统建立在两个核心抽象之上:StateObject 和 StateRecord ,其关系如下图所示:
图示:StateObject 与 StateRecord 的类图结构。请注意,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 构成的单向链表,并负责管理它(实现了 firstStateRecord 和 prependStateRecord 方法)。
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 方法创建一个 带观察者的快照环境 ,并解读 readObserverOf 和 writeObserverOf 这两个回调如何实现 依赖追踪 与 失效标记 的核心逻辑。
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
}
}
流程解读:
- 创建一个带有 readObserver 和 writeObserver 的可变快照。
- 在 enter {} 内执行组合代码,期间的所有读写都会被捕获。
- 在 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)
}
}
}
流程:
- 从 依赖关系映射表(observations) 中找到依赖该状态的作用域。
- 调用 invalidateForResult 标记它为 脏。
- 将已处理的 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: 重组协调器,负责创建快照环境、接收回调信号、调度重组。
上图的整个流程:
- 重组开始 :Recomposer 在帧信号到来时,准备执行重组。
- 创建快照 :调用 composing { ... } ,创建一个携带了 readObserver 和 writeObserver 的读写快照。
- 执行与追踪 :在这个快照的 enter { ... } 块中执行组合代码。
- 读操作 (state.value) : 触发 readObserver -> 将 (状态, 重组作用域) 添加到了 依赖关系映射表(observations) 中。 (建立依赖关系)
- 写操作 (state.value = newValue) : 触发 writeObserver -> 从 依赖关系映射表(observations) 中找到所有依赖了该状态的作用域 -> 调用 invalidate 将它们标记为"脏"。 (脏(Dirty)即意味着需要被重新组合)
- 提交与应用 :在 Recomposer#composing 方法中,finally 代码块中,applyAndCheck(snapshot) 会调用 snapshot.apply() 。
- apply() 过程内部 :该方法会尝试将子快照的更改提交到全局状态。在此过程中,系统会自动处理可能出现的状态冲突 (例如,同一状态在子快照和外部被同时修改)。如果冲突无法自动解决,apply() 会失败。
- applyAndCheck 的处理:applyAndCheck 方法会检查 apply() 的返回结果。如果失败,则抛出异常。而无论是否成功,最后都会销毁 (dispose) 该快照。
- 执行下一帧 :如果发现有 脏 作用域,Recomposer 会 调度 重组。其执行时机取决于当前组合状态:可能立即执行(同步),也可能安排到下一帧执行(异步),后者更为常见。
注意:快照的读写观察在组合阶段实时发生 ,但实际重组执行由 Recomposer 在下一帧统一调度。
五、总结与延伸
回顾前文,Compose 的重组机制之所以能实现 按需重组 ,本质上得益于快照系统与重组调度器的协作。我们可以将 SnapshotMutableStateImpl 与 Recomposer#composing 的联系总结如下:
-
SnapshotMutableStateImpl 负责发出读/写的信号。它是一个"代理",通过 get() 和 set() 方法将所有操作委托给快照系统。
-
Recomposer 通过 composing 方法 创建了一个携带了观察者的快照环境来捕获这些信号。在这个环境中:
- 读信号 被 readObserverOf 捕获,转化为 依赖关系 并记录在案。
- 写信号 被 writeObserverOf 捕获,根据已记录的依赖关系,转化为对具体作用域的 失效标记。
简而言之:一个负责广播 ,一个负责监听与处理。 正是这种设计,将状态的变更与UI的更新完美地解耦并高效地连接起来。
再小结下Compose 的快照系统:
- 快照隔离 ------ 确保状态修改的安全性。
- 读写观察 ------ 精确追踪依赖关系。
- 失效标记 ------ 只重组真正受影响的作用域。
保证了 mutableStateOf 的每一次变化,都能 高效、最小化 地映射到 UI 更新。
下一篇文章,我们将探讨这些被标记的 脏作用域 是如何被存储和决策的,这就引出了 Compose 的另一个核心:SlotTable。