揭秘 Jetpack Compose 快照系统 | 开发者说·DTalk
The following article is from AndroidPub Author fundroid
本文原作者: fundroid,原文发布于: AndroidPub
引言
Compose 通过名为 "快照 (Snapshot)" 的系统支撑状态管理与重组机制的运行。快照作为一个底层设施,在我们的日常开发中很少直接接触,本文就为大家揭开快照的神秘面纱。我们在开头先抛出几个问题,希望在文章结束时大家能够找到答案,对快照也就算有了初步了解了。
快照能做什么? 快照与状态的关系? 快照与线程的关系? 快照与重组的关系?
注意: 本文出现的源码基于版本 1.2.0-alpha06。本文重在帮助大家建立认知,对源码的介绍只是点到为止,请放松阅读。
我们知道 Compose 库从上到下分为多层: Material > UI > Runtime > Compiler。快照系统位于 Runtime 层 androidx/compose/runtime/snapshots。它自成体系,可以脱离 Compose UI 甚至 Compiler 单独使用,只依赖 Runtime 即可使用快照功能,本文中的 Sample 代码均可以不依赖 UI 运行。
implementation "androidx.compose.runtime:runtime:$compose_version"快照的基本操作
快照并非 Compose Runtime 的原创概念,它其实是一个 MVCC 系统的实现,MVCC 全称 Multiversion Concurrency Control (多版本并发控制),常用于数据库管理系统,实现事务并发,提升数据库性能,其模型与 Git 分支管理系统也有点类似,因此我们可以类比数据库的事务或者 Git 的分支来理解快照机制。
快照的创建
先看下面的例子:
fun test() { // 创建状态(主线开发) val state = mutableStateOf(1) // 创建快照(开分支) val snapshot = Snapshot.takeSnapshot() // 修改状态(主线修改状态) state.value = 2
println(state.value) // 打印1 snapshot.enter {//进入快照(切换分支) // 读取快照状态(分支状态) println(state.value) // 打印1 }
// 读取状态(主线状态) println(state.value) // 打印2 // 废弃快照(删除分支) snapshot.dispose()}例子中展示了快照的基本功能: 隔离访问。Snapshot.takeSnapshot() 创建了一个快照,通过调用其 enter() 进入此快照。在快照上只能看到快照被创建时刻的最新状态,看不到此后的变化。
将快照类比成 Git 系统,程序默认处于 GlobalSnapshot 全局快照中,这相当于 Git 的 Main 分支。从全局快照上创建并进入子快照,就如同在 Main 上创建并切换分支,分支代码保持分支创建时的状态,看不到主线或其他分支的修改。当然 Git 的隔离对象是代码,而快照的隔离对象是 "状态",也就是 mutableStateOf 创建的一个 StateObject 实例。
使用下面这些方法都可以创建 StateObject 对象,它们都可以被快照隔离:
mutableStateOf/MutableState mutableStateListOf/SnapshotStateList mutableStateMapOf/SnapshotStateMap derivedStateOf rememberUpdatedState collect*AsState
快照的修改 & 提交
上面的例子中 enter() 内只是读取了快照状态,如果我们试图更新状态则会抛出异常。takeSnapshot() 创建的是一个只读快照,不允许对状态有写操作。如果需要更新状态,需要使用 takeMutableSnapshot() 创建可写的快照:
// 创建可写的快照 val snapshot = Snapshot.takeMutableSnapshot() snapshot.enter { // 对快照状态进行变更 state.value = 2 println(state.value) // 打印2 }
// snaphot之外看不到对快照状态的修改。println(state.value) // 打印1snapshot.enter { // ...}
// 提交snapshot中的状态修改snapshot.apply()
// 快照外可以看到snapshot中的修改println(state.value) // 打印2我们还可以使用 withMutableSnapshot 简化代码,它可以在 "切换回主线" 时自动提交变更。
Snapshot.withMutableSnapshot { state.value = 2}
println(state.value) // 打印2注意: git merge 可以在任意分支之间进行合并,而快照的 apply 永远是从当前快照提交到 "父快照"。快照上允许嵌套创建快照,因此快照存在父子关系。
访问隔离的实现原理
状态关联快照
可见,Compose 的 State 天生支持在快照中访问,所以 Compose 的状态也经常被称为快照状态 (Snapshot State),快照状态通过 snapshotId 实现 "多版本并发控制" 的目的。
管理 SnapshotId
//androidx/compose/runtime/snapshots/Snapshot.kt //遍历链表,根据 snapshotId 返回符合当前快照读取条件的 StateRecord private fun <T : StateRecord> readable(r: T, id: Int, invalid: SnapshotIdSet): T? { var current: StateRecord? = r var candidate: StateRecord? = null //while 循环中遍历链表 while (current != null) { //valid 方法检查 StateRecord 是否符合条件 if (valid(current, id, invalid)) { // 符合条件且 snapshotId 最大的 StateRecord 作为结果返回。 candidate = if (candidate == null) current else if (candidate.snapshotId < current.snapshotId) current else candidate } current = current.next } if (candidate != null) { @Suppress("UNCHECKED_CAST") return candidate as T } return null}
/** * 检查 StateRecord 是否可以被读取: * 1. StateRecord#snapshotId != INVALID_SNAPSHOT。 * 2. StateRecord#snapshotId 不大于当前快照 id。 * 3. StateRecord#snapshotId 不在 invalid 集合中*/private fun valid(currentSnapshot: Int, candidateSnapshot: Int, invalid: SnapshotIdSet): Boolean { return candidateSnapshot != INVALID_SNAPSHOT && candidateSnapshot <= currentSnapshot && !invalid.get(candidateSnapshot)}代码很清晰,如大家所料,这里通过 snapshotId 的比较来决定 StateRecord 是否可读。因为快照被赋予了全局自增 id,理论上小于当前 snapshotId 的状态值是快照创建前被写入的,所以应该对当前快照可见。我们注意到除了 snapshotId 的比较之外,还要求 StateRecord#snapshotId 不能位于 invalid 集合中。
//androidx/compose/runtime/snapshots/Snapshot.kt
open class MutableSnapshot internal constructor( id: Int, // 快照id invalid: SnapshotIdSet, //快照黑名单 override val readObserver: ((Any) -> Unit)?, // 读回调,后文介绍 override val writeObserver: ((Any) -> Unit)? // 写回调,后文介绍) : Snapshot(id, invalid)MutableSnapshot 的定义如上,其中 invalid 成员代表一个快照黑名单。处于黑名单中的 id,即使比当前快照 id 小,也视为不可见内容。我们前面介绍过快照的提交,在子快照未提交之前,即使它的 id 小于全局快照也不应该被全局看见,因此在正式提交之前会被加入全局快照的这个黑名单。
我们在 GlobalSnapshot 中创建子快照,id 赋值为 2;
为了让子快照中访问不到父快照后续的状态变化,子快照创建后 GlobalSnapshot 的 id 升级至 3;
为了让 GlobalSnapshot 看不到子快照的状态变化,将 2 加入 invalid;
子快照提交后,GlobalSnapshot 的 invalid 中移除 2,子快照状态全局可见。
上面过程中出现了 id 升级的概念,可见快照提交的本质就是通过升级父快照 id 让子快照状态全局可见。这与 git merge 之后移动分支的 head 位置也有着异曲同工之处。
状态读写感知
快照系统除了对状态的读写进行隔离,还可以对状态的读写进行感知,前面 MutableSnapshot 的定义中看到 readObserver 和 writeObserver 成员,它们就是快照上对状态进行读写操作时的回调。
val state = mutableStateOf(1) // 监听状态读操作 val readObserver: (Any) -> Unit = { readState -> if (readState == state) { println("readObserver: $readState") // 打印 2 } } // 监听状态写操作val writeObserver: (Any) -> Unit = { writtenState -> if (writtenState == state) { println("writeObserver: $writtenState") // 打印 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()上面代码中,我们在创建快照时传入读写回调,快照中读写状态时依次触发回调,因此上面代码的日志输出如下:
writeObserver: 2readObserver: 22全局快照
监听全局状态变化
提示: Composae 渲染分有三个阶段: 组合,布局,绘制,文中提到的组合就是其中第一个阶段 https://developer.android.google.cn/jetpack/compose/phases
有些状态变化发生在组合阶段之外,比如 onClick 或者一个异步请求的返回都可能触发状态变化,组合之外的代码不执行在子快照,因此它们会直接在全局快照上修改状态。全局快照上没有 apply 操作,但是我们通过主动调用 Snapshot.sendApplyNotifications() 同样可以向 ApplyObserver 发送通知获知全局状态的修改。sendApplyNotifications 通过升级全局快照 id 来确定需要通知哪些状态的变化,即自上次升级 id 以来的所有状态。
ApplyObserver 的通知可能来自子快照的提交,也可能来自 sendApplyNotifications 的直接调用,但用途都是为了监听全局状态的变化。
val state = mutableStateOf(1) Snapshot.registerApplyObserver { set, _ -> // 将响应 sendApplyNotifications 的调用 // 获取有变更的状态 println("$set") // [MutableState(value=3)] } state.value = 2state.value = 3 // 向 ApplyObserver 通知最后一次变化
// 通知变化Snapshot.sendApplyNotifications() val state = mutableStateOf(1) val observer = Snapshot.registerGlobalWriteObserver { writtenState -> // MutableState(value=2) 和 MutableState(value=3) 都会收到 println("$writtenState") } state.value = 2 state.value = 3
observer.dispose()每次状态修改都可以通过 registerGlobalWriteObserver 监听。注意全局快照不提供读操作的回调注册,因为 Compose 只会在组合阶段追踪对状态的读取,所以在子快照监听足以。
非 Compose 中使用快照
文章开头就提到,Compose 快照系统可以脱离 Compose UI 单独使用。下面的例子中,我们通过监听全局快照的状态,实现基于 View 的状态管理。
class MainActivity : AppCompatActivity() { private lateinit var binding: ActivityMainBinding private var counter by mutableStateOf(0) private val observer = Snapshot.registerGlobalWriteObserver { Snapshot.sendApplyNotifications() }
override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root)
lifecycleScope.launch { snapshotFlow { // 将 Counter 的变化更新至 TextView binding.textCounter.text = "$counter" }.collect() }
binding.buttonIncrement.setOnClickListener { counter++ } binding.buttonDecrement.setOnClickListener { counter-- } }
override fun onDestroy() { super.onDestroy() observer.dispose() }}snapshotFlow 是 Compose 提供的状态管理 API,可以监听全局快照的状态变化并转化为 Flow 发送出去。具体实现我们就不看了,只需要知道它内部通过 ApplyObserver 观察状态变化,因此我们通过 registerGlobalWriteObserver 监听到状态修改后,通过 sendApplyNotifications 发送通知。
这段代码同时也揭示了 Compose 的 State 可以像 RxJava/LiveData/Flow 那样成为一种通用的响应式工具,而且还可以省掉冗余的 subscribe/observe/collect 代码,snapshotFlow { } 中会自动追踪所有被读取的状态,当它们发生变化时,block 会触发执行,响应式逻辑更加简洁。
并发与冲突解决
前面的例子都是跑在单线程中的,而作为一个 MVCC 系统,只有在并发场景中使用才更有意义。通常并发环境下对数据访问,为了保证线程安全需要添加各种读写锁,而快照系统通过访问隔离实现无锁操作,提高并发性能。此外快照的提交机制也保证了容错性,进一步套用数据库事务的说法就是保证了 ACID 中的原子性、隔离性和一致性。
多线程下的快照保存
当快照在多线程环境下使用时,当前快照信息保存在 ThreadLocal 中。Compose 在组合执行过程中,通过 currentSnapshot() 获取当前快照。
//androidx.compose.runtime.SnapshotThreadLocal //如果不存在当前快照,则返回全局快照 internal fun currentSnapshot(): Snapshot = threadSnapshot.get() ?: currentGlobalSnapshot.get() private val threadSnapshot = SnapshotThreadLocal<Snapshot>() //使用 ThreadLocal 管理快照internal actual class SnapshotThreadLocal<T> { private val map = AtomicReference<ThreadMap>(emptyThreadMap) private val writeMutex = Any()
@Suppress("UNCHECKED_CAST") actual fun get(): T? = map.get().get(Thread.currentThread().id) as T?
actual fun set(value: T?) { val key = Thread.currentThread().id synchronized(writeMutex) { val current = map.get() if (current.trySet(key, value)) return map.set(current.newWith(key, value)) } }}从 Snapshot#enter 方法的实现可知,进入快照的本质就是将快照存入 SnapshotThreadLocal:
inline fun <T> enter(block: () -> T): T { val previous = makeCurrent() try { return block() } finally { restoreCurrent(previous) } } internal open fun makeCurrent(): Snapshot? { val previous = threadSnapshot.get() threadSnapshot.set(this) return previous}mergeRecords 解决冲突
并发环境必然要考虑冲突的发生。当我们在子线程快照中修改了某 StateObject,同时它在父快照中也发生了变化,那么当提交子快照时就会遇到冲突,此时就要像 git merge 冲突一样,要么放弃提交,要么对冲突进行解决。记得前面 StateObject 的类图中曾经出现了一个 mergeRecords 方法,StateObject 就是用它来处理状态冲突的:
//androidx/compose/runtime/SnapshotState.kt override fun mergeRecords( previous: StateRecord, // 子快照创建之前的全局状态 current: StateRecord, // 全局快照最新状态 applied: StateRecord // 待提交的子快照状态 ): StateRecord? { val previousRecord = previous as StateStateRecord<T> val currentRecord = current as StateStateRecord<T> val appliedRecord = applied as StateStateRecord<T> //父快照与待提交子快照的状态比较 return if (policy.equivalent(currentRecord.value, appliedRecord.value)) current else {//如果状态不相等,进行merge操作 val merged = policy.merge( previousRecord.value, currentRecord.value, appliedRecord.value ) if (merged != null) {//merge成功则返回merge结果 appliedRecord.create().also { (it as StateStateRecord<T>).value = merged } } else { null } }}当子快照提交时,对全局快照的 previous 与 current 会进行比较,如果不相等则意味着本次提交有冲突的可能,此时会通过 mergeRecords 解决冲突,进入上面的代码。逻辑很清晰,重点是对 policy 的两个方法调用,equivalent 用来比较 current 与 applied,如果不相等则调用 merge 进行合并操作,解决冲突。
structuralEqualityPolicy: 结构化比较,即通过 == 比较状态值是否相等,这也是 SnapshotState 目前默认的策略
referentialEqualityPolicy – 引用比较,通过 === 比较,只有同一实例才相等
neverEqualPolicy: 永远判定为不相等
以上无论哪种 Policy 在 merge 的默认实现上都一样,即不合并,状态提交失败。因为 merge 本身属于业务范畴,很难给出默认实现,需要开发者根据需要自己实现。
注意: 当我们更新 StateObject 时,需要判断是否发生变化以决定是否应该重组,这个判断也是使用 SnapshotMutationPolicy#equivalent 完成的。
如何支持 Compose 重组?
读写感知: 标记 RecomposeScope
我们知道 Compose 通过状态变化驱动重组进而完成 UI 的刷新,而且 Compose 的重组是 "智能的",遵循范围最小化原则。每个返回 Unit 的 @Composable 函数 (或 lambda) 都是一个 RecomposeScope,Scope 会追踪内部访问的状态,当状态发生变化时该 Scope 会参与重组,如果状态无变化则会跳过重组。这整个过程正是依靠快照读写感知的机制实现的。
Compose 通过调用 Recomposer#composing 方法完成组合。
//androidx.compose.runtime.Recomposer private inline fun <T> composing( composition: ControlledComposition, modifiedValues: IdentityArraySet<Any>?, block: () -> T ): T { //创建快照 val snapshot = Snapshot.takeMutableSnapshot( readObserverOf(composition), writeObserverOf(composition, modifiedValues) ) try { // 进入快照 return snapshot.enter(block) } finally { applyAndCheck(snapshot) }}可以看到,组合开始时先创建了一个可变快照,并调用 readObserverOf 和 writeObserverOf 创建状态读写回调传入快照。接着调用 enter 进入快照执行组合阶段的 Composable 函数,所以 Composalbe 在快照上的状态读写都会被监听到。
Composable 中读取状态时触发回调,最终调用到 recordReadOf,将修改的 StateObject 连同 currentRecomposeScope 一并注册到 observations,observations 记录了哪些 Scope 访问了哪些 State。
override fun recordReadOf(value: Any) { if (!areChildrenComposing) { composer.currentRecomposeScope?.let { it.used = true observations.add(value, it) ... } } }当 Composable 对状态进行写入时调用 recordWriteOf 方法,从 observations 中找到关联的 Scope 标记为 invalid。
override fun recordWriteOf(value: Any) = synchronized(lock) { invalidateScopeOfLocked(value) derivedStates.forEachScopeOf(value) { invalidateScopeOfLocked(it) } } private fun invalidateScopeOfLocked(value: Any) { observations.forEachScopeOf(value) { scope -> if (scope.invalidateForResult(value) == InvalidationResult.IMMINENT) { observationsProcessed.add(value, scope) } } }在下次帧信号到达时,invalid 的 scope 会在重组中执行,基于最新状态完成组合,同时重复上述过程,设置监听感知状态的下一次变化。
全局快照上的状态修改发生在组合阶段以外,但同样可以确定 RecomposeScope,这是通过前面讲 registerApplyObserver 实现的。当全局快照中发生状态写操作时,GlobalSnapshotManager 会发送 SendApplyNotification。
//androidx.compose.runtime.Recomposer#recompositionRunnerval unregisterApplyObserver = Snapshot.registerApplyObserver { changed, _ -> synchronized(stateLock) { if (_state.value >= State.Idle) { snapshotInvalidations += changed deriveStateLocked() } else null }?.resume(Unit)}如上,Recomposer 在 ApplyObserver 中获得变化的状态 changed,然后调用 deriveStateLocked() 方法,最终也会执行 invalidateForResult 找到 changed 关联的 Scope 并标记为 invalid。
读写隔离: 支持重组并行化
官方文档告诉我们重组是并行的:
Compose can optimize recomposition by running composable functions in parallel. This lets Compose take advantage of multiple cores.
但截至目前重组仍然跑在单线程上,并行化还在开发中,但是依托快照系统并行化重组随时可能开启,所以我们现在就需要带着并行的意识开发自己的代码,避免届时出现 Bug。重组的并行化得益于快照的隔离机制,重组在执行过程中,不会受到其它线程对状态修改的影响,杜绝并发异常的发生。
结合下面的时序图,我们梳理一下 Compose 重组的整个过程,看看快照在其中是如何发挥作用的。假定场景是在 onClick 中修改了某个状态,且并行化已启动。如前文所述,onClick 的状态修改发生在全局快照。
注意: 图中的箭头并非源码中真实的方法调用,只表示一个依赖关系。
全局快照的状态变化会通过 sendApplyNotifications 通知出来;
Recomposer 接收到变化的状态,在下一帧到来之前将需要重组的 Scope 标记为 invalid;
当帧信号达到时,Recomposer 查找 invalid 的 Scope,获取空闲子线程并创建快照,在快照上执行 Scope 代码;
Scope 代码执行中如果读取了某状态,则作为状态的观察者记录到 observations;
Scope 内部如果对某状态进行了修改,则从 observations 查找观察者状态,标记为 invalid;
Scope 执行结束后,如果期间状态有修改,则通过快照提交,将状态变化同步给全局;
全局状态变化通过 ApplyObserver 回调 Recomposer,然后重复过程 2。
回顾&总结
快照能做什么?
Compose 快照是一个可以感知状态读写的 MVCC 系统,它主要功能是隔离和感知状态的变化。
快照与状态的关系?
快照隔离和感知的对象是状态,状态通过 snapshotId 与快照建立关联,实现访问隔离。
快照与线程的关系?
快照可以在单线程下运行,但是它更适合在并发环境下使用,快照帮助多线程任务实现线程安全。
快照与重组的关系?
Compose 的重组借助快照实现了并发执行,同时通过快照的读写感知确定参与下次重组的范围。
长按右侧二维码
查看更多开发者精彩分享
"开发者说·DTalk" 面向