Jetpack Compose · snapshot system

Posted by Rottingham on Mon, 07 Feb 2022 14:02:39 +0100

Jetpack Compose introduces a new method for handling observable states - Snapsot. In Compose, we trigger reorganization through the change of state. Please consider the following questions:

  • Why can state changes trigger reorganization?
  • How does it determine the scope of reorganization?
  • As long as the state changes, will it be reorganized?

Let's study with problems!

Snapshot API

Generally, we don't need to know how to use snapshots. These are what the framework should do. We may have problems with manual operation. So here is just a demonstration of the use of snapshots (not involving the underlying implementation), which is helpful to understand the mechanism of Compose reorganization.

Snapshot (snapshot), a simple metaphor, is to take a picture of all States, so you can get the state before shooting.

Let's use the code demonstration to see what a Snapshot does: first, define a Dog class that contains a state:

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

Create 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
Fido
  • takeSnapshot() will "take" a snapshot of all State values in the program, regardless of where they were created
  • The enter function restores the snapshot state and applies it to the function body

Therefore, we see that it is the old value only in enter.

Variable snapshot

We try 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
  

We will find that when we try to modify the value, there is an error. Because takeSnapshot() is read-only, we can read but not write inside the enter. If we want to create a variable snapshot, we should use 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

You can see that the program did not crash, but the operation in enter did not take effect outside its scope! If we want to change the internal isolation mechanism of apple () application, it is very important:

 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 

You can see that after calling apply, the new value takes effect outside of enter. We can also make a snapshot Withmutablesnapshot() to simplify the call:

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

So far, we know:

  • Take a snapshot of all our states
  • Restore state to a specific code block
  • Change status value

But we still don't know how to perceive reading and writing. Let's figure this out.

Observe read and write

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

In fact, takeMutableSnapshot has two optional parameters, which are called back when reading and writing:

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

Therefore, we can perform some operations in the callback. In Compose, we record the ComposeScope when reading the value, and mark the corresponding Scope as invalid if there is any change during writing.

Global snapshot

A global snapshot is a variable snapshot at the root of the snapshot tree. Compared with a regular variable snapshot that must be applied to take effect, a global snapshot has no apply operation. For example, we will define state in ViewModel, and request data in repository and assign value to state. GlobalSnapshot will send the notification:

It calls:

  • Snapshot.notifyObjectsInitialized. This sends a notification for any status changes since the last call.
  • Snapshot.sendApplyNotifications(). This is similar to notifyObjectsInitialized, but only advances the snapshot if the actual change occurs. In the first case, this function is implicitly called whenever any variable 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)
            }
        }
    }
}  

You can see that writeObserver and ApplyObserver are registered on the android platform, which we will talk about later.

Multithreading

In a snapshot of a given thread, changes made to the status value by other threads will not be seen until the snapshot is applied. Snapshots are "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. You can understand the class name. 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))
        }
    }
}

conflict

What happens if we "take" multiple snapshots and apply the changes?

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, let's try setting a third value first.

  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: Fido

You will find that the changes of the second snapshot cannot be applied because they are modified 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 specific types of values and how to resolve conflicts. It also provides some out of the box strategies:

  • structuralEqualityPolicy – compares objects using the objects' equals method (= =), and all writes are considered non conflicting.
  • referentialEqualityPolicy – all writes are considered non conflicting by referencing (= =) the comparison object.
  • neverEqualPolicy – treat all objects as unequal and all writes as non conflicting.

We can also build 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

summary

The above is the basic use of Snapshot, which is equivalent to advanced DiffUtil. Its characteristics are summarized as follows:

  • Responsive: stateful codes are always automatically kept up-to-date. We don't need to worry about subscriptions and unsubscribes.
  • Isolation: stateful code can operate on the state without worrying that the code running on different threads will change the state. Compose can use this to achieve effects that the old View system cannot achieve, such as putting refactoring on multiple background threads.

Dispel doubts

  • Why can state changes trigger reorganization?

Jetpack Compose registers readObserverOf and writeObserverOf during execution:

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

The following will be executed where the status is read:

  • readObserverOf to record which scope s use this state \ `:
override fun recordReadOf(value: Any) {
      if (!areChildrenComposing) {
          composer.currentRecomposeScope?.let {
              it.used = true
              observations.add(value, it)
              ...
          }
      }
 }
  • When writeObserverOf is written, the scope corresponding to this state will be found and invalidate d:
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)           }       }   }   

Reorganization is performed for these scope s when the next frame signal arrives.

  • How does it determine the scope of reorganization?

The code that can be marked as Invalid must be non inline @ Composalbe function/lambda with no return value, and the principle of minimizing the reorganization range must be followed. See how Compose determines the reorganization scope for details

  • As long as the state changes, will it be reorganized?
    Not necessarily. Please see the following examples for specific cases:

Example ①

val darkMode = mutableStateOf("hello")

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

It will not be reorganized because the state reading caused by delay is in snap It is executed outside the apply method, so readObserverOf will not be registered. Naturally, it will not be linked to the composeScope and will not trigger reorganization. In this example, if it is read before delay, it will be reorganized.

Example ②

val darkMode = mutableStateOf("hello")

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

The state in the thread is read in different threads. Due to the snapshot ThreadLocal mechanism, if there is no snapshot in this thread, get the GlobalSnapshot:

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

Since there is no corresponding readObserver, this example will not be reorganized. However, if this state is read in composable, it will be reorganized, because recombiner registers ApplyObserver. When applying, it will also record globalModified, and find the corresponding scope when the next frame signal arrives (you can follow the process at the breakpoint):

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

Example ③

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

This doesn't trigger reorganization either. You may wonder why it doesn't trigger reorganization because it doesn't have asynchrony and breakpoints also have readObserver and writeObserver? Doesn't it mean that the state change will mark the scope using it as invalid?

However, in actual operation, 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 
     ...
}

First of all, we do record the scope using state, otherwise the invalidate behavior will not be triggered during modification. But at this time, there is no area anchor information that can be reorganized in the slottable. The anchor anchors of each area can be obtained only after the combination is completed. The simple description is that Compose uses slottable to record data information. At this time, the first complete combination has not been completed, and I don't know where to start.

For more information about SlotTable, see: in-depth explanation of JetpackCompose | implementation principle

Secondly, because the state is created in the enter code block, at this time, state snapshotId\==Snapshot. ID does not record the change of state. After all, the diff of a snapshot works between two snapshots.

internal fun <T : StateRecord> T.overwritableRecord(
    state: StateObject,
    snapshot: Snapshot,
    candidate: T
): T {
    ...
    val id = snapshot.id
    //At this time, it returns directly without recording the change of state
    if (candidate.snapshotId == id) return candidate
     ...
}

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

Example ④

val darkMode = mutableStateOf("hello")

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

The answer is reorganization

Because this state was created before shooting, state snapshotId!= Snapshot. The global accounting period will not be modified immediately after the snapshot is modified. However, it will not be marked as "apply valid":

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
}

The observer ApplyObserver will be notified when applying (the writer observer was also mentioned just now), and the changed will be recorded:

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

Composition will find out the scope that has observed the corresponding change status and mark it as invalid to wait 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 }
    }
}

Example ⑤

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

If you put the state Declaration on the outermost layer of kt file, will it be reorganized?

The answer is no, because in kotlin, if variables are not placed in classes, they are directly placed at the top of the file. After compilation, a file will be generated, and this attribute will become static.

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

Therefore, this example involves class initialization:

Only when you actively request a class, this class will be initialized. It only contains static variables, functions, and other static things

That is, in this example, initialization is only performed when onlyDisplay is called, so its state snapshotId==snapshot. ID. at this time, the first combination has not been completed. This time, the invalideresult = = ignore will not be recorded as modified. It is the same problem as example ③.

For my inventory, please click My GitHub Free collection

Topics: Android Back-end