Continue to dig deeper, Jetpack Compose's State snapshot system

Jetpack Compose has a special way of representing state and propagating state changes that drives the ultimate responsive experience: the State snapshot system. This reactive model makes our code more powerful and cleaner, as it allows components to automatically restructure based on their input, and only when necessary, avoiding all the boilerplate we used to need to manually notify these changes in the Android View system document.

What is Snapshot State

Snapshot state refers to an isolated state that can be recorded and observed for changes. When we call functions like mutableStateOf, mutableStateListOf, mutableStateMapOf, derivedStateOf, produceState, collectAsState, etc., the state we get is the snapshot state. All of these functions return some type of state, which developers often refer to as snapshot state.

The term " Snapshot state " is named because it is part of the state snapshot system defined by the Jetpack Compose runtime. This system models and coordinates state changes and change propagation. It's written in a decoupled manner, so it can theoretically be used by other libraries that want to depend on observable state.

One of the things we learned earlier about change propagation is that all Composable declarations and expressions are wrapped by the Jetpack Compose compiler to automatically track any snapshot state reads within their body. This is how the snapshot state is (automatically) observed. The goal is that whenever the state read by the Composable changes, the runtime invalidates its RecomposeScope so that it will be executed again on the next recompose.

This is infrastructure code provided by Compose, so it doesn't need to exist in any client code base. Clients of the runtime, such as Compose UI, don't need to know at all how invalidation and state propagates, or how to trigger reorganization, but only need to focus on providing the building blocks that work with this state: Composable functions.

But snapshotting state is not just a matter of automatically notifying changes to trigger a reorganization. One very important reason for using the word "snapshot": state isolation. This represents the isolation level we are applying in the concurrency context.

Imagine handling mutable state between different threads. This can easily become a mess. Strict coordination and synchronization are required to ensure the integrity of the state, as it can be read or modified from different threads at the same time. This opens the door to conflicts, hard-to-detect bugs, and race conditions.

Traditionally, programming languages ​​have approached this problem in different ways, one of which is immutability. Immutable data is never modified after it is created, which makes it absolutely safe from concurrent scenarios. Another effective approach can be the actor system. The system focuses on state isolation across threads. Actors keep their own copy of state and communicate/coordinate via messages. If this state is mutable, some coordination needs to exist to make the global program state consistent. The Compose snapshot system is not based on the actor system, but is actually closer to that approach.

Jetpack Compose uses mutable state, so Composable functions can automatically respond to state updates. A library that only uses immutable state doesn't make sense. This means it needs to solve the problem of sharing state in concurrent scenarios, since composition can be implemented in multiple threads. Compose's solution to this problem is a state snapshot system, based on state isolation and subsequent change propagation so that mutable state can be safely used across multiple threads.

A snapshot state system is modeled using a concurrency control system because it needs to coordinate state across threads in a safe manner. Sharing mutable state in a concurrent environment is not easy, and this is a generic problem that has nothing to do with the actual use case of the library.

State in Jetpack Compose is an interface that any snapshot state object will implement. The following is the code form of the State interface:

This protocol is marked @Stable because Jetpack Compose only provides and uses stable implementations (for design reasons), which in a nutshell means that any implementation of this interface must ensure that:

  • Calling the equals method on the same two State instances always returns the same result.

  • The composition is notified when the value of a type's exposed property changes.

  • All of its public property value types are also stable.

Next, first understand some knowledge about concurrency control systems. This will help us understand more easily why the Jetpack Compose state snapshot system adopts this model.

concurrency control system

The state snapshot system is implemented as a concurrency control system, so let's introduce that concept first.

In computer science, "concurrency control" is about a method of ensuring correct results of concurrent operations, which means coordination and synchronization. Concurrency control consists of a series of rules that ensure the correctness of the entire system. However, coordination always comes with a price. Coordination often impacts performance, so the key challenge is to devise a method that is as efficient as possible without significantly degrading performance.

An example of concurrency control is a transaction system in a database management system (DBMS). Concurrency control in this context ensures that any database transactions executed in a concurrent environment are performed in a safe manner without violating the data integrity of the database. The goal is to maintain correctness. "Safe" here includes ensuring that transactions are atomic, can be undone safely, that the effects of committed transactions are never lost, and that the effects of aborted transactions are not left in the database. This is a complex issue.

Concurrency control not only occurs frequently in DBMSs, but also in programming languages, for example for implementing transactional memory. Transactional memory attempts to simplify concurrent programming by allowing a set of load and store operations to be performed atomically. In fact, in the Compose state snapshot system, state writes are applied as a single atomic operation when state changes are propagated from one snapshot to other snapshots. Grouping operations like this simplifies coordination between concurrent reads and writes of shared data in parallel systems/processes. On this basis, atomic changes can be easily aborted, undone, or replayed. That is: having a reproducible change history to possibly regenerate any version of the program state.

There are different classes of concurrency control systems:

  • Optimistic : does not block any read or write operations, and is optimistic about the safety of these operations, and if committing would violate the required rules, abort the transaction to prevent the violation. Aborted transactions are immediately re-executed, which implies overhead. This strategy can be a good choice when the average number of aborted transactions is not too high.

  • Pessimistic : If an operation violates a rule, block the operation in the transaction until the possibility of the violation disappears.

  • Semi-optimistic : This is a hybrid solution of the other two. Only block operations in some cases, and be optimistic (and then abort on commit) in others.

Performance for each category varies based on factors such as the average transaction completion rate (throughput), the level of parallelism required, and other factors such as the likelihood of deadlocks. Non-optimistic

Multiversion Concurrency Control (MVCC)

Global state in Jetpack Compose is shared across compositions and threads. Composite functions should be able to run in parallel (parallel reorganization is possible at any time), and if they execute in parallel, the snapshot state can be read or modified concurrently, thus requiring state isolation.

One of the main properties of concurrency control is actually isolation. This feature ensures correctness in the case of concurrent access to data. The easiest way to achieve isolation is to block all readers until writers complete, but this can have a huge impact on performance. MVCC (Multiversion concurrency control) can do better.

To achieve isolation, MVCC keeps multiple copies (snapshots) of data, so each thread can work with an isolated snapshot of state at a given moment. We can understand them as different versions of the state ("multi-versions"). Modifications made by a thread are invisible to other threads until all local changes have been made and propagated.

In concurrency control systems, this technique is called "snapshot isolation", and it is defined as the isolation level used to determine which version each "transaction" sees.

MVCC also takes advantage of immutability, so whenever data is written, a new copy of the data is created instead of modifying the original. This results in multiple versions of the same data being stored in memory, as does all of the object's change history. In Compose, these are called " state records ".

Another feature of MVCC is that it creates a point-in-time consistent view . This is usually a property of backup files, and it means that references to all objects on a given backup remain consistent. In MVCC, this is usually ensured by the transaction ID, so any read operation can refer to the corresponding ID to determine which version of state to use. This is actually how things work in Jetpack Compose. Each snapshot is assigned its own ID. Snapshot IDs are monotonically increasing values, so they are naturally sorted. Since snapshots are distinguished by their IDs, reads and writes are isolated without locking.

Snapshot

A snapshot can be created at any time. It reflects the current state of the program (all snapshot state objects) at a given moment (when the snapshot was created). Multiple snapshots can be created, each of which gets its own independent copy of the program state. That is, copies of the state copies (objects that implement the State interface) of all current snapshot state objects at that point in time.

This approach makes state modification safe, since updating a state object in one snapshot does not affect copies of the same state object in other snapshots. Snapshots are isolated from each other. In a concurrent scenario with multiple threads, each thread will point to a different snapshot and thus a different copy of the state.

The Jetpack Compose runtime provides the Snapshot class to simulate the current state of the program. Any code only needs to call its static method: val snapshot = Snapshot.takeSnapshot() to get a snapshot. This will take a snapshot of the current values ​​of all state objects, and these values ​​will be preserved until the snapshot.dispose() method is called. This will determine the lifetime of the snapshot.

Snapshots have their lifecycle. Whenever we are done using a snapshot, it needs to be disposed of. If we don't call snapshot.dispose(), we will leak all resources related to that snapshot and its retained state. A snapshot is considered active between the create and release states.

When a snapshot is created, it is given an ID so that all state on that snapshot can be easily distinguished from other underlying versions of the same state. This allows for versioning of the program state, or in other words, keeping the program state consistent according to version (multi-version concurrency control).

Snapshots are best understood through code. I'm going to take a snippet of code directly from this very informative and detailed post by Zach Klipp to illustrate:

The name of the Dog class is an implementation of mutableStateOf(“”).

Here the function snapshot.enter, commonly called "enter snapshot", this runs a lambda expression in the context of the snapshot, so the snapshot becomes the source of truth for any state: all state read from the lambda expression will be taken from the snapshot its value. This mechanism allows Compose and any other client library to run any state-processing logic in the context of a given snapshot. This process takes place in the local thread until the call to enter returns. No other threads will be affected in any way.

In the example above, we can see that the updated dog is named "Fido", but if we read it from the context of the snapshot (the enter call), it returns "Spot", which is what it was when the snapshot was created owned value.

Of course, after using the snapshot, you must remember to call snapshot.dispose() to release the state. The following is the complete code:

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()
}

Note that inside the enter function, state can be read and written depending on the type of snapshot (read-only vs mutable).

Snapshots created via Snapshot.takeSnapshot() are read-only. All state it contains cannot be modified. An exception will be thrown if we try to write to any state object in the snapshot.

But not all operations are read state, we may also need to update it (write). Compose provides a specific implementation of the Snapshot contract: MutableSnapshot, which allows modification of the state it holds. Besides that, there are other implementations available. The following lists all the different types of Snapshot implementations.

