Skip to content

Reduce Android recomposition overhead and add opt-in rendering controls - #500

Merged
marcprux merged 15 commits into
skiptools:mainfrom
tifroz:experimental_animation-performance
Sep 23, 2026
Merged

marcprux merged 15 commits into
skiptools:mainfrom
tifroz:experimental_animation-performance

Conversation

@tifroz

@tifroz tifroz commented Jul 29, 2026 •

Copy link
Copy Markdown
Contributor

Skip Pull Request Checklist:

  • I have signed the Contributor Agreement.
  • I have tested locally with swift test.
  • I have tested on an iOS simulator or device.
  • I have tested on an Android emulator or device.
  • I have checked corresponding changes in SkipFuseUI #134.
  • I have added an example to Showcase.
  • AI assisted this PR. Codex generated code with author supervision and assisted the investigation, regression tests, measurement analysis, and description. Manual UI checks and the automated validation above were used to verify the changes.

Summary

Reduce unnecessary Android recomposition automatically, then offer opt-in controls for expensive subtrees that still benefit from targeted isolation. This PR now includes baseline SkipUI fixes in addition to the rendering APIs used by SkipFuseUI #134.

The benefits are most noticeable in the context of a real-life UI with many views, some of them having substantial re-compose cost.

In one real-life example we had a draggable / animated sheet view. The original run measured 300 ms median window-frame time and continued producing frames while idle (re-compose loop). Fixing the environment feedback loop and narrowing GeometryReader dependencies brought subsequent drag measurements to 25–29 ms median, with no frames produced during the measured idle windows. These improvements did not require adding performance modifiers at the affected call sites.

The boundary, equality, and compositor APIs remain supplemental, opt-in tools, with measured benefits at particular call sites. They are not prerequisites for the baseline fixes.

Baseline improvements

1. Stop a bridged environment provider from repeatedly invalidating itself

Consider a root that supplies a custom design-system value and a child that reads it:

SceneView()
    .environment(\.designSystem, designSystem)

The Swift value crosses the bridge in an opaque wrapper. In the returning evaluation path, the provider and the child's environment lookup can run in the same Compose restart scope:

  1. The root supplies a fresh wrapper for the design-system value.
  2. The child reads it while still executing in the supplying root's scope.
  3. That scope becomes a subscriber to the value it supplies.
  4. The next evaluation supplies another wrapper, invalidates the same scope, and repeats—even when the user is doing nothing.

The diagnosis recorded 47–52 invalidations of the same environment state per roughly five-second idle window. One separate capture recorded 68 environment replacements invalidating 1,020 descendant scopes in five seconds. These are diagnostic counts, not frame counts, but they explain why unrelated UI kept doing work.

The fix suppresses observation only for a lookup of a value supplied by the same composer and restart scope. Independently restartable descendants, inherited values, and other state reads remain observed. Dynamic composition locals are preserved; this does not broadly disable observation or replace them with static locals that invalidate non-readers. Provider ownership exists only during synchronous evaluation and is restored on exit, including failure.

2. Let GeometryReader observe only the geometry its content uses

A sheet can move on every drag update while its width and height remain constant:

GeometryReader { geometry in
    SheetContents(availableWidth: geometry.size.width)
}

Previously, creating the proxy eagerly subscribed its content to global bounds and safe-area changes. Moving the sheet could therefore reevaluate size-only content and repeat row/text layout even though the width used by that content had not changed.

The fix separates measured size, global frame, and inset dependencies using equality-checked state projections. Size-only content ignores position-only changes; global-frame readers still receive movement and clipping updates; inset readers still receive actual inset changes. Coordinate updates are not frozen, and absolute safe-area handling elsewhere is unchanged. Existing SkipFuseUI proxy access uses this automatically.

3. Stop consumed animation provenance from animating later plain updates

A separate drag problem occurs with a combined offset:

content.offset(y: animatedAnchor + dragOffset)

After animatedAnchor finishes changing inside withAnimation, its old stamp can be read again when only dragOffset changes. The drag then keeps animating toward the finger instead of following the plain update immediately.

