This is the 8th day of my participation in the More text Challenge. For details, see more text Challenge
Currently, there is an ongoing Chinese manual project for Jetpack Compose, which aims to help developers better understand and master the Compose framework. It is still under construction, and everyone is welcome to follow and join! This article was written by me and has been published in the manual. Please check it out.
For more information about Compose technology, visit the Jetpack Compose Museum on wechat.
preface
As we all know, Jetpack Compose is a set of declarative UI system. When the state that the UI component depends on changes, it automatically redraws and refreshes. This process is officially called reorganization. For more information, see “Does recomposition affect performance? Let’s talk about the Recomposition Scope”. For more information, see “Jetpack Compose snapshot System”. This article will take a look at how the status update to recompose process works in the Compose source code, and show you how the snapshot system is used in the recompose process.
meaning
In this article, the recompose process is explained by reading the source code, which is a very tedious process and has a lot of logical branching that makes it confusing for many people. In this paper, all logical branches unrelated to the main flow are removed, and the logical expression is carried out in combination with drawings, hoping to help you understand the working principle of Recompose. Through this article, I hope you can have a perceptual understanding of the working principle of Recompose, and further explore various technical details in the recompose process based on this article.
⚠️ Tips: Due to the complexity of the Recompose process, this article has so far only described the recompose mainline process, without delving into many of the technical details included. As I use the combination of dynamic and static methods for source code analysis, it is inevitable that some cases are not covered by the case process. If there are any mistakes in this paper, please bring them up.
Recompose process analysis
Start with the MutableState update
When you assign a value to MutableState, the default extension method of MutableState is called mutableState.setValue
// androidx.compose.runtime.SnapshotState
inline operator fun <T> MutableState<T>.setValue(thisObj: Any? , property:KProperty<*>, value: T) {
this.value = value
}
Copy the code
By looking at the mutableStateOf source code, we can see that MutableState is actually an instance of the SnapshotMutableStateImpl type
// androidx.compose.runtime.SnapshotState
fun <T> mutableStateOf(
value: T,
policy: SnapshotMutationPolicy<T> = structuralEqualityPolicy()
): MutableState<T> = createSnapshotMutableState(value, policy)
// androidx.compose.runtime.ActualAndroid.android
internal actual fun <T> createSnapshotMutableState(
value: T,
policy: SnapshotMutationPolicy<T>): SnapshotMutableState<T> = ParcelableSnapshotMutableState(value, policy)
// androidx.compose.runtime.ParcelableSnapshotMutableState
internal class ParcelableSnapshotMutableState<T>(
value: T,
policy: SnapshotMutationPolicy<T>
) : SnapshotMutableStateImpl<T>(value, policy), Parcelable
Copy the code
The setter for this property is called when the value property changes, and of course the getter is called when the state is read.
Next at this point is an instance of StateStateRecord, which actually records the current state information (as indicated by getters and setters for the current value). In this case, the current value and the value to be updated are diff judged according to the rule. The overwritable method of StateStateRecord is called when the change is determined.
// androidx.compose.runtime.SnapshotState
internal open class SnapshotMutableStateImpl<T>(
value: T,
override val policy: SnapshotMutationPolicy<T>
) : StateObject, SnapshotMutableState<T> {
@Suppress("UNCHECKED_CAST")
override var value: T
get() = next.readable(this).value
set(value) = next.withCurrent {
if(! policy.equivalent(it.value, value)) {// This is still the current SnapshotMutableStateImpl
next.overwritable(this, it) {
this.value = value // This refers to next, which updates the value in next}}}...private var next: StateStateRecord<T> = StateStateRecord(value)
}
Copy the code
If you update mutableState in an asynchronous execution block, If the Snapshot of the current thread is null, GlobalSnapshot is returned. If the Snapshot of the current thread is null, GlobalSnapshot is returned. If you update mutableState directly in the Composable, the Snapshot of the executing thread for the current Composable is a MutableSnapshot. This will affect the subsequent recompose execution process.
⚠️ Tips: Browsing through the source code reveals that GlobalSnapshot is actually a subclass of MutableSnapShot
// androidx.compose.runtime.snapshots.Snapshot
internal inline fun <T : StateRecord, R> T.overwritable(
state: StateObject,
candidate: T,
block: T. () - >R
): R {
var snapshot: Snapshot = snapshotInitializer
return sync {
snapshot = Snapshot.current
this.overwritableRecord(state, snapshot, candidate).block() / / update next
}.also {
notifyWrite(snapshot, state) // Write the notification}}Copy the code
So let’s go into overwritableRecord and see what’s going on there, and notice that state is actually a mutableState. This is where the change is recorded through the recordModified method. We can see that the current modified state is added to the modified version of the current Snapshot, which will be used later.
// androidx.compose.runtime.snapshots.Snapshot
internal fun <T : StateRecord> T.overwritableRecord(
state: StateObject,
snapshot: Snapshot,
candidate: T
): T {
if (snapshot.readOnly) {
snapshot.recordModified(state)
}
val id = snapshot.id
if (candidate.snapshotId == id) return candidate
val newData = newOverwritableRecord(state, snapshot)
newData.snapshotId = id
snapshot.recordModified(state) // Record the modification
return newData
}
// androidx.compose.runtime.snapshots.Snapshot
override fun recordModified(state: StateObject){ (modified ? : HashSet<StateObject>().also { modified = it }).add(state) }Copy the code
In case you’re wondering if mutableState updates are in the ComposeScope file, here’s an example. If recompose can be executed in the ComposeScope, it will not be executed in the ComposeScope.
This is explained later in the takeMutableSnapshot read observer and write observer section.
var display by mutableStateOf("Init")
@Preview
@Composable
fun Demo(a) {
Text (
text = display,
fontSize = 50.sp,
modifier = Modifier.clickable {
display = "change" // Recompose cannot be used for GlobalSnapshot
}
)
display = "change" // Recompose can be executed until, at this point, MutableSnapShot
}
Copy the code
The next thing you do is you do event notification via notifyWrite and you see that the writeObserver is called.
// androidx.compose.runtime.snapshots.Snapshot
@PublishedApi
internal fun notifyWrite(snapshot: Snapshot, state: StateObject){ snapshot.writeObserver? .invoke(state) }Copy the code
Different writeObservers are called depending on the current Snapshot.
GlobalSnapshot Write notification
Global write observer is conducted in the setContent registration, at this point the callback registerGlobalWriteObserver tail lambda, you can see there is a channel (yes is Kotlin coroutines that thermal data channel channel). It is easy to see that the pending consumption takes place in the CoroutineScope above with AndroidUidisPatcher.main as the scheduler, so the execution flow naturally goes to sendApplyNotifications(). (Ographer, AndroidUiDispatcher.Main is closely related with Choreographer, so I won’t discuss it anymore, you can follow the source code if you are interested.)
internal object GlobalSnapshotManager {
private val started = AtomicBoolean(false)
fun ensureStarted(a) {
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
sendApplyNotifications
Next, we go to sendApplyNotifications() to see what’s going on. You can see that “modified” is used here. Changes must be true when a change occurs. So advanceGlobalSnapshot is then called
// androidx.compose.runtime.snapshots.Snapshot
fun sendApplyNotifications(a) {
val changes = sync {
currentGlobalSnapshot.get().modified? .isNotEmpty() ==true
}
if (changes)
advanceGlobalSnapshot()
}
Copy the code
We move on to advanceGlobalSnapshot, where we take all modified-in and facilitate the call to all observers included in applyObservers.
// androidx.compose.runtime.snapshots.Snapshot
private fun advanceGlobalSnapshot(a) = advanceGlobalSnapshot { }
private fun <T> advanceGlobalSnapshot(block: (invalid: SnapshotIdSet) - >T): T {
val previousGlobalSnapshot = currentGlobalSnapshot.get(a)val result = sync {
takeNewGlobalSnapshot(previousGlobalSnapshot, block)
}
val modified = previousGlobalSnapshot.modified
if(modified ! =null) {
val observers: List<(Set<Any>, Snapshot) -> Unit> = sync { applyObservers.toMutableList() }
observers.fastForEach { observer ->
observer(modified, previousGlobalSnapshot)
}
}
....
return result
}
Copy the code
The applyObservers recompositionRunne
To my investigation this applyObservers contains only two of the observer, a is SnapshotStateObserver applyObserver status information is used to update the snapshot, The other is what recompositionRunner uses to process the Recompose process. Since we are studying the Recompose process, there is no point in discussing it. Let’s take a look at what recompose’s observer does. First, it adds all the changed mutablestates to its snapshotInvalidations, which will be used later. You can see later that there is a resume, indicating that the last call to deriveStateLocked for lambda returns an instance of a coroutine Continuation. So let’s go to deriveStateLocked and see who this coroutine Continuation instance is.
// androidx.compose.runtime.Recomposer
@OptIn(ExperimentalComposeApi::class)
private suspend fun recompositionRunner(
block: suspend CoroutineScope. (parentFrameClock: MonotonicFrameClock) - >Unit
) {
withContext(broadcastFrameClock) {
...
// This is the person responsible for handling recompose's observer
val unregisterApplyObserver = Snapshot.registerApplyObserver {
changed, _ ->
synchronized(stateLock) {
if (_state.value >= State.Idle) {
snapshotInvalidations += changed
deriveStateLocked()
} else null}? .resume(Unit)}... }}Copy the code
You can see from the return value that this is an unusable Continuation instance, a Continuation,
// androidx.compose.runtime.Recomposer
private fun deriveStateLocked(a): CancellableContinuation<Unit>? {...return if (newState == State.PendingWork) {
workContinuation.also {
workContinuation = null}}else null
}
Copy the code
So where was this workContinuation assigned? It’s easy to find the unique place where it was assigned. In this case, a continuation is co, and in this case, resume is resuming execution of the awaitWorkAvailable call.
// androidx.compose.runtime.Recomposer
private suspend fun awaitWorkAvailable(a) {
if(! hasSchedulingWork) { suspendCancellableCoroutine<Unit> { co ->
synchronized(stateLock) {
if (hasSchedulingWork) {
co.resume(Unit)}else {
workContinuation = co
}
}
}
}
}
Copy the code
RunRecomposeAndApplyChanges three steps
We can find runRecomposeAndApplyChanges invokes the awaitWorkAvailable to generate hangs, so at this time to return calls runRecomposeAndApplyChanges, There are three main steps to follow
// androidx.compose.runtime.Recomposer
suspend fun runRecomposeAndApplyChanges(a) = recompositionRunner { parentFrameClock ->
val toRecompose = mutableListOf<ControlledComposition>()
val toApply = mutableListOf<ControlledComposition>()
while (shouldKeepRecomposing) {
awaitWorkAvailable()
// Resume execution from here
if (
synchronized(stateLock) {
if(! hasFrameWorkLocked) {/ / step 1recordComposerModificationsLocked() ! hasFrameWorkLocked }else false})continue
// Wait for the Vsync signal, similar to scheduleTraversals?
parentFrameClock.withFrameNanos { frameTime ->
...
trace("Recomposer:recompose") {
synchronized(stateLock) {
recordComposerModificationsLocked()
/ / step 2
compositionInvalidations.fastForEach { toRecompose += it }
compositionInvalidations.clear()
}
val modifiedValues = IdentityArraySet<Any>()
val alreadyComposed = IdentityArraySet<ControlledComposition>()
while (toRecompose.isNotEmpty()) {
try {
toRecompose.fastForEach { composition ->
alreadyComposed.add(composition)
/ / step 3performRecompose(composition, modifiedValues)? .let { toApply += it } } }finally{ toRecompose.clear() } .... }... }}}}Copy the code
For these three steps, we respectively is first step 1 call the recordComposerModificationsLocked method, remember snapshotInvalidations, he records all changes mutableState, This calls back to the recordModificationsOf method for all known composition.
// androidx.compose.runtime.Recomposer
private fun recordComposerModificationsLocked(a) {
if (snapshotInvalidations.isNotEmpty()) {
snapshotInvalidations.fastForEach { changes ->
knownCompositions.fastForEach { composition ->
composition.recordModificationsOf(changes)
}
}
snapshotInvalidations.clear()
if(deriveStateLocked() ! =null) {
error("called outside of runRecomposeAndApplyChanges")}}}Copy the code
All Composable Scopes that depend on the current mutableState are stored in the compositionInvalidations List through a series of calls.
// androidx.compose.runtime.Recomposer
internal override fun invalidate(composition: ControlledComposition) {
synchronized(stateLock) {
if (composition !in compositionInvalidations) {
compositionInvalidations += composition
deriveStateLocked()
} else null}? .resume(Unit)}Copy the code
Step 2 is simple: move all elements of compositionInvalidations to toRecompose, while step 3 is the most important part of recompose. Re-execute all affected Composable scopes via performRecompose.
performRecompose
For example, we can see that performRecompose is being used indirectly. while recompose is currently working on a compose, we need to look into this composing to see when a callback is possible.
// androidx.compose.runtime.Recomposer
private fun performRecompose(
composition: ControlledComposition,
modifiedValues: IdentityArraySet<Any>?: ControlledComposition? {
if (composition.isComposing || composition.isDisposed) return null
return if (
composing(composition, modifiedValues) {
if(modifiedValues? .isNotEmpty() ==true) {
composition.prepareCompose {
modifiedValues.forEach { composition.recordWriteOf(it) }
}
}
composition.recompose() // Where recompose really occurs
}
) composition else null
}
Copy the code
A snapshot was first taken inside my composing process, and our recompose process was executed into this snapshot, and finally apply. For more information about the snapshot system, see Jetpack Compose · Snapshot System.
// androidx.compose.runtime.Recomposer
private inline fun <T> composing(
composition: ControlledComposition,
modifiedValues: IdentityArraySet<Any>? , block: () ->T
): T {
val snapshot = Snapshot.takeMutableSnapshot(
readObserverOf(composition), writeObserverOf(composition, modifiedValues)
)
try {
return snapshot.enter(block)
} finally {
applyAndCheck(snapshot)
}
}
Copy the code
TakeMutableSnapshot read observer vs. write observer
Note that the takeMutableSnapshot method is called with both a read observer and a write observer. When do the two observers call back? So every time we recompose, we’re going to take a snapshot, and then our re-execution is going to be executed in that snapshot, and during the re-execution if there’s a read or write to the mutableState we’re going to call back both the read observer and the write observer. And that means that every time you recompose, you’re going to rebind. The timing of the read observer callback is easy to understand. What is the timing of the write observer callback? Remember when we started with GlobalSnapshot and MutableSnapshot?
Up to this point we have been analyzing the GlobalSnapshot execution. Calling takeMutableSnapshot will return a MutableSnapshot instance, Our recompose recompose process occurs in the enter method of the current MutableSnapshot instance. Calling snapshot.current during the recompose process will return the current MutableSnapshot instance. So a write that occurs during the rerun calls back to the write observer passed in by takeMutableSnapshot. In this case, when the Demo is recompose, the Snapshot where display is located is the Snapshot of the MutableSnapshot taken.
var display by mutableStateOf("Init")
@Preview
@Composable
fun Demo(a) {
Text (
text = display,
fontSize = 50.sp
)
display = "change" // Recompose can be executed until, at this point, MutableSnapShot
}
Copy the code
MutableSnapshot write notification
Next, let’s look at how write observer for takeMutableSnapshot is implemented. The updated value is passed to the recordWriteOf method of the current Recompose composition.
// androidx.compose.runtime.Recomposer
private fun writeObserverOf(
composition: ControlledComposition,
modifiedValues: IdentityArraySet<Any>?: (Any) -> Unit {
return{ value -> composition.recordWriteOf(value) modifiedValues? .add(value) } }Copy the code
Through the analysis of the process, it is found that in fact, when the state write operation is performed in the recompose process, the recompose process is not performed immediately by the write observer, but waits until the apply operation is performed after the current recompose process ends.
applyAndCheck
Returning to the composing method for Recomposer, we are using applyAndCheck to complete the subsequent apply operation. ApplyAndCheck internally uses mutablesnapshot.apply
// androidx.compose.runtime.Recomposer
private inline fun <T> composing(
composition: ControlledComposition,
modifiedValues: IdentityArraySet<Any>? , block: () ->T
): T {
val snapshot = Snapshot.takeMutableSnapshot(
readObserverOf(composition), writeObserverOf(composition, modifiedValues)
)
try {
return snapshot.enter(block)
} finally {
applyAndCheck(snapshot) / / here}}private fun applyAndCheck(snapshot: MutableSnapshot) {
val applyResult = snapshot.apply()
if (applyResult is SnapshotApplyResult.Failure) {
error(
"Unsupported concurrent change during composition. A state object was " +
"modified by composition as well as being modified outside composition.")}}Copy the code
ApplyObservers, used in Apply
Let’s go back to mutablesnapshot.apply and see if the current modified is in snapshot.recordModified(state). ApplyObservers are still used for traversal notifications. ApplyObservers is a static variable, so different Global Snapshots and MutableSnapshot can be shared, The recompose process is still handled by pre-subscribing recompositionRunner, as described in applyObservers, and the recompose process is exactly the same.
// androidx.compose.runtime.snapshots.Snapshot
open fun apply(a): SnapshotApplyResult {
val modified = modified
....
val (observers, globalModified) = sync {
validateOpen(this)
if (modified == null || modified.size == 0) {... }else{... applyObservers.toMutableList() to globalModified } } ....if(modified ! =null && modified.isNotEmpty()) {
observers.fastForEach {
it(modified, this)}}return SnapshotApplyResult.Success
}
Copy the code