Let's briefly describe the different types of snapshots:

  • ReadonlySnapshot : The state object held in the snapshot is read-only and can only be read but not modified.

  • MutableSnapshot : The state objects held in snapshots can be both read and modified.

  • NestedReadonlySnapshot and NestedMutableSnapshot : for read-only and mutable snapshots of Child, since snapshots can form a tree. A snapshot can have any number of nested snapshots. More on that later.

  • GlobalSnapshot : holds a mutable snapshot of the global shared program state. It is actually the root snapshot for all snapshots.

  • TransparentObserverMutableSnapshot : This is a special case. It does not apply any state isolation, and exists only to notify read and write observers when a state object is read/written. All state records on it are automatically marked as invalid, so they cannot be seen/read by any other snapshot. The ID of this type of snapshot is always that of its parent, so any records created for it are actually associated with the parent. It's "transparent" in the sense that everything performed in it is performed as if it were performed in the parent snapshot.

Snapshot Tree

As we explained above, snapshots form a tree. So we can find NestedReadonlySnapshot and NestedMutableSnapshot among different snapshot types. Any snapshot can contain any number of nested snapshots. The root of the tree is GlobalSnapshot, which holds the global state.

Nested snapshots are like independent copies of snapshots that can be destroyed/released independently. This allows the parent snapshot to be destroyed/released while keeping it alive. They often appear when using subcomposition in Compose.

A brief recap. We mentioned earlier that subcompositions are inline compositions created within a parent composition whose sole purpose is to support independent invalidation. Compositions and subcompositions also form a tree.

When creating deferred lists or BoxWithConstraints, a subcomposition of nested snapshots is created. We can also find subcompose in SubcomposeLayout or VectorPainter.

When a subcomposition needs to be made, a nested snapshot is created to store and isolate its state, so the nested snapshot can be destroyed when the subcomposition disappears, while keeping the parent composition and parent snapshot alive. Any changes made to a nested snapshot will be propagated to its parent.

All snapshot types provide a function to take a nested snapshot and attach it to the parent snapshot, eg Snapshot#takeNestedSnapshot() or MutableSnapshot#takeNestedMutableSnapshot().

A Child read-only snapshot can be created from any snapshot type. A mutable snapshot can only be generated from another mutable snapshot (or from a global snapshot, which can also be considered a mutable snapshot).

Snapshots and threads

It's important to think of snapshots as structures independent of any thread's scope. A thread can indeed have a current snapshot, but a snapshot is not necessarily bound to a thread. Threads can enter and leave snapshots at will, and another thread can enter sub-snapshots. In fact, one of the intended use cases for snapshots is working in parallel. Multiple child threads can be spawned, each with its own snapshot.

Once we define a mutable snapshot, we will also learn how the child snapshot notifies the parent of its changes to maintain consistency. All threads' changes will be isolated from each other, and conflicting updates from different threads will be detected and handled. Nested snapshots allow this work decomposition to be recursive. All of these potentially unlock features like parallel combos.

A snapshot of the current thread can be obtained through Snapshot.current. Returns the current thread's snapshot, if any; otherwise returns the global snapshot (saves the global state).

The Compose runtime has the ability to trigger recomposition when it observes written state. It would be beneficial to see how this mechanism interfaces with the state snapshot system we described earlier. Let's start by learning how to observe reads first.

Observational reading and writing

The Compose runtime has the ability to trigger recomposition when observed state is written.

Whenever we take a snapshot (eg Snapshot.takeSnapshot() ), we get a ReadonlySnapshot back. Since the state objects in this snapshot cannot be modified, only read, all state in the snapshot will be preserved until it is destroyed. The lambda of the takeSnapshot function allows us to pass a readObserver (as an optional parameter) observer that will be notified whenever any state object is read from the snapshot in the enter call.

The snapshotFlow function can be used as an example of using readObserver: fun snapshotFlow(block: () -> T): Flow. This function converts a State object to a Flow. When a Flow is collected, it runs its block and emits the results of the State objects it reads. When one of the State objects is modified, Flow emits the new value to its collector. In order to achieve this behavior, it needs to log all state reads so that the block re-executes when any of these state objects change. To track these reads, it takes a read-only snapshot and passes a read observer to store them in a 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
}

A read-only snapshot not only notifies its readObserver when some state is read, but also its parent's readObserver. Reads on nested snapshots must be visible to all parents and their observers, so all observers on the snapshot tree are notified.

Now let's start observing write operations.

Observing writes is also possible, so writeObserver (state update) can only be passed when creating a mutable snapshot. A mutable snapshot is one that allows modification of the state it holds. We can take a mutable snapshot by calling Snapshot.takeMutableSnapshot(). Here we can pass optional read and write watchers to be notified on any read or write.

A good example of observing reads and writes is Recomposer, which is able to track any reads and writes in a Composition to automatically trigger recomposition when needed.

