Jetpack Compose introduces a new way to handle observable stateSnapsot(Snapshot). In Compose we passstateTo trigger reorganization, consider the following questions:

  • Why does a state change trigger a reorganization?

  • How does it determine the scope of recombination?

  • Does it always reorganize as long as the state changes?

Let’s learn with questions!

Snapshot API

In general, we don’t need to know how snapshots are used, that’s what the framework is supposed to do, and it’s very likely that we’ll have problems doing it manually. So I’ll just demonstrate the use of snapshots (not the underlying implementation) to help you understand the Compose reorganization mechanism.

A Snapshot is a Snapshot of all the states, so you can get a Snapshot of the state before you took it.

Let’s take a look at the code demo to see what Snapshot does: First define a Dog class with a state:

class Dog {
    var name: MutableState<String> = mutableStateOf("")
}

Copy the code

Create a snapshot

Val dog = dog () dog.name.value = "Spot" val snapshot = snapshot.takesnapshot () dog.name.value = "Fido" println(dog.name.value) snapshot.enter { println(dog.name.value) } println(dog.name.value) // Output: Fido Spot FidoCopy the code

  • TakeSnapshot () will “take” a snapshot of all the State values in the program, no matter where they were created

  • The Enter function restores the snapshot state and applies it to the function body

So we see only old values in Enter.

Variable snapshot

We tried to change the dog’s name in the Enter block:

fun main() {
    val dog = Dog()
    dog.name.value = "Spot"

    val snapshot = Snapshot.takeSnapshot()

    println(dog.name.value)
    snapshot.enter {
        println(dog.name.value)
        dog.name.value = "Fido"
        println(dog.name.value)
    }    
    println(dog.name.value)
}

// Output:
Spot
Spot

java.lang.IllegalStateException: Cannot modify a state object in a read-only snapshot
  
Copy the code

We find that we get an error when we try to change the value because takeSnapshot() is read-only, so inside Enter we can read but not write. If we want to create a mutable snapshot we should use the takeMutableSnapshot() method.

fun main() {
    val dog = Dog()
    dog.name.value = "Spot"

    val snapshot = Snapshot.takeSnapshot()

    println(dog.name.value)
    snapshot.enter {
        println(dog.name.value)
        dog.name.value = "Fido"
        println(dog.name.value)
    }    
    println(dog.name.value)
}

// Output:
Spot
Spot

java.lang.IllegalStateException: Cannot modify a state object in a read-only snapshot

Copy the code

You can see that the program did not crash, but the action in Enter did not take effect outside of its scope! This is an important isolation mechanism. If we want to apply changes inside Enter, we call apply() :

 fun main() {
     val dog = Dog()
     dog.name.value = "Spot"

     val snapshot = Snapshot.takeMutableSnapshot()
     println(dog.name.value)
     snapshot.enter {
         dog.name.value = "Fido"
         println(dog.name.value)
     }
     println(dog.name.value)
     snapshot.apply()
     println(dog.name.value)
}

// Output:
Spot
Fido
Spot
Fido 
Copy the code

You can see that after apply is called, the new value takes effect in addition to Enter. We can also make the Snapshot. WithMutableSnapshot () to simplify the invocation:

fun main() {
    val dog = Dog()
    dog.name.value = "Spot"

    Snapshot.withMutableSnapshot {
        println(dog.name.value)
        dog.name.value = "Fido"
        println(dog.name.value)
    }
    println(dog.name.value)
}
Copy the code

What we know so far:

  • Take snapshots of all our states

  • “Restore” state to a specific code block

  • Change state value

But we don’t yet know how to perceive reading and writing, so let’s figure that out.

Observe reads and writes

Whether LiveData,Flow or State are all observer modes, so there must be observer and observed. For a snapshot system, the observed is our state, and there are two observers, one is read observer, one is write observer.

TakeMutableSnapshot actually takes two optional arguments, one for read-time callback and the other for write-time callback:

fun takeMutableSnapshot( readObserver: ((Any) -> Unit)? = null, writeObserver: ((Any) -> Unit)? = null ): MutableSnapshot = (currentSnapshot() as? MutableSnapshot)? .takeNestedMutableSnapshot( readObserver, writeObserver ) ? : error("Cannot create a mutable snapshot of an read-only snapshot")Copy the code

So we can do something in the callback, which in Compose records the ComposeScope when the value is read and marks the corresponding Scope as invalid when the value is written.

Global snapshot

A global snapshot is a mutable snapshot at the root of the snapshot tree. In contrast to regular mutable snapshots, which must apply to take effect, global snapshots have no apply operation. For example, we would define state in ViewModel and request data from repository and assign state. The GlobalSnapshot sends the notification:

It does this by calling:

  • The Snapshot. NotifyObjectsInitialized. This sends notifications for any state that has changed since the last call.

  • The Snapshot. SendApplyNotifications (). This is similar to notifyObjectsInitialized, but only pushes the snapshot if the actual change occurs. In the first case, this function is implicitly called whenever any mutable snapshot is applied to the global snapshot.

