I don’t understand Jetpack Compose. So I’ve been r...
# compose
m
I don’t understand Jetpack Compose. So I’ve been reading the source code and managed to put together the attached notes. It seems that
Recomposer.composing
runs every time we compose, which means each composition pass takes place in a snapshot. I understand that the snapshot has observers which records which states are read/written during the pass. But what happens when the event handler of a button for example updates a state? By the time such user input is taken, we’re no longer in a nested snapshot with the aforementioned observers. Who notices what when how and does what when how which achieves what?
Copy code
CoroutineScope(AndroidUiDispatcher.Main).launch {
    channel.consumeEach {
        sent.set(false)
        Snapshot.sendApplyNotifications()
    }
}
Snapshot.registerGlobalWriteObserver {
    if (sent.compareAndSet(false, true)) {
        channel.trySend(Unit)
    }
}
I’m aware that
GlobalSnapshotManager
observes all writes, but it just advances the global snapshot and does nothing else. How does this lead to a recomposition?
Copy code
private suspend fun recompositionRunner(
    block: suspend CoroutineScope.(parentFrameClock: MonotonicFrameClock) -> Unit
) {
    val parentFrameClock = coroutineContext.monotonicFrameClock
    withContext(broadcastFrameClock) {
        // Enforce mutual exclusion of callers; register self as current runner
        val callingJob = coroutineContext.job
        registerRunnerJob(callingJob)

        // Observe snapshot changes and propagate them to known composers only from
        // this caller's dispatcher, never working with the same composer in parallel.
        // unregisterApplyObserver is called as part of the big finally below
        val unregisterApplyObserver =
            Snapshot.registerApplyObserver { changed, _ ->
                synchronized(stateLock) {
                        if (_state.value >= State.Idle) {
                            val snapshotInvalidations = snapshotInvalidations
                            changed.fastForEach {
                                if (
                                    it is StateObjectImpl &&
                                        !it.isReadIn(ReaderKind.Composition)
                                ) {
                                    // continue if we know that state is never read in
                                    // composition
                                    return@fastForEach
                                }
                                snapshotInvalidations.add(it)
                            }
                            deriveStateLocked()
                        } else null
                    }
                    ?.resume(Unit)
            }

        addRunning(recomposerInfo)

        try {
            // Invalidate all registered composers when we start since we weren't observing
            // snapshot changes on their behalf. Assume anything could have changed.
            knownCompositions().fastForEach { it.invalidateAll() }

            coroutineScope { block(parentFrameClock) }
        } finally {
            unregisterApplyObserver.dispose()
            synchronized(stateLock) {
                if (runnerJob === callingJob) {
                    runnerJob = null
                }
                deriveStateLocked()
            }
            removeRunning(recomposerInfo)
        }
    }
}
I think it’s the write observer is meant to notify this apply observer, but I still don’t understand anything beyond that.
z
You're on the right track. When a click handler changes state, it triggers the global write observers. Compose’s runtime registers one of those which advances the global snapshot, triggering a snapshot apply observer (also triggered when a change is made inside an explicit snapshot). That is the signal that “something changed”. Compose’s apply observer notes what changed and then requests a new frame (if it hasn't already) and schedules the real work to happen when that frame starts to get produced (see
runRecomposeAndApplyChanges
). It recomposes all the recompose scopes that were invalidated. A scope can be invalidated either because it read some state that was changed (and that change was observed by the apply observer), or explicitly via a
RecomposeScope
object. Recomposing one scope can invalidate more, so it just keeps looping until there are no more invalidated scopes left. Then it commits all those changes, updates modifiers, and starts effects before giving control back to the frame scheduler (Choreographer on Android) to do other phases like layout and drawing.
m
@Zach Klippenstein (he/him) [MOD] Thanks for the pointer. I’ve been able to figure out that the call to
this.resolveParentCompositionContext()
in
AbstractComposeView.ensureCompositionCreated
creates a new
Recomposer
if necessary. The factory used to create the Recomposer for the view tree installs a lifecycle observer, which calls
recomposer.runRecomposeAndApplyChanges()
on create. That‘s what seems to lead to the snapshot apply observer. It seems to entail storing the continuation of the coroutine in
this.workContinuation
to resume it later based on some internal state. I’ve not managed to get the full picture yet. Could you please share some insights I should keep in mind while I look into it?