SkipUI now acknowledges captured native provenance after successful composition using SideEffect. The paired native implementation expires the source stamp for future reads while preserving animations already captured by sibling modifiers. In the paired physical-device reproduction, a plain target of 120 was still at 81.78 after 80 ms without expiry; with expiry it was at 120.18. Fresh animated writes still animate.

This PR contains the SkipUI callback support. The corresponding SkipFuseUI stamp-expiry implementation is included in SkipFuseUI #134. This part requires both implementations, unlike the environment and GeometryReader fixes above.

Physical-device measurements

Samsung SM-S721U, Android 16; repeated controlled sheet-handle drags with retained content. Frame-timing windows had no active composition observer, profiler, or screen recording.

Stage Frames during eight-second idle window Drag median / p95
SkipUI without this patch, repeated baseline 92 300 / 350 ms
+ Environment fix present, fresh pre-geometry baseline 0 129 / 150 ms
+ Selective geometry dependencies 0 25 / 32 ms

These are Debug-build Android gfxinfo window-frame histogram values, not touch-to-display latency, display FPS, or Release benchmarks. The rows show successive investigation stages, not one randomized comparison. Existing opt-in modifiers remained in place during these baseline measurements. The geometry comparison changed the framework implementation without adding or removing those call sites. The patched runs still missed 12 of 30 and 15 of 30 measured frame deadlines; this is not a claim that all animation cost is gone.

Separate Perfetto traces support the mechanism: average recomposer-pass duration fell from 50.51 ms to 4.19 ms across 42 passes in each trace. Text-layout initialization slices fell from 1,824 to zero in those traced windows. Nested trace durations overlap and must not be added together.

Supplemental opt-in enhancements

Use these after measuring the remaining expensive interaction:

  • androidEquatable reuses evaluated content while its complete equality key is unchanged. For example, a menu's expensive action content can remain stable while the surrounding menu animates. A repeated comparison measured 61 ms p95 with the top-action gate versus 133 ms without it. Median and frame counts varied, so this supports targeted use rather than a universal speedup claim.
  • androidCompositionBoundary retains an independent Compose host and lifecycle. Stable id and inputs reuse the projection; changed inputs update content without replacing the host, and parent constraints still remeasure it. A media-panel drag comparison measured 10/13 ms median/p95 with the boundary versus 10/53 ms without it. Conversely, bypassing an active-WebView boundary showed no meaningful bookmarks-drag improvement or regression in the tested samples. Boundaries are not needed everywhere.
  • compositorTransform combines scale and translation in one graphics layer while keeping layout position and measured size stationary. In a native-WebView popup transition, replacing it with chained scaleEffect/offset lost the horizontal transition despite similar frame statistics. Its value here is correct visual behavior, not a claimed timing improvement.

These comparisons bypassed one call site at a time after the baseline fixes. They do not establish the effect of removing all controls together or their necessity for every lifecycle interaction.

For example, an equality gate can isolate stable label content while keeping a changing action outside it:

ContactLabel(contact: contact)
    .androidEquatable(recomposeOverride: contact)
    .onTapGesture { onSelect(contact) }

The key must include every body-affecting external input, including relevant environment and hoisted state. Do not assume state reads inside cached content automatically invalidate the equality cache. Boundaries similarly require complete parent-driven inputs; changing id replaces retained state. They create a separate host and cost more than equality reuse.

Other rendering corrections retained in this PR

  • Preserve nested ForEach child identity and row reordering in stacks.
  • Keep bridged animation provenance attached to its modifier; a new plain target cancels an in-flight interpolation.
  • Preserve content-sized retained-host measurement and avoid transient zero-sized placeholders for constrained, resizable cached images.
  • Avoid a geometry-observation state write during composition.

