Skip to content

Commit e44c7d8

Browse files
committed
Align scene snapshot-apply to Android; tidy FrameRecomposer queue naming
Replaces the un-Android between-phase Snapshot.sendApplyNotifications() that postponeInvalidation ran around every scene operation with the apply model Android actually uses. - postponeInvalidation no longer applies between the measure/layout and draw sub-phases; Android's Choreographer runs measure -> layout -> draw in one traversal and never applies between phases. - BaseComposeScene.draw() advances the global snapshot via Snapshot.notifyObjectsInitialized() right before drawing, mirroring AndroidComposeView.dispatchDraw (post-385d71d1ec5): lighter than sendApplyNotifications and coalesces a placement-write cascade into one frame. - FrameRecomposer.performFrame drives applies via its pump + the per-context GlobalSnapshotManager, so deferred owner-invalidation lands on frame/input boundaries. - OffsetToFocusedRect: document the remaining between-phase-apply dependency (iOS FocusableAboveKeyboard) as a FIXME. Also tidies FrameRecomposer's two-queue naming to match AndroidUiDispatcher: effectDispatcher/recomposeDispatcher -> trampolineDispatcher/frameDispatcher and performScheduledEffects/performScheduledRecomposerTasks -> performTrampolineDispatch/performFrameDispatch, with KDoc stating each rolls its loop synchronously.
1 parent d255524 commit e44c7d8

3 files changed

Lines changed: 72 additions & 64 deletions

File tree

compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/layout/OffsetToFocusedRect.skiko.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,10 @@ internal fun OffsetToFocusedRect(
9797
// Intentionally update state within composition to trigger second measure and
9898
// layout because focus rect may be miscalculated due to simultaneous offset and
9999
// window insets changes.
100+
//
101+
// FIXME: this "second measure" only settles in-frame because BaseComposeScene.draw()
102+
// currently calls Snapshot.sendApplyNotifications() between the measure and draw phases -
103+
// a temporary, un-Android workaround kept solely for this code path.
100104
currentOffset = startOffset + (endOffset - startOffset) * offsetProgress
101105

102106
val placeables = measurables.fastMap { it.measure(constraints) }

compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/FrameRecomposer.skiko.kt

Lines changed: 47 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -40,22 +40,21 @@ import kotlinx.coroutines.withContext
4040
* (`AndroidComposeView` + the host recomposer + `Choreographer`).
4141
*
4242
* Two work queues mirror `AndroidUiDispatcher`'s two queues:
43-
* - [effectDispatcher] (Android's `toRunTrampolined`): coroutine dispatch, composition effects
43+
* - [trampolineDispatcher] (Android's `toRunTrampolined`): coroutine dispatch, composition effects
4444
* (`LaunchedEffect`, `rememberCoroutineScope` launches) and the recomposer's effect context;
45-
* - [recomposeDispatcher] (Android's `toRunOnFrame`), together with [frameClock]: `withFrameNanos`
46-
* awaiters and recomposition (the recomposition loop runs on `recomposeDispatcher + frameClock`).
45+
* - [frameDispatcher] (Android's `toRunOnFrame`), together with [frameClock]: `withFrameNanos`
46+
* awaiters and recomposition (the recomposition loop runs on `frameDispatcher + frameClock`).
4747
*
4848
* Both are [FlushCoroutineDispatcher]s layered over the host's real dispatcher, so on a host with a
4949
* live native loop they drain automatically (like Android's `Handler.post`); [performFrame] and the
50-
* scene phases also drain them explicitly via [performScheduledEffects] /
51-
* [performScheduledRecomposerTasks].
50+
* scene phases also roll them synchronously via [performTrampolineDispatch] / [performFrameDispatch].
5251
*
5352
* Android drives frames through `Choreographer.doFrame`; non-Android platforms have no such hook, so
5453
* the host calls [performFrame] explicitly before driving scene measure/layout and draw.
5554
*
5655
* The host dispatcher must be confined to a single thread, so [composeThreadId] is stable - the
5756
* analog of Android's UI [android.os.Looper] thread. It is recorded whenever the recomposer runs on
58-
* the host thread (via [performScheduledRecomposerTasks]) and read by [runOnComposeThread].
57+
* the host thread (via [performFrameDispatch]) and read by [runOnComposeThread].
5958
*/
6059
@InternalComposeUiApi
6160
class FrameRecomposer(
@@ -65,26 +64,32 @@ class FrameRecomposer(
6564
private val job = Job()
6665
private val coroutineScope = CoroutineScope(coroutineContext + job)
6766

68-
/** Trampoline queue: coroutine dispatch / composition effects / scheduled apply notifications. */
69-
private val effectDispatcher = FlushCoroutineDispatcher(coroutineScope)
67+
/**
68+
* Trampoline queue (Android's `toRunTrampolined`): coroutine dispatch / composition effects /
69+
* scheduled apply notifications. Rolled synchronously by [performTrampolineDispatch].
70+
*/
71+
private val trampolineDispatcher = FlushCoroutineDispatcher(coroutineScope)
7072

71-
/** Frame queue: `withFrameNanos` awaiters and recomposition tasks. */
72-
private val recomposeDispatcher = FlushCoroutineDispatcher(coroutineScope)
73+
/**
74+
* Frame queue (Android's `toRunOnFrame`): `withFrameNanos` awaiters and recomposition tasks.
75+
* Rolled synchronously by [performFrameDispatch].
76+
*/
77+
private val frameDispatcher = FlushCoroutineDispatcher(coroutineScope)
7378

7479
/**
7580
* The clock that drives the recomposition loop. Its `withFrameNanos` awaiters are resumed by
7681
* [performFrame]; new awaiters request a frame via [invalidate].
7782
*/
7883
private val frameClock = BroadcastFrameClock(::onNewAwaiters)
7984

80-
private val recomposer = Recomposer(coroutineContext + job + effectDispatcher)
85+
private val recomposer = Recomposer(coroutineContext + job + trampolineDispatcher)
8186

8287
/**
8388
* Id of the host (compose) thread. The analog of Android's check
8489
* `handler.looper === Looper.myLooper()`: snapshot-observer callbacks run inline when on this
85-
* thread, otherwise they are posted to the shared [effectDispatcher].
90+
* thread, otherwise they are posted to the shared [trampolineDispatcher].
8691
*
87-
* Recorded only in [performScheduledRecomposerTasks] - i.e. only where the recomposer actually
92+
* Recorded only in [performFrameDispatch] - i.e. only where the recomposer actually
8893
* executes on the host thread (driven by [performFrame] each frame, and by `setContent` so the
8994
* thread is established before the first frame) - so a stray off-thread call can never corrupt
9095
* which thread is the compose thread.
@@ -107,7 +112,7 @@ class FrameRecomposer(
107112
"FrameRecomposer requires a ContinuationInterceptor in its coroutineContext"
108113
}
109114
coroutineScope.launch(
110-
recomposeDispatcher + frameClock,
115+
frameDispatcher + frameClock,
111116
start = CoroutineStart.UNDISPATCHED
112117
) {
113118
recomposer.runRecomposeAndApplyChanges()
@@ -146,23 +151,8 @@ class FrameRecomposer(
146151
* the trampoline and run on the next turn (after draw), matching Android.
147152
*/
148153
fun performFrame(frameTimeNanos: Long) {
149-
// It's usually handled by [GlobalSnapshotManager], but currently there are a few places
150-
// that require synchronous execution, so this guard is for compatibility.
151-
Snapshot.sendApplyNotifications()
152-
153-
recomposeFrame(frameTimeNanos)
154-
}
155-
156-
/**
157-
* Advances only the host recomposer and frame clock by one frame at [frameTimeNanos].
158-
*/
159-
private fun recomposeFrame(frameTimeNanos: Long) {
160154
postponeFrameInvalidation {
161-
// Flush composition effects (e.g. LaunchedEffect, coroutines launched in
162-
// rememberCoroutineScope()) queued by the previous turn must run before
163-
// recomposition tasks and frame-clock awaiters.
164-
performScheduledEffects()
165-
performScheduledRecomposerTasks()
155+
performFrameDispatch()
166156

167157
frameClock.sendFrame(frameTimeNanos)
168158
}
@@ -174,8 +164,8 @@ class FrameRecomposer(
174164
/** Returns whether the host still has recomposition or loop work to process. */
175165
fun hasPendingWork(): Boolean =
176166
recomposer.hasPendingWork ||
177-
effectDispatcher.hasImmediateTasks() ||
178-
recomposeDispatcher.hasImmediateTasks() ||
167+
trampolineDispatcher.hasImmediateTasks() ||
168+
frameDispatcher.hasImmediateTasks() ||
179169
frameClock.hasAwaiters
180170

181171
/** Cancels the host recomposer and releases host-owned resources. */
@@ -198,7 +188,7 @@ class FrameRecomposer(
198188
* Runs [block] on the compose thread: inline when already on it, otherwise [dispatch]ed onto
199189
* the shared trampoline queue. The non-Android analog of `AndroidComposeView`'s
200190
* `if (uiThread) cmd() else handler.post(cmd)`. This is the executor for owners' snapshot
201-
* observers; off-thread commands therefore ride [effectDispatcher] (one shared queue, like
191+
* observers; off-thread commands therefore ride [trampolineDispatcher] (one shared queue, like
202192
* Android's single UI looper) and are reflected in [hasPendingWork].
203193
*/
204194
internal fun runOnComposeThread(block: () -> Unit) {
@@ -207,30 +197,40 @@ class FrameRecomposer(
207197

208198
/**
209199
* Enqueues [block] onto the trampoline queue; it runs on the next loop turn or the next
210-
* [performScheduledEffects]. The non-Android analog of `Handler.post`.
200+
* [performTrampolineDispatch]. The non-Android analog of `Handler.post`.
211201
*/
212202
internal fun dispatch(block: () -> Unit) {
213-
effectDispatcher.dispatch(job, Runnable(block))
203+
trampolineDispatcher.dispatch(job, Runnable(block))
214204
}
215205

216206
/**
217-
* Runs the frame queue (pending `withFrameNanos`/recompose tasks) and records the compose thread:
218-
* this is where the recomposer (`runRecomposeAndApplyChanges`) executes on the host thread, so it
219-
* is the single place [composeThreadId] is set. Driven by [performFrame] each frame, and by
220-
* `BaseComposeScene.setContent` so the compose thread is established before the first frame.
207+
* Synchronously rolls the frame loop: drains the [frameDispatcher] queue (pending
208+
* `withFrameNanos` / recompose tasks) after first rolling the trampoline loop via
209+
* [performTrampolineDispatch]. This is also the single place the recomposer
210+
* (`runRecomposeAndApplyChanges`) executes on the host thread, so it is where [composeThreadId]
211+
* is recorded. Driven by [performFrame] each frame, and by `BaseComposeScene.setContent` so the
212+
* compose thread is established before the first frame.
213+
*
214+
* The non-Android analog of `AndroidUiDispatcher.performFrameDispatch`.
221215
*/
222-
internal fun performScheduledRecomposerTasks(): Unit =
223-
trace("FrameRecomposer:performScheduledRecomposerTasks") {
216+
internal fun performFrameDispatch(): Unit =
217+
trace("FrameRecomposer:performFrameDispatch") {
224218
composeThreadId = getCurrentThreadId()
225-
recomposeDispatcher.flush()
219+
performTrampolineDispatch()
220+
frameDispatcher.flush()
226221
}
227222

228223
/**
229-
* Runs the trampoline queue (coroutine dispatch / composition effects / scheduled apply
230-
* notifications).
224+
* Synchronously rolls the trampoline loop: first flushes pending snapshot apply notifications
225+
* (so writes made since the last turn are visible to the queued work), then drains the
226+
* [trampolineDispatcher] queue (coroutine dispatch / composition effects).
227+
*
228+
* The non-Android analog of `AndroidUiDispatcher.performTrampolineDispatch`.
231229
*/
232-
internal fun performScheduledEffects(): Unit =
233-
trace("FrameRecomposer:performScheduledEffects") {
234-
effectDispatcher.flush()
230+
internal fun performTrampolineDispatch(): Unit =
231+
trace("FrameRecomposer:performTrampolineDispatch") {
232+
Snapshot.sendApplyNotifications()
233+
234+
trampolineDispatcher.flush()
235235
}
236236
}

compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/scene/BaseComposeScene.skiko.kt

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -74,19 +74,8 @@ internal abstract class BaseComposeScene(
7474
if (isInvalidationDisabled) return block()
7575
isInvalidationDisabled = true
7676
return try {
77-
// Keep the same scene-boundary snapshot behavior the previous combined render path had
78-
// via SnapshotInvalidationTracker.sendAndPerformSnapshotChanges(): first send global
79-
// apply notifications, then run only this scene's queued owner-observer callbacks.
80-
// This makes snapshot reads that affect layout/draw visible before the phase starts,
81-
// but keeps the tracker scene-local;
82-
Snapshot.sendApplyNotifications()
83-
8477
block()
8578
} finally {
86-
// This is the previous wrapper's trailing checkpoint written out explicitly.
87-
// It lets state writes produced during the phase enqueue layout/draw invalidations
88-
// before the native platform decides whether another layout or draw pass is needed.
89-
Snapshot.sendApplyNotifications()
9079
isInvalidationDisabled = false
9180
}.also {
9281
invokeInvalidationCallbacks()
@@ -132,7 +121,7 @@ internal abstract class BaseComposeScene(
132121
* changed parameters can be applied in a separate turn and trigger double
133122
* recomposition when new content is installed.
134123
*/
135-
frameRecomposer.performScheduledRecomposerTasks()
124+
frameRecomposer.performFrameDispatch()
136125
composition?.dispose()
137126
composition = createComposition(
138127
parentCompositionContext = parentCompositionContext ?: frameRecomposer.compositionContext,
@@ -145,7 +134,7 @@ internal abstract class BaseComposeScene(
145134
content = content
146135
)
147136
}
148-
frameRecomposer.performScheduledRecomposerTasks()
137+
frameRecomposer.performFrameDispatch()
149138
}
150139

151140
override fun measureAndLayout() {
@@ -167,10 +156,25 @@ internal abstract class BaseComposeScene(
167156
if (isClosed) return
168157

169158
postponeInvalidation("BaseComposeScene:draw") {
159+
// FIXME: Remove applying the global snapshot here.
160+
// Android never applies the snapshot *between* the layout and draw phases
161+
// (applies happen once per frame on the main looper, not between phases).
162+
// This between-phase apply is a temporary workaround kept only to preserve current
163+
// behavior for OffsetToFocusedRect (iOS FocusableAboveKeyboard).
164+
Snapshot.sendApplyNotifications()
165+
170166
// AndroidComposeView.dispatchDraw() begins with measureAndLayout() so layout changes
171167
// discovered after the host layout traversal are still settled before drawing. Keep
172168
// that trailing layout pass here even though measureAndLayout() is also a public phase.
173169
doMeasureAndLayout()
170+
171+
// Mirror AndroidComposeView.dispatchDraw (commit 385d71d1ec5): advance the global
172+
// snapshot before drawing so writes made since the last pass - including state objects
173+
// created during a prior draw (e.g. an Indication node) - are recorded as modified and
174+
// visible to this draw. Lighter than sendApplyNotifications (only advances; no
175+
// hasPendingChanges guard), and the Android-faithful replacement for the per-phase apply.
176+
Snapshot.notifyObjectsInitialized()
177+
174178
doDraw(canvas)
175179
}
176180
}
@@ -203,7 +207,7 @@ internal abstract class BaseComposeScene(
203207
scaleGestureFactor = scaleGestureFactor,
204208
panGestureOffset = panGestureOffset,
205209
).also {
206-
frameRecomposer.performScheduledEffects()
210+
frameRecomposer.performTrampolineDispatch()
207211
}
208212
}
209213

@@ -234,7 +238,7 @@ internal abstract class BaseComposeScene(
234238
scaleGestureFactor = scaleGestureFactor,
235239
panGestureOffset = panGestureOffset,
236240
).also {
237-
frameRecomposer.performScheduledEffects()
241+
frameRecomposer.performTrampolineDispatch()
238242
}
239243
}
240244

@@ -245,7 +249,7 @@ internal abstract class BaseComposeScene(
245249
override fun sendKeyEvent(keyEvent: KeyEvent): Boolean =
246250
postponeInvalidation("BaseComposeScene:sendKeyEvent") {
247251
inputHandler.onKeyEvent(keyEvent).also {
248-
frameRecomposer.performScheduledEffects()
252+
frameRecomposer.performTrampolineDispatch()
249253
}
250254
}
251255

@@ -260,7 +264,7 @@ internal abstract class BaseComposeScene(
260264
uptimeMillis = timeMillis
261265
)
262266
processRotaryScrollEvent(event).also {
263-
frameRecomposer.performScheduledEffects()
267+
frameRecomposer.performTrampolineDispatch()
264268
}
265269
}
266270

0 commit comments

Comments
 (0)