internal object GlobalSnapshotManager { private val started = AtomicBoolean(false) fun ensureStarted() { if (started.compareAndSet(false, true)) { val channel = Channel<Unit>(Channel.CONFLATED) CoroutineScope(AndroidUiDispatcher.Main).launch { channel.consumeEach { Snapshot.sendApplyNotifications() } } Snapshot.registerGlobalWriteObserver { channel.offer(Unit) } }}}Copy the code

You can see that writeObserver is registered on Android, and it also has ApplyObserver, which we’ll talk about later.

multithreading

In a snapshot for a given thread, changes made to the state value by other threads are not seen until the snapshot is applied. The snapshot is isolated from other snapshots. Any changes made to the state within the snapshot will not be visible to other threads until the snapshot is applied and the global snapshot is automatically advanced. SnapshotThreadLocal SnapshotThreadLocal

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))
        }
    }
}
Copy the code

conflict

What if we “take” multiple snapshots and apply the changes to all of them?

fun main() { val dog = Dog() dog.name.value = "Spot" val snapshot1 = Snapshot.takeMutableSnapshot() val snapshot2 = Snapshot.takeMutableSnapshot() println(dog.name.value) snapshot1.enter { dog.name.value = "Fido" println("in snapshot1: "+ dog.name.value)} // Don't apply it yet, Println (dog.name.value) snapshot2.enter {dog.name.value = "Fluffy" println("in snapshot2: " + dog.name.value) } // Ok now we can apply both. println("before applying: " + dog.name.value) snapshot1.apply() println("after applying 1: " + dog.name.value) snapshot2.apply() println("after applying 2: " + dog.name.value) } // Output: Spot in snapshot1: Fido Spot in snapshot2: Fluffy before applying: Spot after applying 1: Fido after applying 2: FidoCopy the code

You find that the changes from the second snapshot cannot be applied because they all view changes with the same initial value, so the second snapshot either executes Enter again or tells you how to resolve the conflict.

Compose actually has an API for resolving merge conflicts! MutableStateOf () requires an optional SnapshotMutationPolicy. This policy defines how to compare a particular type of value (Equivalent) and how to resolve conflicts (merge). It also provides some out-of-the-box strategies:

  • StructuralEqualityPolicy – Compares objects using their equals method (==), and all writes are considered non-conflicting.

  • ReferentialEqualityPolicy – by reference (= = =) objects, all written to be considered are conflict.

  • NeverEqualPolicy – All objects are considered unequal and all writes are considered nonconflicting.

We can also construct our own rules:

class Dog {
  var name: MutableState<String> =
    mutableStateOf("", policy = object : SnapshotMutationPolicy<String> {
      override fun equivalent(a: String, b: String): Boolean = a == b

      override fun merge(previous: String, current: String, applied: String): String =
        "$applied, briefly known as $current, originally known as $previous"
    })
}

fun main() {
  // Same as before.
}

// Output:
Spot
in snapshot1: Fido
Spot
in snapshot2: Fluffy
before applying: Spot
after applying 1: Fido
after applying 2: Fluffy, briefly known as Fido, originally known as Spot

Copy the code

conclusion

This is the basic use of Snapshot, which is equivalent to the advanced DiffUtil. Its characteristics can be summed up as follows:

  • Reactive: Stateful code is always automatically up to date. We don’t have to worry about subscribing and unsubscribing.

  • Isolation: Stateful code can manipulate state without worrying that code running on different threads will change the state. Compose can take advantage of this to do things that the older View system could not do, such as putting refactoring into multiple background threads.

To reassure

  • whystateCan change trigger reorganization?

Jetpack Compose registers readObserverOf and writeObserverOf at execution time:

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) } }Copy the code