Validation and limits

  • Recorded SkipUI Robolectric runs cover 17 returning-environment tests, 15 geometry tests, and 13 rendering regressions. The final geometry run passed all 45 tests across those three classes. Coverage includes nested providers, independent readers, ownership restoration, movement versus resize, clipping, insets, conditional dependencies, density, remounting, and content updates.
  • Earlier regression runs also passed the 10 equality, 4 composition-boundary, 9 animation, and 18 transaction tests. These runs overlap with other recorded suites; the counts are not one combined final run.
  • A negative control restoring the eager global-frame read made the position-only GeometryReader regression test fail. Removing the control restored the passing result.
  • Paired animation-lifetime validation passed 17 physical-device Compose tests, 11 native stamp tests, and 7 Apple SwiftUI reference tests on macOS. This includes ordinary siblings, GeometryReader consumers, independent children, screen/dialog consumers, repeated writes, and interruption recovery. The native-side implementation is included in Bridge supported UIApplication API, support .blur modifier #134.
  • Theme changes, filtering, keyboard appearance/dismissal, and returning to the underlying screen were exercised. Swift compilation/transpilation and whitespace checks passed. Release performance, iOS-device interruption parity, rotation/multi-window behavior, and every third-party native-view lifecycle were not established.

The environment fix uses Compose internal composer/scope identity APIs; its ownership tests should be rerun when upgrading Compose. Geometry dependency separation uses public snapshot/derived-state APIs, and provenance acknowledgement uses public SideEffect.

Accepted interruption limitation: a plain drag during an active combined-offset animation snaps the whole offset to its model destination. Apple SwiftUI can instead preserve the anchor's unfinished animation while applying the drag delta. Exact parity is outside this change. Tests require that subsequent drag updates stay immediate, the cancelled animation cannot overwrite later positions, and a fresh animation still works.

The new opt-in modifiers return the original view on non-Android platforms. compositorTransform changes rendered pixels rather than layout geometry.

@cla-bot cla-bot Bot added the cla-signed label Jul 29, 2026
@tifroz tifroz changed the title Experimental animation performance Support for SkipFuseUI Animation Performance Fix (Android) Jul 29, 2026
@tifroz

tifroz commented Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

@marcprux pinging you for a code review (not urgent, but I think this may have fallen through the cracks)

@tifroz
tifroz force-pushed the experimental_animation-performance branch from 45b9111 to 3af2f36 Compare August 22, 2026 20:35
- preserve ForEach and stack composition identity
- improve equatable content caching and bridge equality
- support explicit first-render animation sources
- prevent animation provenance from leaking between modifiers
- document Android rendering performance APIs
- verify state updates refresh content without replacing the native host
- verify parent size changes remeasure the same host
- verify environment values remain available and update inside the boundary
- document that fixed sizing belongs outside the boundary
@tifroz
tifroz force-pushed the experimental_animation-performance branch from 3af2f36 to 96118ff Compare September 16, 2026 03:11
Use static composition locals for custom bridged environment values so returning evaluation cannot subscribe the provider scope to its own fresh wrapper updates. Replacements still invalidate provider content; builtin environment values retain their existing dynamic locals.

Add regression coverage for settling after parent updates, propagation to retained readers, and nested override removal. The loop reproduction fails before the change; all eight rendering regression tests pass with the fix.
Restore dynamic composition locals and suppress observation only when a returning provider reads its own supplied value in the same composition scope. Preserve dependency tracking for descendants, inherited values, and independent state reads.

Add regression coverage for selective reader invalidation, nested providers, and restoration of inherited value tracking.
Notify native provenance captures after successful Compose application so completed writes no longer animate later plain updates. Preserve captured sibling animations and document the accepted interruption behavior.
@tifroz tifroz changed the title Support for SkipFuseUI Animation Performance Fix (Android) Reduce Android recomposition overhead and add opt-in rendering controls Sep 17, 2026
@vincentborko

vincentborko commented Sep 19, 2026 •

Copy link
Copy Markdown
Contributor

Retracted. The screen I measured here was redrawing because of a looping Lottie animation, not a recomposition loop, so this data point says nothing about the fix in this PR. Details: skip-ui#462 correction.

@tifroz

tifroz commented Sep 19, 2026

Copy link
Copy Markdown
Contributor Author

@vincentborko Thanks for testing this and documenting the caveats.

The fix Stop a bridged environment provider from repeatedly invalidating itself targets a particular feedback loop: It occurs when an environment value crosses the Swift-to-Compose bridge in a fresh wrapper, and the supplying provider and a reader execute in the same Compose restart scope. The provider then becomes subscribed to the value it supplies, so replacing that wrapper on the next evaluation invalidates the same scope again—even though the underlying Swift value hasn’t changed.

Whether a screen encounters this depends on how its environment providers and readers are composed; simply using bridged environment values doesn’t necessarily trigger it. We encountered it while investigating a draggable/animated sheet, with recomposition continuing after interaction stopped, but movement itself isn’t a prerequisite.

The PR addresses several other performance issues too, but my guess is that your screen has another cause of continuous idle drawing not addressed here. I’m happy to dig into your specific use case and augment or adjust this PR if you can share a repro Git repository?

@tifroz

tifroz commented Sep 23, 2026

Copy link
Copy Markdown
Contributor Author

@marcprux nudge - I updated last week, adding SkipUI-internal bug fixes for excessive recomposition in Compose (as opposed to the APIs created to give the app some control over unwanted recomposition).

The performance improvements are not noticeable in a demo app, but they make a substantial difference in a real-world app with many moving pieces and a rich UI (expensive recompositions)

@marcprux

Copy link
Copy Markdown
Member

This is a substantial contribution. Thank you!

@marcprux
marcprux merged commit 7c56bff into skiptools:main Sep 23, 2026
2 checks passed
Ceylo added a commit to Ceylo/skip-ui that referenced this pull request Sep 23, 2026
…ptools#530

Menu: skiptools#524 supersedes 6f06ae4; kept the fork's body font and visible divider.
Animation: skiptools#500's new-target cancellation alongside the fork's resume record;
a resume only fires for an unchanged target, so the two do not overlap.
GeometryReader: skiptools#500's GeometryReaderState and lazily-observing proxy, fed the
laid-out frame (positionInRoot + size) rather than the clipped boundsInRoot(),
and still composing on the measure pass through BoxWithConstraints with a
constraints-seeded proxy until placement. actualReaderReportsUnclippedGlobalFrame
pins the frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Ceylo added a commit to Ceylo/skip-ui that referenced this pull request Sep 24, 2026
Belongs to merge 0a6d781, which kept the laid-out frame over skiptools#500's
boundsInRoot(); the test was written after that merge was staged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Ceylo added a commit to Ceylo/skip-ui that referenced this pull request Sep 24, 2026
…size

skiptools#500 (7c56bff) renders a .resizable() image whose painter has no intrinsic
size yet through RenderPainter, to avoid a one-frame 0x0 placeholder for
cached icons. RenderPainter falls back to fillSize without an intrinsic, and
the layout it produces outlives the load: FurAffinity's home screen icon
(.resizable().aspectRatio(contentMode: .fit).frame(width: 100)) took the
whole height of its VStack, pushing the buttons 646 px down and over the
footer. Removing only this branch restores the pre-merge layout exactly
(button bounds [306,1324] and [459,1504], same as before the merge).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vincentborko

Copy link
Copy Markdown
Contributor

thanks a lot for this!

@fhasse95 fhasse95 mentioned this pull request Oct 1, 2026
5 of 7 tasks
Aecasorg added a commit to Aecasorg/skip-ui that referenced this pull request Oct 2, 2026
skiptools#500 renders a resizable asset image before Coil reports its size, so
cached icons don't flash a 0x0 placeholder. With a content mode and no
explicit ratio, RenderPainter then fills all the width it is offered:
an icon sized with resizable().aspectRatio(contentMode: .fit)
.frame(height:) takes a whole HStack row and keeps that width after
Coil reports. Keep the branch for resizable images without a content
mode, which fill their slot either way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants