Repository navigation
Reduce Android recomposition overhead and add opt-in rendering controls - #500
Conversation
|
@marcprux pinging you for a code review (not urgent, but I think this may have fallen through the cracks) |
45b9111 to
3af2f36
Compare
- 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
3af2f36 to
96118ff
Compare
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.
|
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. |
|
@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? |
|
@marcprux nudge - I updated last week, adding 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) |
|
This is a substantial contribution. Thank you! |
…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>
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>
…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>
|
thanks a lot for this! |
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>
Skip Pull Request Checklist:
swift test.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:
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:
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:
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:
After
animatedAnchorfinishes changing insidewithAnimation, its old stamp can be read again when onlydragOffsetchanges. 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.
SkipUIwithout this patch, repeated baselineThese are Debug-build Android
gfxinfowindow-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:
androidEquatablereuses 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.androidCompositionBoundaryretains an independent Compose host and lifecycle. Stableidandinputsreuse 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.compositorTransformcombines scale and translation in one graphics layer while keeping layout position and measured size stationary. In a native-WebView popup transition, replacing it with chainedscaleEffect/offsetlost 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:
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; changingidreplaces retained state. They create a separate host and cost more than equality reuse.Other rendering corrections retained in this PR
ForEachchild identity and row reordering in stacks.Validation and limits
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.
compositorTransformchanges rendered pixels rather than layout geometry.