Where the state is read:

  • readObserverOfTo record whatScope uses thisstate` :
override fun recordReadOf(value: Any) {
      if (!areChildrenComposing) {
          composer.currentRecomposeScope?.let {
              it.used = true
              observations.add(value, it)
              ...
          }
      }
 }
Copy the code
  • writeObserverOfWhile writing will find the corresponding use of thisstatescopeTo make itinvalidate :
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)           }       }   }   
Copy the code

These scopes are reorganized the next time a frame signal arrives.

  • How does it determine the scope of recombination?

Code that can be marked as Invalid must be non-inline @Composalbe function/lambda with no return value and must follow the reorganization scope minimization principle. See: How does Compose determine the scope of reorganization

  • Does it always reorganize as long as the state changes? Not necessarily, please see the following examples for specific cases:

Example 1.

val darkMode = mutableStateOf("hello")

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContent {
        lifecycleScope.launch {
            delay(100)
            val text= darkMode.value
            darkMode.value = "Compose"
        }
    }
}
Copy the code

Does not recompose because delay causes state reads to be performed outside the snap.apply method and therefore does not register readObserverOf, which is not linked to the composeScope and does not trigger recompose. In this case it is reorganized if it is read before delay.

Example 2.

val darkMode = mutableStateOf("hello")

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContent {
        thread {
            val text =  darkMode.value
            darkMode.value = "Compose"
        }
    }
}
Copy the code

If the thread does not have a snapshot, obtain the GlobalSnapshot:

internal fun currentSnapshot(): Snapshot = threadSnapshot.get() ? : currentGlobalSnapshot.get()Copy the code

Since there is no corresponding readObserver, this example will not be reorganized. However, this state is recomposed if read in the Composable because ReComposer is registered with ApplyObserver and globalModified is also recorded by Apply. When the next frame arrives, look for the corresponding scope:

val unregisterApplyObserver = Snapshot.registerApplyObserver { changed, _ -> synchronized(stateLock) { if (_state.value >= State.Idle) { snapshotInvalidations += changed deriveStateLocked() } else null }? .resume(Unit) }Copy the code

Example 3.

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContent {
        val darkMode = mutableStateOf("hello")
        Text(darkMode.value)
        darkMode.value = "Compose"
    }
}
Copy the code

This one doesn’t trigger reorganization either, and you might wonder why this one doesn’t trigger reorganization because it doesn’t have async and it has readObserver and writeObserver breakpoints. A state change will mark the scope that uses it as invalid.

In practice, however, the InvalidationResult is IGNORE

fun invalidate(scope: RecomposeScopeImpl, instance: Any?) : InvalidationResult { ... if (anchor == null || ! slotTable.ownsAnchor(anchor) || ! anchor.valid) // The scope has not yet entered the composition return InvalidationResult.IGNORED ... }Copy the code

First, we do record the scope that uses state, otherwise the invalidate behavior would not be triggered when we modify it. However, at this time there is no reconfigurable anchors in slotTable, you can only get anchors for each area after the combination is complete. For example, Compose uses a SlotTable to record data, so it is not clear where to start.

More information, please refer to: about SlotTable in-depth explanation JetpackCompose | implementation principle

Second, since state is created in the Enter code block, state.snapshotid == snapshot. id does not record state changes. After all, a snapshot diff works between two snapshots.

internal fun <T : StateRecord> T.overwritableRecord( state: StateObject, snapshot: Snapshot, candidate: T ): T { ... Val id = snapshot.id if (candidate. SnapshotId == id) return candidate... }Copy the code

But what if you put the creation of state outside of setContent?

Example 4.

val darkMode = mutableStateOf("hello")

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContent {
        Text(darkMode.value)
        darkMode.value = "Compose"
    }
}
Copy the code

The answer is yes

Because this state was created before shooting, state.snapshotid! = snapshot. id, changes to state during this period are not immediately marked as invalid, but are counted as modified and notified by the global Snapshot after applying:

internal fun <T : StateRecord> T.overwritableRecord( state: StateObject, snapshot: Snapshot, candidate: T ): T { ... val id = snapshot.id if (candidate.snapshotId == id) return candidate val newData = newOverwritableRecord(state, Snapshot) newdata. snapshotId = id // Record changes snapshot.recordModified(state) return newData}Copy the code

ApplyObserver is notified to the observer when applied, and changed:

val unregisterApplyObserver = Snapshot.registerApplyObserver { changed, _ -> synchronized(stateLock) { if (_state.value >= State.Idle) { // here snapshotInvalidations += changed deriveStateLocked() } else null }? .resume(Unit) }Copy the code

Composation finds that the scope observing the corresponding changed state is marked as invalid waiting for reorganization:

private fun addPendingInvalidationsLocked(values: Set<Any>) { var invalidated: HashSet<RecomposeScopeImpl>? = null fun invalidate(value: Any) { observations.forEachScopeOf(value) { scope -> if (! observationsProcessed.remove(value, scope) && scope.invalidateForResult(value) ! = InvalidationResult.IGNORED ) { val set = invalidated ? : HashSet<RecomposeScopeImpl>().also { invalidated = it } set.add(scope) } } } for (value in values) { if (value is RecomposeScopeImpl) { value.invalidateForResult(null) } else { invalidate(value) derivedStates.forEachScopeOf(value) { invalidate(it) } } } invalidated? .let { observations.removeValueIf { scope -> scope in it } } }Copy the code

Example 5.

var onlyDisplay = mutableStateOf("onlyDisplay")

class MainActivity : ComponentActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            Text(
                text = onlyDisplay.value,
                fontSize = 50.sp,
            )
            onlyDisplay.value = "Display"
        }
    }
}
Copy the code

If I put the state declaration in the outermost layer of the KT file, will it be reorganized?

And the answer is no, because in Kotlin if you put variables out of the class, they go right to the top of the file. When compiled, a file is actually generated, and the property becomes static.

public final class MainActivityKt {
       static MutableState<String> onlyDisplay = SnapshotStateKt.mutableStateOf$default("onlyDisplay", null, 2, null);
}
Copy the code

So this example deals with class initialization:

A class is initialized only when it’s requested, and it only contains static variables, functions, things that are static.


State. SnapshotId ==snapshot.Id, the first combination has not been completed, invalidateResult==IGNORE, I’m not going to call it modified, which is the same problem as example 3.

My inventory, need small partners please clickMy lotFree to receive