The composing function is called when creating the initial composition (Composition) and every time it is recomposed. This logic relies on a MutableSnapshot that allows state to be read as well as written, and any reads or writes in the block called by enter are notified to the Composition. (In other words, reads and writes of mutable state can be tracked by composition)

The block code block passed as a parameter here is actually running the composed or recomposed code, so it executes all Composable functions in the tree to calculate the change list. And because these operations happen inside the enter function, any read or write operations are automatically tracked.

Whenever a snapshot state write is tracked into the composition, the corresponding RecomposeScopes reading the exact same snapshot state will be invalidated and trigger a recompose.

At the end of composition, the applyAndCheck(snapshot) call propagates any changes that occurred during composition to other snapshots and to the global state.

Here's what observers look like in code, they are simple functions:

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)
    }
}

There are some useful functions that can be used to observe reads and writes in the current thread. This is the Snapshot.observe(readObserver, writeObserver, block) function. For example, the derivedStateOf function uses it to respond to all object reads in the provided block.

Snapshot.observe() is the only place where TransparentObserverMutableSnapshot is used. The sole purpose of creating a parent (root) snapshot of this type is to notify observers of reads, as described earlier. The Comose team added this type to avoid generating a callback list in snapshots in some special cases.

MutableSnapshot

MutableSnapshot is the type of snapshot used when dealing with mutable snapshot state, where we need to track writes to automatically trigger reorganization.

In a mutable snapshot, any state object will have the same value as when the snapshot was taken, unless you modified the state object locally within the snapshot. All changes made in a MutableSnapshot are isolated from changes made by other snapshots. Changes propagate upward from the bottom of the tree. A child nested mutable snapshot needs to have its changes applied first and then propagated to the parent or the global snapshot (if it is the root of the tree). This is done by calling NestedMutableSnapshot#apply. (or MutableSnapshot#apply if non-nested)

The following passage is taken directly from the kdocs of the Jetpack Compose runtime:

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.

(Translation: Composition uses mutable snapshots so that changes made in Composable functions are isolated from the global state for a period of time and applied to the global state later when the composition is applied. If MutableSnapshot.apply fails to apply this snapshot, the snapshot and changes computed during composition are discarded and a new composition is scheduled to be recomputed.)

So when the composition is applied (to recap: we apply changes through the Applier as the last step in composition), any changes in the mutable snapshot are applied and notified to its parent or the final global snapshot. If there is an error applying these changes, a new combination will be scheduled.

Mutable snapshots also have a lifetime. It always ends by calling apply and dispose. This is both necessary to propagate state modifications to other snapshots, and to avoid leaks.

Changes propagated via apply are applied atomically, which means that the global state or parent snapshot (if it's nested) treats all of these changes as a single atomic change. This will clean up the history of state changes to make it easier to identify, reproduce, abort, or revert. This is the role of the transactional memory we described earlier in the concurrency control system section.

If a mutable snapshot is discarded but never applied, all its outstanding state changes will be discarded.

Here's a practical example showing how to use apply in client code:

When we print from inside the enter call, the value is "Another street", so the modification is visible. This is because we are running in the context of a snapshot. However, if we print (outside) right after the enter call, the value seems to have reverted to the original value. This is because changes in a MutableSnapshot are isolated from any other snapshot. After apply is called, the changes propagate, and we can finally see streetname printed again with the modified value.

Note that only state updates done within the enter call are tracked and propagated.

There is another simplified version of the syntax: Snapshot.withMutableSnapshot , which implicitly ensures that apply will be called last.

The way apply is called at the end might remind us of how Composer records and applies changelists. It's the same concept again. Whenever we need to understand the list of changes in the tree, we need to record/delay those changes so we can apply (trigger) them in the correct order and enforce consistency at that moment. This is the only time the program is aware of all changes, or in other words, this is the moment it has a global view.

It is also possible to register application observers to observe final modification changes. This can be achieved by calling Snapshot.registerApplyObserver.

GlobalSnapshot 和 Nested Snapshot

A GlobalSnapshot is a mutable snapshot that happens to hold global state. It will get updates from other snapshots in the bottom-up order described above.

GlobalSnapshots cannot be nested. Since there is only one GlobalSnapshot, it is actually the ultimate root of all snapshots. It holds the current global (shared) state. Therefore, the global snapshot cannot be applied (it has no apply call).

To apply changes in a global snapshot, it must be "advanced". This is done by calling Snapshot.advanceGlobalSnapshot(), which clears the previous global snapshot and creates a new snapshot that accepts all valid state from the previous global snapshot. In this case, Apply observers are also notified, because the changes are effectively "applied" even though the mechanism is different. Also, it is not possible to call dispose() on it. Destroying global snapshots can also be done in the "advanced" way.

In Jetpack Compose, global snapshots are created during initialization of the snapshot system. In the JVM, this happens when SnapshotKt.class is initialized by the Java or Android runtime.

After this, a global snapshot manager is started when the Composer is created, and then each composition (both the initial composition and any further recompositions) creates its own nested mutable snapshot and appends it to the tree, so it can store All states combined with isolation. The Composition will also take this opportunity to register read and write observers to track reads and writes to the Composition. Remember the composition function introduced earlier:

Finally, any child composition can create its own nested snapshot and append it to the tree to support invalidation while keeping the parent element alive. This will give us a complete blueprint of the snapshot tree.

Another interesting detail is that when the Composer is created, the GlobalSnapshotManager.ensureStarted() is called when the Composition is created. This is part of the integration with the platform (Compose UI), which will start observing all writes to global state and periodically dispatch snapshot app notifications in the AndroidUiDispatcher.Main context.

StateObject and StateRecord

Multi-version concurrency control ensures that every time a state is written, a new version is created (copy-on-write). The Jetpack composite state snapshot system follows this, so it is possible to end up storing multiple versions of the same snapshot state object.

This design has three important implications for performance.

  • First, the cost of creating a snapshot is O(1) complexity, not O(N) (where N is the number of state objects).

  • Second, the cost of committing a snapshot is O(N) complexity, where N is the number of mutated objects in the snapshot.

  • Third, the snapshot itself does not hold a list of snapshot data (only a temporary list of modified objects), so state objects are free to be garbage collected (GC) without notifying the snapshot system.

Internally, the snapshot state object is modeled as a StateObject, and in multiversion, the storage form for each version of the object is a StateRecord. Each record holds data for a single version of the state. The version (record) seen by each snapshot corresponds to the latest valid version available at the time the snapshot was taken. (valid snapshot with the highest snapshot ID)

But how to make state records effective?

"Effective" is only meaningful under a specific snapshot. Records are associated with the snapshot ID that created the record. The status record of a snapshot is valid when the following conditions are met: if the ID of the status record is less than or equal to the snapshot ID (that is, created in the current or previous snapshot), and it does not belong to the invalid set of the snapshot, nor is it explicitly marked as invalid. Any valid records from the previous snapshot are automatically copied to the new snapshot.

This begs the question: what would make a record part of the mentioned invalid set or explicitly marked as invalid?

  • Records created after the current snapshot are considered invalid because they were created for snapshots created after this one.

  • When the current snapshot was created, if the records created for the snapshot were turned on, the records are added to the invalidation set, so they are also considered invalid.

  • Records created in snapshots that were destroyed before being applied are explicitly marked as invalid.

An invalid record is not visible to any snapshot, so it cannot be read. When reading the snapshot state from a Composable function, the record is not taken into account and its latest valid state is returned instead.

Back to the state object. Below is a brief example of how they are modeled in a state snapshot system.

Any mutable snapshot state object created by any means will implement this interface. For example, the state returned by the mutableStateOf, mutableStateListOf, or derivedStateOf runtime functions, etc.

Let's take a look at the mutableStateOf(value) function.

This call returns an instance of SnapshotMutableState, which is essentially an observable mutable state, in other words, a state that can be updated and will automatically notify observers of the state. This class is a StateObject, so it maintains a linked list of records that store different versions of the state (value in this case). Each time the state is read, the list of records is traversed to find and return the most recent valid record.

If we look back at the definition of StateObject, we can see that it has a pointer to the first element of the linked list of records, with each record pointing to the next. It also allows prepending a new record to the list (making it new firstStateRecord).

Another function in the StateObject definition is mergeRecords. We mentioned earlier that the system can automatically merge conflicts when possible. That's what this function does. The merge strategy is simple and will be described in detail later.

Let's understand StateRecord a bit

Here we can see that each record is associated with a snapshot ID. This ID is the ID that belongs to the snapshot that created the record. This will determine whether the record is valid for a given snapshot following the above requirements.

We said that whenever an object is read, the StateRecords list for a given snapshot state (StateObject) is traversed, looking for the latest valid record (with the highest snapshot ID). Likewise, the latest valid state of each snapshot state object is captured when the snapshot is created, and this will be the state used throughout the lifetime of the new snapshot (unless it is a mutable snapshot and the state is modified locally) .

StateRecord also has an assign function that assigns it from another StateRecord object and creates it.

StateRecord is also a contract (interface). Each existing StateObject type defines a different implementation because records store information about each type of StateObject that is different for each type (each use case).

Following the example of mutableStateOf, we know that it returns a SnapshotMutableState, which is a StateObject. It will maintain a linked list of records of a very specific type: StateStateRecord. This record is just a wrapper around a value of type T because in this case that's all the information we need to store in each record.

Another good example could be mutableStateListOf. It creates a SnapshotStateList, which is another implementation of StateObject. The state simulates an observable mutable list (implements the Kotlin collection's MutableList contract), so its records will have the type StateListStateRecord defined by itself. This record uses a PersistentList (see Kotlin Immutable Collections) to hold a version of the state list.

Read and write status

In other words, read and write status records. "When an object is read, the StateRecords list for a given snapshot state (StateObject) is traversed, looking for the most recent valid record (with the highest snapshot ID)". Let's see how this is implemented in code.

This is the TextField Composable component from the compose.material library. It remembers a mutable state for holding the text value, so every time the value is updated, the Composable is restructured to display the new characters on the screen.

We will not consider the call to remember for now, because it is not the focus of our discussion here. The mutableStateOf function is used here to create a snapshot state:

This ends up creating a SnapshotMutableState state object which gets a value: T and a SnapshotMutationPolicy as parameters. It will wrap the value (stored in memory) and when it needs to be updated it will use a conflict strategy to check if the new value passed is different from the current value. The following is the definition of the value attribute in this class:

Whenever we use a getter to access the TextField Composable internal value (eg textFieldValueState.value), it will start iterating by calling the readable method with the reference next of the next state record (the first record in the linked list). The readable function iterates through to find the current (latest) valid readable state, notifying any registered read observers. For each new iteration item, it is checked against the valid conditions defined in the previous section. The current snapshot will be the current thread's snapshot or the global snapshot if the current thread is not associated with any snapshot.

/**
 * 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()
}

This is how mutableStateOf's snapshot state is read. The situation is similar for other available mutable snapshot state implementations such as those returned by mutableStateListOf.

When we want to update the state, we can use the state's setter method to do so. Here is the sample code:

The withCurrent function calls the readable function under the hood to run the provided code block and pass it the current latest readable state record as a parameter.

Next, it checks whether the new value is equivalent to the current value using the provided SnapshotMutationPolicy. If they are not equal, the writing process will start. This work is done by the overwritable function.

I'm intentionally not going into the implementation details here as they may change in the future. However, I'll explain briefly: it runs the block with the writable status record and proposes a candidate record that will be the latest valid record at the moment. If it is valid for the current snapshot, it is used for writing, otherwise it creates a new record and adds it to the head of the list, making it the new initial record. This block actually modifies it.

Finally, it notifies any registered write observers.

Delete or reuse obsolete records

With multiversion concurrency control, we can store multiple versions (records) of the same state, but this introduces an interesting challenge: removing versions that are obsolete and will never be read. We'll explain how Compose solves this in a moment, but let's first introduce the concept of "open snapshots".

Any new snapshots are added to an open snapshot collection until actively closed. While a snapshot remains open, all of its state records are considered invalid (unreadable) for other snapshots. Closing a snapshot means that all its records automatically become valid (readable) for any new snapshot created.

  1. Once we understand this, let's take a look at how Compose reclaims stale records:

  2. It tracks the lowest open snap. Compose keeps track of a set of open snapshot IDs. These IDs are monotonically increasing and keep increasing.

If a record is valid but not visible in the lowest open snapshot, it can be safely reused because it will never be picked up by any other snapshot.

Reusing overwritten records often results in only 1 or 2 records in the mutable state object, which improves performance significantly. As snapshots are applied, overwritten records will be reused by the next snapshot. If a snapshot is destroyed before it is applied, all records are marked invalid (discarded), which means they can be reused immediately.

change propagation

Before explaining how changes in mutable snapshots are propagated, it may be useful to review the meaning of "closing" and "advancing" snapshots so that we understand both terms.

Closing a snapshot effectively removes its ID from the set of open snapshot IDs, with the result that all state records (records) associated with that ID will become visible/readable for read by any new snapshots created. This makes turning off snapshots an efficient way to propagate state changes.

When closing a snapshot, many times we want to immediately create a new snapshot to replace it. This is called "advancing". Newly created snapshots get a new ID generated by incrementing the previous ID. This ID is then added to the collection of opened snapshot IDs.

As we've learned, global snapshots are never applied, but always advanced, which makes all changes visible to newly created global snapshots. Mutable snapshots can also advance while their nested snapshots apply changes.

Now that we understand this well, we're ready to learn how changes in mutable snapshots are propagated.

When snapshot.apply() is called on a mutable snapshot, all local changes made to state objects within its scope will be propagated to the parent snapshot (in the case of nested mutable snapshots) or to the global state.

Calling apply or dispose will define the life cycle of the snapshot. The application's mutable snapshot can also be freed later. However, calling apply after dispose will throw an exception because the changes have already been discarded.

From what we have described, to propagate all local changes (visible to new snapshots taken), it is sufficient to simply delete the snapshot from the active snapshot set. Whenever a snapshot is created, a copy of the currently open snapshot is passed in as an invalid snapshot set (that is, any snapshots that have not yet been applied should not be visible to the new snapshot). Simply removing the snapshot id from the open snapshot set is enough for each new snapshot to consider the recrods created during this snapshot as valid, so they can be returned when their corresponding state objects are read.

But you should only do this after you are sure there are no state conflicts (collision writes), as those need to be resolved first.

When a snapshot is applied, the changes made by the applied snapshot are added along with the changes from other snapshots. The state object has a linked list of records where all changes are aggregated. This opens the door to write conflicts, as multiple snapshots may attempt to apply changes to the same state object. When a mutable snapshot wants to apply (notify/propagate) its local changes, it tries to detect potential write conflicts and merge those conflicts if possible.

Here we have two scenarios:

  • no pending local changes

If there are no local changes pending in the snapshot:

  • A mutable snapshot is actively closed (removes it from the set of open snapshot ids, making all state records automatically visible/readable to new snapshots).

  • Global snapshots are "advanced" (same as shutdown, but will also be replaced by new global snapshots created).

  • Use this opportunity to check for any state changes in the global snapshot so that the mutable snapshot can notify any potential application observers of these changes.

  • There are pending local changes

When there are pending changes:

  • Use an optimistic approach to detect conflicts and count merged records (remember the concurrency control category). Collisions will try to merge automatically, otherwise they will be discarded.

  • For each pending local change, it checks to see if it differs from the current value. If not, the change is ignored and the current value is maintained.

  • If it's an actual change (different), the already computed optimistic merge is checked to decide whether to keep the previous, current, or applied record. It can actually create an amalgamation of all of these.

  • If it has to perform a merge of records, it will create a new record (immutability) and assign the snapshot id to it (associate it with the mutable snapshot), then prepend it to the record's linked list, making it effectively to be the first record in the list.

If there is any failure in applying the changes, it will fall back to the same flow it would have done if there were no pending local changes. This closes the mutable snapshot to make its records visible to any new snapshots, advances the global snapshot (closes and replaces it with a new one), so it includes all changes in the mutable snapshot just closed, and notifies any apply observers detected to any global state changes.

The process is slightly different for nested mutable snapshots, as they do not propagate changes to the global snapshot, but to their parent snapshot. For this reason, they add all their modified state objects to the parent object's modified set. Since all of these changes need to be visible by the parent snapshot, the nested mutable snapshot removes its own id from the invalid snapshot's parent snapshot set.

merge write conflict

To do a merge, the mutable snapshot iterates over its modified state list (local changes), and for each change it does the following:

  • Get the current value (state record) in the parent snapshot or global state.

  • Get the previous value before applying the changes.

  • Get the state of the object after applying the changes.

  • Try to merge all three of them automatically. This is delegated to the state object, which relies on the merge strategy provided (see the StateObject definition above).

The fact is that none of the strategies available in the runtime support correct merging, so bumping updates will cause a runtime exception and notify the user of the problem. To avoid getting into this situation, Compose guarantees that collisions are impossible by accessing state objects with unique keys (remembered state objects in composable functions usually have unique access properties). Given mutableStateOf is merged using StructuralEqualityPolicy, which deeply compares the two versions of the object via the equal sign (==), so all properties are compared, including the unique object key, making it impossible for the two objects to collide.

Automatically merging conflicting changes is a potential optimization that Compose doesn't use yet, but other libraries could use.

You can provide a custom conflict policy by implementing the SnapshotMutationPolicy interface. An example from the Compose documentation that can be used as a reference strategy is to use MutableState as a counter. This policy assumes that changing the state value to the same will not be considered a change, so any changes to mutable state using a counterPolicy will never cause an application conflict.

When two values ​​are the same, they are considered equivalent, so the current value will be preserved. Note how the merge is obtained, adding the difference between the newly applied value and the previous value to the current value, so the current value always reflects the total stored.

This paragraph is an explanation from the official document: *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. * (As the name of the policy implies, it is useful for counting, for example tracking what was consumed or produced in a snapshot The number of resources. For example, if snapshot A produced 10 artifacts and snapshot B produced 20 artifacts, the result of applying both A and B should be 30 artifacts.)

We have a single mutable state that is compared using a counter strategy, and two snapshots that try to modify it and apply the changes. This would be a perfect scenario for collisions, but any collisions are completely avoided given our counter strategy.

This is just a simple example of how to provide a custom SnapshotMutationPolicy to avoid conflicts, so we can get the gist. Another implementation where collisions are impossible might be a collection where elements can only be added but not removed. Other useful types such as ropes can be similarly converted to non-conflicting datatypes, provided there are certain restrictions on how they work and what is expected.

We can also provide a custom strategy that accepts conflicts but resolves them by merging the data using the merge function.

Summarize

The idea of ​​Snapshot state is state isolation and snapshot isolation.

Based on MVCC (multi-version concurrency control) implementation :

  • Keep multiple copies/snapshots of the data, with each thread working at a given moment using an isolated local copy.

  • Each thread will point to a different snapshot and thus a different copy of the state.

  • Whenever data is written, a new copy of the data is created rather than the original data being modified.

  • Multiple versions of the same data, or historical records, are stored in memory, which is called StateRecord in Compose

  • Each snapshot is assigned an ID, that is, as a transaction ID, and the snapshot ID is monotonically increasing.

  • Reads and writes are separated by snapshot ID and isolated without locking.

Snapshot lifecycle :

  • Created when Snapshot.takeSnapshot() is called and destroyed when snapshot.dispose() is called. A snapshot is considered active between the create and release states.

  • Snapshots should be destroyed when not in use, otherwise related resources may be leaked.

snapshot.enter : Often referred to as "entering a snapshot", this will run a lambda expression in the context of the snapshot. Once entered in this lambda, reading and writing of state within its scope are isolated, based on the current snapshot. It allows threads to be local, isolated from other threads.

Common types of snapshots:

  • ReadonlySnapshot: read-only snapshot

  • MutableSnapshot: readable and writable

  • NestedReadonlySnapshot and NestedMutableSnapshot: Nested snapshots. Read-only and mutable snapshots for Child in the snapshot tree. Can be destroyed/released independently while keeping the parent snapshot alive. When subgrouping, a nested snapshot is created. As in SubcomposeLayout.

  • GlobalSnapshot: A mutable snapshot of the global shared state. The root snapshot for all snapshots. It cannot be nested, there is only one globally.

Snapshot Tree : Snapshots can form a tree, and the root of the tree is GlobalSnapshot.

Snapshots and threads are independent of each other :

  • A thread can have a current snapshot, but the snapshot is not necessarily bound to the thread. Threads can enter and leave a snapshot at will.

  • Snapshot.current Gets a snapshot of the current thread. It returns a snapshot of the current thread, or a global snapshot.

Snapshot read and write monitoring :

  • For example, Snapshot.takeSnapshot(readObserver) can set an observer for read-only snapshots. This observer is notified whenever any state object is read from the snapshot in a snapshot.enter call.

  • Mutable snapshots can set both readObserver and writeObserver to observe read and write operations.

  • Inside the derivedStateOf function is to use Snapshot.observe(readObserver, writeObserver, block) to observe the read and write in the current thread.

Recomposer tracks any read and write operations in Composition and automatically triggers reorganization. This is achieved by registering read-write observers with the mutable snapshot.

  • Snapshot.takeMutableSnapshot(readObserver, writeObserver) is called when the Recomposer does the initial composition and every recomposition.

  • It runs the combined or reassembled block code in snapshot.enter(block), so it can be listened to.

  • Whenever a snapshot state write is tracked/observed by Recomposer, the corresponding RecomposeScopes reading the same snapshot state will be invalidated and trigger a recomposition.

Application of snapshot changes :

  • snapshot.apply() can apply snapshot changes (for mutable snapshots), it is an atomic operation.

  • After applying() is called, modifications to a snapshot are propagated to other snapshots. All local changes made to state objects within its scope will be propagated to the parent snapshot (in the case of nested mutable snapshots) or to the global state.

  • Snapshot.withMutableSnapshot{} is a simplified version that implicitly calls apply().

Global snapshot :

  • A global snapshot is created when the SnapshotKt.class class is initialized by the JVM.

  • A global snapshot manager is started when a Composer is created, and when a Composition is created, GlobalSnapshotManager.ensureStarted() is called to start observing all writes to the global state.

  • Each composition creates its own nested mutable snapshots and appends them to the snapshot tree, and the global snapshot is the root of this tree, so the global snapshot manager can store and isolate all the state of the composition.

Internal representation of snapshot state :

  • MVCC ensures that every time a state is written, a new version is created (copy-on-write).

  • The internal implementation of the snapshot state object is a StateObject. In multi-version, the storage form of each version stored for this object is a StateRecord.

  • Each record saves a version of state information, and each record is associated with the snapshot ID that created the record, and the snapshot ID is incremented, so that each version of the record forms a single-linked list incremented by ID.

  • The version (record) seen by each snapshot corresponds to the latest valid version available at the time the snapshot was taken.

  • The latest valid version refers to the record created before the snapshot and the record is neither invalid nor added to the invalid list.

Status read and write :

  • When reading an object, the StateRecords list for a given snapshot state (StateObject) is traversed, looking for the most recent valid record (with the highest snapshot ID)

  • When writing, use the latest valid record as the candidate record. If it is valid for the current snapshot, it is used for writing, otherwise it creates a new record and adds it to the head of the list, making it the new initial record.

  • Any registered read observers and write observers will be notified when the read and write are complete.


In order to help everyone better understand the knowledge points of the Jetpack Compose system, here is a more complete and detailed record of the "Jetpack Beginner to Master" (including Compose) study notes! ! ! Friends who are interested in Jetpose Compose can refer to the following...

Jetpack Family Bucket (Compose)

Jetpack section

  1. Jetpack之Lifecycle
  2. Jetpack之ViewModel
  3. Jetpack之DataBinding
  4. Navigation of Jetpack
  5. Jetpack之LiveData

Compose part
1. Detailed introduction to Jetpack Compose
2. Compose study notes
3. Detailed explanation of Compose animation usage

Guess you like

Origin blog.csdn.net/weixin_61845324/article/details/132516023