Jetpack Compose 有一种特殊的方式来表示状态和传播状态变化,从而驱动最终的响应式体验:状态快照系统(State snapshot system)。这种响应式模型使我们的代码更加强大和简洁,因为它允许组件根据它们的输入自动重组,并且只在必要时重组,避免了我们过去在Android View 系统中手动通知这些更改所需的所有样板文件。
什么是 Snapshot State
Snapshot state(快照状态)是指可以被记录并观察其变化的隔离状态。当我们调用像mutableStateOf、mutableStateListOf、mutableStateMapOf、derivedStateOf、produceState、collectAsState
等函数时,我们所得到的状态就是快照状态。所有这些函数都返回某种类型的状态,开发人员经常称其为快照状态。
“Snapshot state” 这个术语的命名是因为它是 Jetpack Compose runtime 定义的状态快照系统的一部分。这个系统建模和协调状态变化和变化传播。它是以分离的方式编写的,因此理论上可以被其他想要依赖于可观察状态的库使用。
关于变化传播,我们在之前了解到的一件事情是所有 Composable 声明和表达式都会被 Jetpack Compose 编译器包装,以自动跟踪其体内的任何快照状态读取。这就是快照状态如何被(自动)观察的方式。目标是每当 Composable 读取的状态发生变化时,runtime 就会使其 RecomposeScope 失效,以便在下一次重组时再次执行它。
这是由 Compose 提供的基础设施代码,因此它不需要存在于任何客户端代码库中。runtime 的客户端,如 Compose UI,可以完全不需要了解失效和状态传播的方式,或者如何触发重组,而只需要关注提供与该状态配合使用的构建块:即 Composable 函数。
但是快照状态不仅仅是自动通知更改以触发重组的问题。使用 “snapshot” 这个单词的一个很重要的原因是:状态隔离。这代表了我们在并发上下文中应用的隔离级别。
想象一下在不同线程之间处理可变状态会怎样。这很容易变得一团糟。需要严格的协调和同步来确保状态的完整性,因为它可以在同时从不同的线程中读取或修改。这为冲突、难以检测的bug和竞争条件敞开了大门。
传统意义上,编程语言以不同的方式处理这个问题,其中之一是不可变性。不可变数据在创建后永远不会被修改,这使它在并发场景下绝对安全。另一个有效的方法可以是 actor 系统。该系统专注于跨线程的状态隔离。Actor 保留其自己的状态副本,通过消息实现通信/协调。如果该状态是可变的,则需要存在一些协调来使全局程序状态一致。Compose 快照系统不是基于 actor 系统,但实际上更接近于该方法。
Jetpack Compose 使用可变状态,因此 Composable 函数可以自动响应状态更新。仅使用不可变状态的库是没有意义的。这意味着它需要解决在并发场景中共享状态的问题,因为组合可以在多个线程中实现。Compose解决此问题的方法就是状态快照系统,它基于状态隔离和后续的变更传播,以便可以在多个线程之间安全地使用可变状态。
快照状态系统是使用并发控制系统建模的,因为它需要以安全的方式协调跨线程的状态。在并发环境中共享可变状态并不容易,这是一个通用的问题,与库的实际用例无关。
在 Jetpack Compose 中 State 是一个接口,任何快照状态对象都会实现这个接口。以下是State接口的代码形式:
这个协议被标记为 @Stable
,因为 Jetpack Compose 仅提供和使用稳定的实现(出于设计原因),概括一下,这意味着该接口的任何实现必须确保:
- 对相同的两个 State 实例调用
equals
方法总是返回相同的结果。 - 当类型的公开属性值更改时,会通知组合。
- 它所有的公开属性值类型也是稳定的。
建议读一下Zach Klipp的这篇文章,介绍了一些相关的想法。我非常推荐这篇文章。
接下来首先了解一些关于并发控制系统的知识。这将有助于我们更容易地理解为什么Jetpack Compose状态快照系统采用这种模型。
并发控制系统
状态快照系统是按照并发控制系统实现的,因此让我们先介绍这个概念。
在计算机科学中,“并发控制”是关于确保并发操作正确结果的一种方法,这意味着协调和同步。并发控制由一系列规则组成,确保整个系统的正确性。但是,协调总是伴随着一定的代价。协调通常会影响性能,因此关键挑战是设计一种尽可能高效但不会显著降低性能的方法。
一个并发控制的例子是数据库管理系统(DBMS)中的事务系统,这个上下文中的并发控制确保在并发环境中执行的任何数据库事务都是以安全的方式进行的,不违反数据库的数据完整性。目标是维护正确性。这里的“安全”涵盖的内容包括确保事务是原子性的、可以安全地撤销、已提交事务的效果永远不会丢失,以及已中止事务的效果不会留在数据库中。这是一个复杂的问题。
并发控制不仅在DBMS中经常出现,在编程语言中也会出现,例如用于实现事务内存。事务内存试图通过允许一组加载和存储操作以原子方式执行来简化并发编程。实际上,在Compose状态快照系统中,当状态更改从一个快照传播到其他快照时,状态写入被应用为单个原子操作。像这样分组的操作简化了并行系统/进程中共享数据的并发读写之间的协调。在此基础上,原子更改可以轻松地中止、撤销或重现。即:拥有可重现更改历史,以可能重新生成程序状态的任何版本。
并发控制系统有不同的类别:
- 乐观:不阻塞任何读或写操作,并对这些操作的安全性持乐观态度,如果提交时将违反所需规则,则中止事务以防止违反。中止的事务立即重新执行,这意味着有开销。当平均中止事务的数量不太高时,这种策略可能是一个很好的选择。
- 悲观:如果操作违反规则,则阻止事务中的操作,直到违反的可能性消失。
- 半乐观:这是其他两种的混合解决方案。只在某些情况下阻止操作,并对其他情况持乐观态度(然后在提交时中止)。
每个类别的性能因因素而异,例如平均事务完成速率(吞吐量)、所需的并行性水平和其他因素,例如死锁的可能性。非乐观类
多版本并发控制 (MVCC)
Jetpack Compose 中的全局状态是跨组合和线程共享的。组合函数应该能够并行运行(可以随时进行并行重组),如果它们并行执行,则可以同时读取或修改快照状态,因此需要进行状态隔离。
并发控制的主要特性之一实际上是隔离性。该特性确保了在并发访问数据的情况下的正确性。实现隔离的最简单方法是阻止所有 readers 直到 writers 完成,但这会对性能产生极大的影响。MVCC(Multiversion concurrency control)可以做得更好。
为了实现隔离性,MVCC 保留了数据的多个副本(快照),因此每个线程都可以在给定时刻使用一个隔离的状态快照来工作。我们可以将它们理解为状态的不同版本(“多版本”)。线程所做的修改对其他线程来说是不可见的,直到所有本地更改完成并传播。
在并发控制系统中,这种技术被称为“快照隔离”,并且它被定义为用于确定每个“事务”看到哪个版本的隔离级别。
MVCC 还利用了不可变性,因此每当写入数据时,都会创建数据的新副本,而不是修改原始数据。这导致在内存中存储了相同数据的多个版本,就像对象的所有更改历史一样。在 Compose 中,这些称为“状态记录”。
MVCC 还具有的一个特点是它创建了时间点一致的视图。这通常是备份文件的一个特性,它表示给定备份上所有对象的引用保持一致。在 MVCC 中,通常通过事务 ID 来确保这一点,因此任何读操作都可以引用相应的 ID 来确定使用哪个版本的状态。这实际上是 Jetpack Compose 中的工作方式。每个快照都被分配了自己的 ID。快照 ID 是单调递增的值,因此它们自然地被排序。由于快照是通过它们的 ID 区分的,因此读取和写入是相互隔离的,无需进行锁定。
Snapshot
一个快照可以在任何时候被创建。它反映了程序在给定时刻(创建快照时)的当前状态(所有快照状态对象)。可以创建多个快照,它们都会获得自己独立的程序状态副本。也就是说,当前所有快照状态对象在那个时间点的状态副本(实现 State 接口的对象)的副本。
这种方法使得状态修改是安全的,因为在一个快照中更新一个状态对象不会影响到其他快照中同一个状态对象的副本。快照之间是隔离的。在有多个线程的并发场景中,每个线程都将指向不同的快照,因此指向不同的状态副本。
Jetpack Compose runtime 提供了 Snapshot 类来模拟程序的当前状态。任何代码只需要调用它的静态方法:val snapshot = Snapshot.takeSnapshot()
即可获取到一个快照。这将获取所有状态对象当前值的快照,并且这些值将被保留,直到 snapshot.dispose()
方法被调用。这将决定快照的生命周期。
快照有其生命周期。每当我们使用完一个快照时,它都需要被处理掉。如果我们不调用 snapshot.dispose()
,我们将泄漏所有与该快照相关的资源及其保留状态。快照在创建和释放状态之间被视为处于活动状态。
当一个快照被创建时,它被赋予一个 ID,以便所有在该快照上的状态可以轻松地与其他潜在版本的相同状态区分开来。这允许为程序状态进行版本控制,或者换句话说,根据版本(多版本并发控制)使程序状态保持一致。
最好的理解快照是通过代码。我将直接从Zach Klipp的这篇非常值得学习且详细的帖子中提取一段代码来说明:
其中 Dog
类的 name
是一个 mutableStateOf("")
的实现。
这里函数 snapshot.enter
,通常称为“进入快照”,这会在快照的上下文中运行一个 lambda 表达式,因此快照成为任何状态的真实来源:从 lambda 表达式读取的所有状态将从快照中获取其值。这个机制允许 Compose 和任何其他客户端库在给定快照的上下文中运行任何处理状态的逻辑。这个过程在本地线程中进行,直到调用 enter
返回。其他任何线程都不会受到任何影响。
在上面的例子中,我们可以看到更新后的狗名为“Fido
”,但是如果我们从快照的上下文(enter
调用)读取它,它会返回“Spot
”,这是在快照被创建时它所拥有的值。
当然,在使用完快照后必须记得调用 snapshot.dispose()
来释放状态,下面是完整代码:
class Dog {
var name: MutableState<String> = mutableStateOf("")
}
fun main() {
val dog = Dog()
dog.name.value = "Spot"
val snapshot = Snapshot.takeSnapshot()
dog.name.value = "Fido"
println(dog.name.value) // ---> Fido
snapshot.enter { println(dog.name.value) } // 进入快照 ---> Spot
println(dog.name.value) // ---> Fido
// When finished with the snapshot, it must always be disposed.
snapshot.dispose()
}
请注意,在
enter
函数内部,可以根据快照的类型(只读 vs 可变)读取和写入状态。
通过 Snapshot.takeSnapshot()
创建的快照是 只读 的。它所包含的所有状态都不能被修改。如果我们试图写入快照中的任何状态对象,将会抛出异常。
但并非所有的操作都是读取状态,我们可能还需要更新它(写入)。Compose 提供了 Snapshot 契约的一个特定实现:MutableSnapshot,它允许修改它所持有的状态。除此之外,还有其他可用的实现。以下列举了 Snapshot 所有不同类型的实现。
让我们简要介绍一下不同类型的快照:
-
ReadonlySnapshot:快照中持有的状态对象是只读的,只能读取而不能修改。
-
MutableSnapshot:快照中持有的状态对象既可以读取也可以修改。
-
NestedReadonlySnapshot和NestedMutableSnapshot:用于 Child 的只读和可变快照,因为快照可以形成一棵树。一个快照可以有任意数量的嵌套快照。稍后会更详细地介绍。
-
GlobalSnapshot:持有全局共享程序状态的可变快照。它实际上是所有快照的 root 快照。
-
TransparentObserverMutableSnapshot:这是一个特殊情况。它不应用任何状态隔离,并且仅存在于在读取/写入状态对象时通知读取和写入观察者。它上面的所有状态记录都会自动标记为无效,因此它们不可被任何其他快照看到/读取。这种类型的快照的ID始终为其父级的ID,因此为它创建的任何记录实际上都与父级关联。在这种意义上,它是“透明的”,因为在其中执行的所有操作都像在父快照中执行一样。
Snapshot Tree (快照树)
正如我们上面解释的那样,快照形成了一棵树。因此,我们可以在不同的快照类型中找到 NestedReadonlySnapshot 和 NestedMutableSnapshot。任何快照都可以包含任意数量的嵌套快照。树的根是 GlobalSnapshot,保存全局状态。
嵌套快照类似于快照的独立副本,可以独立销毁/释放。这允许在保持父快照处于活动状态的同时销毁/释放它。它们在Compose中使用子组合(subcomposition)时经常出现。
简短回顾一下。我们之前提到过子组合是在父组合中创建的内联组合,其唯一目的是支持独立的失效。组合和子组合也形成了一棵树。
在创建延迟列表或 BoxWithConstraints 时,会创建嵌套快照的子组合。我们还可以在 SubcomposeLayout 或 VectorPainter 中找到子组合。
当需要进行子组合时,会创建一个嵌套的快照来存储和隔离其状态,因此在子组合消失时嵌套快照可以被销毁,同时保持父级组合和父级快照处于活动状态。对嵌套快照进行的任何更改,都将会传播到其父级。
所有的快照类型都提供了一个函数来获取一个嵌套快照并将其附加到父快照上,例如 Snapshot#takeNestedSnapshot()
或 MutableSnapshot#takeNestedMutableSnapshot()
。
一个 Child 只读快照可以从任何快照类型生成。而可变快照只能从另一个可变快照生成(或从全局快照生成,它也可被视为一个可变快照)。
快照和线程
将快照视为独立于任何线程范围之外的结构是非常重要的。线程确实可以有一个当前的快照,但是快照不一定与线程绑定。线程可以随意进入和离开快照,另一个线程可以进入子快照。实际上,快照的预期使用案例之一是并行工作。可以产生多个子线程,每个线程都有自己的快照。
一旦我们定义了可变的快照,我们也将学习子快照如何通知父快照其更改以保持一致性。所有线程的更改都将彼此隔离,并且不同线程的冲突更新将被检测和处理。嵌套快照允许这种工作分解是递归的。所有这些都可能解锁诸如并行组合之类的功能。
可以通过 Snapshot.current
获取当前线程的快照。如果有,则返回当前线程的快照;否则返回全局快照(保存全局状态)。
Compose运行时具有观察到写入的状态时触发重新组合的能力。了解这个机制与我们之前描述的状态快照系统是如何连接的将是有益的。让我们开始学习如何先观察读取。
观察读写
Compose runtime 在被观察的状态被写入时,有能力触发重组。
每当我们获取快照时(例如 Snapshot.takeSnapshot()
),我们得到的返回值是一个 ReadonlySnapshot
。由于这个快照中的状态对象不能被修改,只能被读取,快照中的所有状态都会被保留,直到它被销毁。takeSnapshot
函数的 lambda 允许我们传递一个 readObserver
(作为可选参数)观察者,每当在 enter
调用中从快照中读取任何状态对象时,都会通知该观察者。
snapshotFlow 函数可以作为使用 readObserver
一个例子:fun <T> snapshotFlow(block: () -> T): Flow<T>
。该函数将 State<T>
对象转换为 Flow
。当 Flow
被收集时,它会运行其 block
块并发出其中读取的 State
对象的结果。当其中一个 State
对象被修改时,Flow
会将新值发出给其收集器。为了实现这种行为,它需要记录所有状态读取,以便在这些状态对象中的任何一个发生更改时重新执行 block
块。为了跟踪这些读取,它采用一个只读快照并传递一个读取观察者,以便将它们存储在一个 Set
中:
// SnapshotFlow.kt
fun <T> snapshotFlow(block: () -> T): Flow<T> = flow {
val readSet = mutableSetOf<Any>()
val readObserver: (Any) -> Unit = { readSet.add(it) }
// ...
Snapshot.takeSnapshot(readObserver)
// ...
// Do something with the Set
}
只读快照不仅在读取某些状态时通知其readObserver
,还会通知其父级的readObserver
。嵌套快照上的读取必须对所有父级和其观察者可见,所以快照树上所有的观察者都会被通知。
现在让我们开始观察写操作。
观察写入也是可能的,因此只有在创建可变快照时才能传递 writeObserver
(状态更新)。可变快照是允许修改其持有的状态的快照。我们可以通过调用 Snapshot.takeMutableSnapshot()
来获取一个可变快照。在这里,我们可以传递可选的读和写观察器以便在任何读取或写入时得到通知。
观察读写的一个好例子就是 Recomposer,它能够跟踪 Composition 中的任何读写操作,以在需要时自动触发重组。
composing
函数在创建初始组合(Composition)和每次重组时都会调用。这个逻辑依赖于一个 MutableSnapshot
,它允许状态不仅可以被读取,还可以被写入,并且 enter
调用的 block
块中的任何读取或写入都被通知给组合(Composition)中。(换句话说,也就是可变状态的读写操作可以被组合追踪到)
这里作为参数传递的 block
代码块,实际上是运行组合或重组的代码,因此它执行树中的所有 Composable 函数来计算更改列表。又因为这些操作都是发生在 enter
函数内部,因此会自动跟踪任何读取或写入操作。
每当快照状态写入被追踪到组合中时,读取完全相同的快照状态的相应的 RecomposeScopes 将被无效化并触发重组。
在组合结束时,applyAndCheck(snapshot)
调用会将组合期间发生的任何更改传播到其他快照和全局状态。
下面是观察者在代码中的样子,它们是简单的函数:
private fun readObserverOf(composition: ControlledComposition): (Any) -> Unit {
return { value -> composition.recordReadOf(value) }
}
private fun writeObserverOf(composition: ControlledComposition, modifiedValues: IdentityArraySet<Any>?): (Any) -> Unit {
return { value ->
composition.recordWriteOf(value)
modifiedValues?.add(value)
}
}
有一些比较实用的函数可以用来在当前线程中观察读取和写入。这就是 Snapshot.observe(readObserver, writeObserver, block)
函数。例如,derivedStateOf
函数就使用它来响应提供的块中的所有对象读取。Snapshot.observe()
是唯一使用 TransparentObserverMutableSnapshot 的地方。创建此类型的父(根)快照的唯一目的是向观察者通知读取,如前面所述。Comose团队添加这种类型,是为了避免在一些特殊情况下快照中产生一个回调列表。
MutableSnapshot
MutableSnapshot 是在处理可变快照状态时使用的快照类型,在这种情况下,我们需要跟踪写入以自动触发重组。
在可变快照中,任何状态对象都将拥有快照在被拍摄时的相同的值,除非你在快照中本地修改了状态对象。在 MutableSnapshot 中进行的所有更改都与其他快照所做的更改隔离。更改从树的底部向上传播。子嵌套的可变快照需要先应用其更改,然后将其传播到父级或全局快照(如果它是树的根)。这是通过调用 NestedMutableSnapshot#apply
来完成的。(或者 MutableSnapshot#apply
,如果是非嵌套的话)
以下段落直接摘自Jetpack Compose runtime 的 kdocs:
Composition uses mutable snapshots to allow changes made in Composable functions to be temporarily isolated from the global state and is later applied to the global state when the composition is applied. If MutableSnapshot.apply fails applying this snapshot, the snapshot and the changes calculated during composition are disposed and a new composition is scheduled to be calculated again.
(翻译:组合使用可变快照,以便在 Composable 函数中进行的更改在一段时间内与全局状态隔离,并在应用组合时稍后应用于全局状态。如果 MutableSnapshot.apply 无法应用此快照,则该快照和组合期间计算的更改将被丢弃,并计划重新计算新的组合。)
因此,在应用组合(回顾一下:我们应用变更是在组合的最后一步通过Applier来完成的)时,将应用可变快照中的任何更改并将其通知其父级或最终全局快照。如果应用这些更改时出现错误,则会安排新的组合。
可变快照也有生命周期。它始终通过调用 apply
和 dispose
来结束。这既是将状态修改传播到其他快照的必要条件,也是为了避免泄漏。
通过 apply
传播的更改应用是原子性的(atomically),这意味着全局状态或父快照(如果其嵌套)会将所有这些更改视为单个原子更改。这将会清理一下状态更改的历史记录,以便更容易识别、重现、中止或还原。这就是我们前面在并发控制系统部分所描述的事务内存的作用。
如果可变快照被丢弃但从未应用,则其所有未处理的状态更改都将被丢弃。
这里有一个实际的例子,展示了在客户端代码中如何使用 apply
:
当我们从 enter
调用内部打印时,值为 “Another street
”,因此修改是可见的。这是因为我们在快照的上下文中运行。但是,如果我们在 enter
调用之后立即打印(在外部),则该值似乎已恢复为原始值。这是因为 MutableSnapshot 中的更改与任何其他快照隔离。调用 apply
之后,更改会传播,然后我们最终可以看到再次打印 streetname
时输出的是修改后的值。
注意,只有在 enter
调用中完成的状态更新才会被跟踪和传播。
还存在另一种简化版本的语法:Snapshot.withMutableSnapshot
,它将隐式的确保 apply
会在最后被调用。
最后调用
apply
的方式可能会让我们想起 Composer 是如何记录和应用更改列表的。这又是同样的概念。每当我们需要理解树中的变更列表时,就需要记录/延迟这些变更,这样我们就可以以正确的顺序应用(触发)它们,并在那一刻强制一致性。这是程序知道所有更改的唯一时机,或者换句话说,这是它拥有一个全局视角的时刻。
也可以注册应用观察器来观察最终的修改更改。这可以通过调用 Snapshot.registerApplyObserver
来实现。
GlobalSnapshot 和 Nested Snapshot
GlobalSnapshot 是一种可变快照,恰好保存全局状态。它将按照上面描述的从下到上的顺序从其他快照获取更新。
GlobalSnapshot 不能嵌套。因为只存在一个 GlobalSnapshot,它实际上是所有快照的最终根。它保存当前的全局(共享)状态。因此,不能应用全局快照(它没有apply
调用)。
要在全局快照中应用更改,它必须是 “advanced” 的。这是通过调用 Snapshot.advanceGlobalSnapshot()
来完成的,它清除前一个全局快照并创建一个新快照,该快照接受前一个全局快照的所有有效状态。在这种情况下,Apply 观察者也会得到通知,因为即使机制不同,这些更改也会被有效地“应用”。同样,也不可能对其调用 dispose()
。销毁全局快照也可以通过“advanced” 的方式完成。
在 Jetpack Compose 中,全局快照是在快照系统初始化期间创建的。在 JVM 中,当 SnapshotKt.class
被 Java 或 Android runtime 初始化时就会发生这种情况。
在此之后,在创建 Composer 时会启动一个全局快照管理器,然后每个组合(包括初始组合和任何进一步的重组)创建自己的嵌套可变快照并将其附加到树中,因此它可以存储和隔离组合的所有状态。Composition 也将利用这个机会注册读写观察者来跟踪对 Composition 的读写。还记得前面介绍的 composing
函数吗:
最后,任何子组合都可以创建自己的嵌套快照并将其附加到树中,以在保持父元素活动的同时支持失效。这将为我们提供快照树的完整蓝图。
另一个有趣的细节是,当 Composer 被创建后,在创建 Composition 时,会调用 GlobalSnapshotManager.ensureStarted()
。这是与平台集成的一部分 (Compose UI),它将开始观察所有对全局状态的写入,并在 AndroidUiDispatcher.Main 上下文中定时调度快照应用通知。
StateObject 和 StateRecord
多版本并发控制确保每次写入状态时,都会创建一个新版本(写时复制)。Jetpack 组合状态快照系统遵循这一点,因此最终可能存储同一个快照状态对象的多个版本。
这种设计对性能有三个方面的重要意义。
- 首先,创建快照的成本是 O(1) 复杂度,而不是 O(N) (其中N是状态对象的数量)。
- 其次,提交快照的成本是 O(N) 复杂度,其中 N 是快照中发生突变的对象的数量。
- 第三,快照本身不会持有快照数据的列表(只有修改对象的临时列表),因此状态对象可以自由地被垃圾收集器(GC)收集,而不需要通知快照系统。
在内部,快照状态对象被建模为一个 StateObject,在多版本中,为该对象存储的每一个版本的存储形式的都是一个 StateRecord。每条记录都保存状态的单个版本的数据。每个快照所看到的版本(记录)对应于拍摄快照时可用的最新有效版本。(快照 ID 最高的有效快照)
但是怎样才能使状态记录有效呢?
“有效” 在特定的快照下才有意义。记录与创建记录的快照 ID 相关联。一个快照的状态记录在满足如下条件时是有效的:如果状态记录的 ID 小于等于快照 ID (即在当前或上一个快照中创建的),并且不属于快照的invalid
集合,也没有被明确标记为invalid
。前一个快照中的任何有效记录都会自动复制到新快照中。
这就引出了一个问题:什么会使记录成为所提到的无效集合的一部分或显式标记为无效呢?
- 在当前快照之后创建的记录被认为是无效的,因为它们是为在此快照之后创建的快照而创建的。
- 当前快照创建时,如果为快照创建的记录已打开,则记录被添加到无效集合中,所以它们也被视为无效。
- 在应用之前被销毁的快照中创建的记录被明确标记为无效。
一个无效的记录对任何快照都不可见,因此它无法被读取。当从 Composable 函数中读取快照状态时,该记录不会被考虑在内,而会返回其最新的有效状态。
回到状态对象。下面是在状态快照系统中它们的建模方式的简要示例。
任何通过任何方式创建的可变快照状态对象都将实现此接口。例如,由 mutableStateOf
、mutableStateListOf
或 derivedStateOf
运行时函数返回的状态等。
让我们来看一下 mutableStateOf(value)
函数。
这个调用返回一个 SnapshotMutableState 的实例,它本质上是一个可观察的可变状态,换句话说,它是一个可以更新的状态,并会自动通知观察者的状态。这个类是一个 StateObject,因此它维护一个记录的链接列表,用来存储状态的不同版本(在这个例子中是value
)。每次读取状态时,将遍历记录列表,以找到并返回最新的有效记录。
如果我们回顾一下 StateObject 的定义,我们可以看到它有一个指针指向记录链表的第一个元素,每个记录都指向下一个。它还允许在列表中预先添加一个新记录(使其成为新 firstStateRecord
)。
StateObject 定义中的另一个函数是 mergeRecords
。我们之前提到过系统在可能的情况下可以自动合并冲突。这就是这个函数的作用。合并策略很简单,稍后将详细介绍。
让我们稍微了解一下 StateRecord
这里我们可以看到每个记录都关联了一个快照 ID。这个 ID 是属于创建该记录的那个快照的 ID 。这将确定该记录是否对于遵循上述要求的给定快照是有效的。
我们说过每当一个对象被读取时,会遍历给定快照状态(StateObject)的 StateRecords 列表,查找最新的有效记录(具有最高的快照 ID)。同样地,当快照被创建时,每个快照状态对象的最新有效状态都会被捕获,并且这将是新快照的整个生命周期中使用的状态(除非它是可变快照且状态在本地被修改)。
StateRecord 还有一个 assign 函数,它从另一个StateRecord对象给其赋值并创建它。
StateRecord 也是一个契约(接口)。每种现有 StateObject 类型定义了不同的实现,因为记录存储了每种类型的 StateObject 的相关信息,这些信息对于每个类型(每个使用情况)都不同。
跟随 mutableStateOf
的例子,我们知道它返回的是一个 SnapshotMutableState
,它是一个StateObject。它将维护一个非常特定类型的记录链表:StateStateRecord
。这个记录只是一个包装在 T 类型值上的包装器,因为在这种情况下,这就是我们需要在每个记录中存储的所有信息。
另一个好的例子可以是 mutableStateListOf
。它创建了一个 SnapshotStateList
,这是 StateObject 的另一个实现。该状态模拟了一个可观察的可变列表(实现了Kotlin集合的MutableList
契约),因此其记录将具有由自身定义的 StateListStateRecord
类型。此记录使用一个PersistentList
(参见Kotlin不可变集合)来保存状态列表的一个版本。
读写状态
换句话说,也就是读写状态记录。“当读取一个对象时,将遍历给定快照状态(StateObject)的StateRecords列表,查找最近的有效记录(具有最高的快照ID)”。让我们看看这在代码中是如何实现的。
这是来自 compose.material
库的 TextField Composable 组件。它会记住一个可变状态用于保存文本值,所以每次值更新时,该 Composable 都会重组以在屏幕上显示新的字符。
我们暂时不考虑 remember
的调用,因为它不是我们在此讨论的重点。这里使用的是 mutableStateOf
函数,用于创建快照状态:
这最终创建了一个 SnapshotMutableState 状态对象,该对象获取了 value: T
和 SnapshotMutationPolicy<T>
作为参数。它将包装该值(存储在内存中),并在需要更新该值时使用冲突策略来检查所传递的 新值 是否与 当前值 不同。以下是该类中 value
属性的定义:
每当我们使用 getter
访问 TextField
Composable 内部值时(例如 textFieldValueState.value
),它将通过下一个状态记录(链表中的第一个记录)的引用 next
来调用 readable
方法开始迭代。readable
函数通过迭代查找当前(最新的)有效的可读状态,同时通知任何已注册的读取观察者。对于每个新迭代项,它将按照先前部分中定义的有效条件进行检查。当前快照将是当前线程的快照或者全局快照(如果当前线程未关联到任何快照的话)。
/**
* Return the current readable state record for the current snapshot.
* It is assumed that [this] is the first record of [state]
*/
fun <T : StateRecord> T.readable(state: StateObject): T {
val snapshot = Snapshot.current
snapshot.readObserver?.invoke(state)
return readable(this, snapshot.id, snapshot.invalid) ?: sync {
val syncSnapshot = Snapshot.current
readable(this, syncSnapshot.id, syncSnapshot.invalid)
} ?: readError()
}
这就是 mutableStateOf
的快照状态是如何读取的。对于其他可用的可变快照状态实现(例如由mutableStateListOf
返回的实现),情况也是类似的。
当我们想要更新状态时,我们可以使用该状态的 setter
方法来实现。下面是示例代码:
withCurrent
函数在底层调用了 readable
函数,以便运行提供的代码块并将当前最新的可读状态记录作为参数传递给它。
接下来,它会使用提供的 SnapshotMutationPolicy
检查新值是否等效于当前值。如果它们不相等,就会开始写入过程。这项工作是由 overwritable
函数完成的。
这里我有意地不深入讲解实现细节,因为它们在未来可能会发生变化。但是,我会简要地解释一下:它使用可写状态记录运行block块,并提出一个候选记录,该候选记录将是当前最新的有效记录。如果它对于当前快照有效,就使用它来进行写入,否则它将创建一个新的记录并将其添加到列表的开头,使其成为新的初始记录。该block块对其进行实际修改。
最后,它会通知任何已注册的写观察器者。
删除或复用废弃的记录
通过多版本并发控制,我们可以存储同一状态的多个版本(记录),但这引入了一个有趣的挑战:删除已过时且永远不会被读取的版本。我们将在一会儿解释 Compose 如何解决这个问题,但让我们先介绍 “打开快照”(open snapshots) 的概念。
任何新的快照都会被添加到一个打开(open)的快照集合中,直到主动关闭它。在快照保持打开状态时,它的所有状态记录都被认为对于其他快照无效(不可读取)。关闭快照意味着所有它的记录自动变为有效(可读取)以供创建任何新的快照使用。
一旦我们了解了这一点,让我们来了解一下 Compose 如何回收过时的记录:
- 它跟踪最低的打开快照。Compose 跟踪一组打开的快照 ID。这些 ID 是单调递增的,不断增加。
- 如果一条记录是有效的但在最低的打开快照中不可见,则可以安全地重用它,因为它永远不会被任何其他快照选择。
复用被覆盖的记录通常会导致可变状态对象中只有 1 或 2 个记录,这会显著提高性能。随着快照的应用,被覆盖的记录将被下一个快照复用。如果一个快照在应用之前被销毁,则所有记录都被标记为无效(丢弃),这意味着它们可以立即被复用。
变更传播
在解释可变快照中的更改是如何传播之前,回顾一下“closing”和“advancing”快照的含义可能很有用,以便我们理解这两个术语。
关闭快照实际上就是将其 ID 从打开的快照 ID 集合中删除,其结果是所有与该 ID 相关联的状态记录(记录)将变为可见/可读,以便由创建的任何新快照读取。这使得关闭快照成为传播状态更改的有效方法。
关闭快照时,很多时候我们希望立即创建一个新的快照来代替它。这就是所谓的 “advancing”。新创建的快照会获得一个新的 ID,该 ID 通过递增前一个 ID 生成。然后将此 ID 添加到打开的快照 ID 集合中。
正如我们所学到的,全局快照永远不会应用,而总是在前进,这样可以使其所有更改对新创建的全局快照可见。可变快照也可以在其嵌套快照应用更改时前进。
现在我们已经很好地理解了这一点,我们已经准备好学习可变快照中的更改是如何传播的。
在可变快照上调用snapshot.apply()
时,对其范围内的状态对象所做的所有局部更改都将传播到父快照(如果是嵌套可变快照)或全局状态。
调用
apply
或dispose
将划定快照的生命周期。应用的可变快照也可以在之后被释放。但是,在dispose
之后调用apply
将抛出异常,因为这些更改已经被丢弃。
根据我们所描述的,要传播所有本地更改(对所拍摄的新快照可见),只需从活动快照集中删除快照就足够了。无论何时创建快照,当前打开的快照的副本都会作为无效快照集传入(也就是说,任何尚未应用的快照都不应该对新快照可见)。只需从打开的快照集中删除快照 id
,就足以让每个新快照将在此快照期间创建的recrods
视为有效的,因此,当读取它们对应的状态对象时,就可以返回它们。
但是只有在确定没有状态冲突(碰撞写入)之后才应该这样做,因为需要首先解决这些问题。
当应用快照时,应用快照所做的更改将与其他快照的更改一起添加。状态对象有一个记录的链表,所有的更改都聚合在其中。这为写入冲突打开了大门,因为多个快照可能会尝试对相同的状态对象应用更改。当一个可变快照想要应用(通知/传播)它的本地更改时,它会尝试检测潜在的写冲突并尽可能合并这些冲突。
这里我们有两个场景:
没有挂起的本地更改
如果快照中没有挂起的本地更改:
- 可变快照被主动关闭(将其从打开的快照
id
集中移除,使其所有状态记录对新快照自动可见/可读)。 - 全局快照是“advanced”的(与关闭相同,但也将被创建的新的全局快照所取代)。
- 利用这个机会检查全局快照中是否有任何状态更改,这样可变快照就可以将这些更改通知给任何潜在的应用程序观察者。
有挂起的本地更改
当有挂起的更改时:
- 使用乐观方法检测冲突并计算合并的记录(记住并发控制类别)。碰撞将尝试自动合并,否则将被丢弃。
- 对于每个挂起的本地更改,它都会检查它是否与当前值不同。如果不是,则忽略更改并保持当前值。
- 如果是实际的更改(不同),则检查已经计算的乐观合并,以决定是保留以前的、当前的还是应用的记录。它实际上可以创建所有这些的合并。
- 如果它必须执行记录的合并,它将创建一个新记录(不可变性)并将快照
id
分配给它(将其与可变快照关联),然后将其前置到记录的链表中,使其有效地成为列表中的第一个记录。
如果在应用更改时出现任何失败,它将回退到没有挂起的本地更改时所做的相同流程。这是关闭可变快照以使其记录对任何新快照可见,推进全局快照(关闭并用一个新快照替换它),因此它包括刚刚关闭的可变快照中的所有更改,并通知任何apply观察者检测到的任何全局状态更改。
对于嵌套的可变快照,过程略有不同,因为它们不会将更改传播到全局快照,而是传播到它们的父快照。出于这个原因,它们将其所有已修改的状态对象添加到父对象的已修改集。由于所有这些更改都需要由父快照可见,因此嵌套可变快照将自己的id
从无效快照的父快照集中删除。
合并写冲突
为了进行合并,可变快照迭代它的修改状态列表(本地更改),对于每一个更改,它执行以下操作:
- 获取父快照或全局状态中的当前值(状态记录)。
- 在应用更改之前获取先前的值。
- 获取应用更改后对象的状态。
- 尝试自动合并他们三个。这被委托到状态对象中,状态对象依赖于提供的合并策略(参见前文的 StateObject 定义)。
事实是 runtime 中没有一个可用的策略支持正确的合并,因此碰撞更新将导致 runtime 异常并将该问题通知用户。为了避免陷入这种情况,Compose 通过使用唯一 key
访问状态对象(可组合函数中记住的状态对象通常具有唯一访问属性)来保证不可能发生冲突。给定 mutableStateOf
使用StructuralEqualityPolicy
进行合并,它通过等号(==)深度比较对象的两个版本,因此所有的属性都会被比较,包括唯一的对象 key
,使得两个对象不可能碰撞。
自动合并冲突的更改是一个潜在的优化,Compose 还没有使用,但其他库可以使用。
可以通过实现 SnapshotMutationPolicy 这个接口提供一个自定义的冲突策略。从 Compose 文档中找到的一个可以作为策略参考的例子是,将MutableState<Int>
作为一个计数器。该策略假设将状态值更改为相同的不会被视为更改,因此使用 counterPolicy
对可变状态的任何更改永远不会导致应用冲突。
当两个值相同时,它们被认为是等效的,因此将保留当前值。请注意合并是如何获得的,将新应用的值与前一个值之间的差加到当前值之上,因此当前值总是反映存储的总量。
这一段是摘自官方文档的解释:As the name of the policy implies, it can be useful when counting things, such as tracking the amount of a resource consumed or produced while in a snapshot. For example, if snapshot A produces 10 things and snapshot B produces 20 things, the result of applying both A and B should be that 30 things were produced. (正如策略的名称所暗示的那样,它在计数时非常有用,例如跟踪快照中消耗或产生的资源的数量。例如,如果快照A产生了10个产物,快照B产生了20个产物,那么同时应用A和B的结果应该是产生了30个产物。)
我们有一个使用计数器策略进行比较的单一可变状态,以及两个尝试修改它并应用更改的快照。这将是碰撞的完美场景,但考虑到我们的 counter 策略,任何碰撞都是完全避免的。
这只是如何提供自定义 SnapshotMutationPolicy
以避免冲突的一个简单示例,因此我们可以了解要点。另一个不可能发生冲突的实现可能是只能添加元素而不能删除元素的集合。其他有用的类型(如rope)也可以类似地转换为无冲突的数据类型,前提是它们的工作方式和预期结果受到一定的限制。
我们还可以提供自定义策略,接受冲突,但通过使用merge
函数合并数据来解决冲突。
总结
-
Snapshot state 的思想是状态隔离、快照隔离。
-
基于 MVCC(多版本并发控制) 实现:
- 保留数据的多个副本/快照,每个线程在给定时刻使用一个隔离的本地副本来工作。
- 每个线程都将指向不同的快照,因此指向不同的状态副本。
- 每当写入数据时,都会创建数据的新副本,而不是修改原始数据。
- 内存中存储了相同数据的多个版本,或历史记录,Compose中叫状态记录 StateRecord
- 每个快照都被分配一个 ID,即作为事务 ID,快照 ID 是单调递增的。
- 读取和写入根据快照 ID 区分,相互隔离,无需进行锁定。
-
快照生命周期:
- 在调用
Snapshot.takeSnapshot()
时被创建,在调用snapshot.dispose()
时被销毁。快照在创建和释放状态之间被视为处于活动状态。 - 快照不使用时,应该被销毁,否则可能泄漏相关资源。
- 在调用
-
snapshot.enter
:通常称为“进入快照”,这会在快照的上下文中运行一个 lambda 表达式,一旦进入这个 lambda 中,在其范围内对状态的读取和写入都是隔离的,基于当前快照。 它允许在本地线程,与其他线程隔离。 -
常见类型的快照:
-
ReadonlySnapshot
:只读快照 -
MutableSnapshot
:可读可写 -
NestedReadonlySnapshot
和NestedMutableSnapshot
:嵌套快照。用于快照树中的 Child 的只读和可变快照。可以在保持父快照处于活动状态的同时独立销毁/释放。进行子组合时,会创建一个嵌套的快照。如SubcomposeLayout
中。 -
GlobalSnapshot
:全局共享状态的可变快照。所有快照的 root 快照。它不能嵌套,全局只有一个。
-
-
Snapshot Tree :快照可以形成一棵树,树的根是
GlobalSnapshot
。 -
快照和线程相互独立:
- 线程可以有一个当前的快照,但是快照不一定与线程绑定。线程可以随意进入和离开一个快照。
Snapshot.current
获取当前线程的快照。它返回当前线程的快照,或者全局快照。
-
快照的读写监听:
- 如
Snapshot.takeSnapshot(readObserver)
可以为只读快照设置一个观察者。每当在snapshot.enter
调用中从快照中读取任何状态对象时,都会通知该观察者。 - 可变快照可以同时设置
readObserver
和writeObserver
观察读取和写入操作。 derivedStateOf
函数的内部就是使用Snapshot.observe(readObserver, writeObserver, block)
来在当前线程中观察读取和写入。
- 如
-
Recomposer
跟踪Composition
中的任何读写操作,自动触发重组。这是通过向可变快照注册读写观察者实现的。Snapshot.takeMutableSnapshot(readObserver, writeObserver)
在Recomposer
进行初始组合和每次重组时都会调用它。- 它在
snapshot.enter(block)
中运行组合或重组的block
代码,因此可以被监听到。 - 每当快照状态写入被
Recomposer
追踪/观察到时,读取相同的快照状态的相应的RecomposeScopes
将被无效化并触发重组。
-
快照更改的应用:
-
snapshot.apply()
可以应用快照更改(针对可变快照),它是原子性的操作。 -
调用
apply()
之后,对快照的修改会传播到其他快照。对其范围内的状态对象所做的所有局部更改都将传播到父快照(如果是嵌套可变快照)或全局状态。 -
Snapshot.withMutableSnapshot{}
是会隐式调用apply()
的简化版本。
-
-
全局快照:
- 全局快照的创建是在
SnapshotKt.class
类被JVM
初始化时。 - 创建
Composer
时会启动一个全局快照管理器,在创建Composition
时,会调用GlobalSnapshotManager.ensureStarted()
将开始观察所有对全局状态的写入。 - 每个组合创建自己的嵌套可变快照并将其附加到快照树中,而全局快照是这个树的根,因此全局快照管理器可以存储和隔离组合的所有状态。
- 全局快照的创建是在
-
快照状态的内部表示:
- MVCC 确保每次写入状态时,都会创建一个新版本(写时复制)。
- 快照状态对象的内部实现是一个
StateObject
,在多版本中,为该对象存储的每一个版本的存储形式的都是一个StateRecord
。 - 每个记录都保存状态的一个版本的信息,每个记录与创建记录的快照
ID
相关联,快照ID
是递增的,这样每个版本的记录形成一个按ID
递增的单链表。 - 每个快照所看到的版本(记录)对应于拍摄快照时可用的最新有效版本。
- 最新有效版本是指处于该快照之前创建的记录且该记录不是
invalid
也没有被加入invalid
列表中。
-
状态读写:
- 当读取一个对象时,将遍历给定快照状态(
StateObject
)的StateRecords
列表,查找最近的有效记录(具有最高的快照ID
) - 写入时使用当前最新的有效记录作为候选记录。如果它对于当前快照有效,就使用它来进行写入,否则它将创建一个新的记录并将其添加到列表的开头,使其成为新的初始记录。
- 读写完毕后都会通知任何已注册的读观察者和写观察器者。
- 当读取一个对象时,将遍历给定快照状态(