Skip to content

Align frame snapshot/invalidation with Android's model#3096

Open
Ivan Matkov (MatkovIvan) wants to merge 2 commits into
jb-mainfrom
ivan.matkov/cleanup-task-scheduling
Open

Align frame snapshot/invalidation with Android's model#3096
Ivan Matkov (MatkovIvan) wants to merge 2 commits into
jb-mainfrom
ivan.matkov/cleanup-task-scheduling

Conversation

@MatkovIvan

@MatkovIvan Ivan Matkov (MatkovIvan) commented Jun 7, 2026

Copy link
Copy Markdown
Collaborator

Summary

The non-Android frame pipeline accumulated several workarounds around snapshot apply, owner invalidation, that diverge from what AndroidComposeView / Choreographer actually do. This PR removes those workarounds and aligns the pipeline to Android's canonical model.

Reference model (Android): Choreographer.doFrame() runs the whole sequence recompose → layout → draw
without yielding to the Looper: no queued task (including GlobalSnapshotManager's apply post) runs between phases. Compose Multiplatform was applying snapshots between phases and routing owner invalidations through per-scene async queues - neither has an Android counterpart.

What changes

The branch is structured as reviewable commits:

  1. Replace per-scene SnapshotInvalidationTracker with inline compose-thread invalidation
    Deletes SnapshotInvalidationTracker + its CommandList queue.
    Owner snapshot observers now run inline when already on the compose thread, otherwise post to the host's shared trampoline (FrameRecomposer.runOnComposeThread), mirroring AndroidComposeView.
    Removes the off-thread queues whose were the root of the CMP-7838 / CMP-7067 deadlocks;
    useInUiThread {} reverts to use {}.

  2. Align scene snapshot-apply to Android; tidy FrameRecomposer queue naming
    postponeInvalidation no longer applies between measure/layout and draw.
    draw() advances the snapshot via Snapshot.notifyObjectsInitialized() right before drawing - mirroring
    AndroidComposeView.dispatchDraw, lighter than sendApplyNotifications and coalescing a placement-write cascade into one frame.
    Renames the two FrameRecomposer queues to match AndroidUiDispatcher (trampolineDispatcher/frameDispatcher).

Fixes

  • CMP-10287 Per-scene owner invalidation queue deviates from the Android model

Known follow-up

CMP-10291 OffsetToFocusedRect relies on inter-phase Snapshot apply between layout and draw

Release Notes

N/A

@m-sasha

Copy link
Copy Markdown

Is there a reason to bundle all these small changes into one PR?

@MatkovIvan

Copy link
Copy Markdown
Collaborator Author

Is there a reason to bundle all these small changes into one PR?

Initially, there was only one change + a couple of fixups to make CI green again.
But you're right - in the current shape, we can extract some parts into separate PRs (but not everything). Let me do that...

@MatkovIvan

Ivan Matkov (MatkovIvan) commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator Author

Alexander Maryanovsky (@m-sasha) I've extracted a few independent parts into separate PRs: #3106 #3107 #3108

I'll rebase this one on top once they are merged

// (applies happen once per frame on the main looper, not between phases).
// This between-phase apply is a temporary workaround kept only to preserve current
// behavior for OffsetToFocusedRect (iOS FocusableAboveKeyboard).
Snapshot.sendApplyNotifications()

@ASalavei Andrei Salavei (ASalavei) Jun 9, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If possible - I would make it iOS-only call to be able to eliminate it safely later.

@MatkovIvan Ivan Matkov (MatkovIvan) Jun 9, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought about it, and decided not to introduce such diffs between platforms.
Also, OffsetToFocusedRect is in skikoMain

@@ -200,11 +156,23 @@ internal abstract class BaseComposeScene(
if (isClosed) return

postponeInvalidation("BaseComposeScene:draw") {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had no chance to check the implementation of the SingleComposeSceneRenderingScope - having two embedded postponeInvalidation calls here and on the platform level - is a an overkill. That must be simplified.

@MatkovIvan Ivan Matkov (MatkovIvan) Jun 9, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SingleComposeSceneRenderingScope is just for migration, it should go away there (on platform).
Here is just de-dup/optimization logic.
Probably we can drop this one too at some point, but it's out of the scope for now

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SingleComposeSceneRenderingScope moved postpone logic on the platform level - which mean, the same logic is no longer needed in the compose scene - at least for the rendering-related methods.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SingleComposeSceneRenderingScope is just one of the usages that does it for cross-phase invalidations (to catch if needDraw was raised after layout and down back during draw).
Scene itself has this logic within one phase only.
Both are required for now

@ASalavei Andrei Salavei (ASalavei) Jun 9, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well... Still do not like this approach, but currently I cannot say if it's correct or not.

What I see here: if we want to unlock an ability for the platforms to write an optimized code - the postponeInvalidation logic must be moved the the platform level and platforms must decide how to arrange invalidation, snapshot adjustments and other stuff in the most optimal way.

Personally I cannot say if these changes are what I really need to make the rendering properly optimized on iOS without making bunch of experiments. That's why I'm thinking that current API isn't flexible enough.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see how this postponeInvalidation conflicts with "an optimized code" and why "current API isn't flexible enough".

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Explained in DM: Current API tries to be universal for all platforms, which is not optimal in terms of a specific platform. Let's keep as it is and back to the discussion when face real cases.

…ead invalidation

Removes SnapshotInvalidationTracker and its CommandList queue. Owner snapshot
observers now run directly, mirroring Android's AndroidComposeView: callbacks
execute inline when already on the compose thread, otherwise they are posted to
the host's shared trampoline queue (FrameRecomposer.runOnComposeThread).

- RootNodeOwner takes `invalidate` and `onChangedExecutor` directly, owns its
  own hasPendingMeasureOrLayout / hasPendingDraw flags (now @volatile, since the
  ComposeScene contract allows reading them from any thread), and observes via
  OwnerSnapshotObserver(onChangedExecutor).
- FrameRecomposer gains runOnComposeThread + composeThreadId (recorded where the
  recomposer runs on the host thread) and requires a single-thread
  ContinuationInterceptor in its context.
- BaseComposeScene drops the tracker; invokeInvalidationCallbacks() forwards the
  owners' pending flags to the platform invalidate callbacks.
- ComposeScene drops hasPendingSnapshotCommands (no longer a separate queue);
  hasInvalidations() = hasPendingMeasureOrLayout || hasPendingDraw.
- Scenes pass their owners ::invokeInvalidationCallbacks + ::runOnComposeThread.
- The GlobalSnapshotManager race that forced ImageComposeScene tests onto the UI
  thread is gone, so useInUiThread {} reverts to use {}.

Tests that depend on the new invalidation model land here so this commit stays
green: RenderPhasesTest's scroll/pan cases move to runSkikoComposeUiTest (they
fail under runInternalSkikoComposeUiTest's StandardTestDispatcher now), and
ComposeSceneTest gets a Timeout rule.
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.

@igordmn Igor Demin (igordmn) left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's Alexander Maryanovsky (@m-sasha) also look if we have time, he worked on this before

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants