skiptools / skiptools/skip-bridge
Bridged Fuse view peers can never be skipped by Compose: fresh Swift_peer each evaluation, pointer-identity equals, no @Stable
Nobody has claimed this yet.
- Dominant language
- Swift
- Stars
- 6
- Forks
- 7
- PR merge metrics
- No merged PRs in 30d
Description
Environment
- skip 1.9.4, skip-fuse-ui 1.17.2, skip-ui 1.57.0, skip-bridge 0.17.2, skip-model 1.7.5
- Fuse app (compiled Swift bridged to Kotlin peers); Kotlin 2.3.0, compileSdk 35
- Filing here because the runtime types live in skip-bridge; the emitting generator is skipstone — happy to move/split this issue if you prefer.
Summary
In a Fuse app, every bridged view peer gets a brand-new identity on every evaluation, and the generated peers are (correctly, given that) never equal to their predecessors and never stability-inferred. The combination means Jetpack Compose can never skip an unchanged bridged child: one @Observable change anywhere in a screen re-materialises the entire visible bridged subtree across JNI. We believe this is the single largest structural contributor to the Android-vs-iOS smoothness gap for Fuse apps — it amplifies every other per-view cost, and app-side mitigations (lazy containers, stable ids, precomputed row content) can only reduce how often it happens, not how much work each occurrence does.
Mechanism (three generated properties that combine)
1. Fresh peer per evaluation. The generated Swift toJavaObject allocates a new box and a new retained pointer every call, so each re-evaluation of a parent body produces a Kotlin peer with a different Swift_peer:
// SkipBridgeGenerated/MyCardView_Bridge.swift (generated by skipstone)
nonisolated func toJavaObject(options: JConvertibleOptions) -> JavaObjectPointer? {
let box = SwiftValueTypeBox(self)
let Swift_peer = SwiftObjectPointer.pointer(to: box, retain: true)
return try! Self.Java_class.create(ctor: Self.Java_constructor_methodID, options: options,
args: [Swift_peer.toJavaParameter(options: options), ...])
}
2. Identity-only equality. The generated Kotlin peer compares those pointers, so a re-evaluated view is never equals its previous incarnation even when its content is identical:
// MyCardView.kt (generated)
override fun equals(other: Any?): Boolean {
if (other !is skip.bridge.SwiftPeerBridged) return false
return Swift_peer == other.Swift_peer()
}
override fun hashCode(): Int = Swift_peer.hashCode()
3. Compose instability. The peer exposes a public mutable field and carries no stability annotation, so the Compose compiler infers unstable regardless of 1 and 2:
// MyCardView.kt (generated)
var Swift_peer: skip.bridge.SwiftObjectPointer = skip.bridge.SwiftObjectNil
In our app, 0 of 38 generated SwiftPeerBridged peer files carry @Stable/@Immutable. (skip-ui itself works around exactly this class of problem internally — List.swift carries // SKIP INSERT: @Stable // Otherwise Compose recomposes all internal @Composable funcs because 'this' is unstable.)
The re-evaluation chain that makes this bite: Evaluate() returns a fresh List<Renderable>, the peer's body() wraps Swift_composableBody(Swift_peer) which re-reads body on the Swift side each call, re-bridging every nested child afresh — new pointers all the way down.
Impact
On a screen of N sibling bridged cards, a single @Observable property write re-bridges and re-evaluates all N cards (plus headers and nested subviews) across JNI, every time. Concretely for us: scroll + concurrent state updates jank, sustained GC pressure from the per-frame box/pointer allocations, and a visible responsiveness gap vs the identical SwiftUI code on iOS, which only re-bodies the affected view. Lazy containers and stable ids do not help, because item content lambdas allocate fresh unstable peers each pass.
Suggested direction
Give bridged view peers value identity so Compose's skipping machinery can engage:
- Set
Swift_peeronce in the constructor (private/internal set), and mark generated peers@Stable— the two prerequisites for the Compose compiler to even consider skipping. - Derive
equals/hashCodefrom the compiled Swift view's stored properties (the same information SwiftUI uses for Equatable-body diffing) rather than the pointer — e.g. a generated structural-equality call across the bridge, or a content hash captured at bridging time. - Only re-bridge a child when its structural equality changed — turning "parent re-evaluated" from "rebuild subtree" into "compare and skip".
Even partial steps (ctor-only Swift_peer + @Stable + generated field-wise equals for views whose stored properties are all bridgeable-equatable) would let unchanged children skip in the common case.
Repro
https://github.com/Aecasorg/skip-fuse-perf-repro — scene 1 (UnrelatedStateScene): an @Observable counter ticks once per second and is read only by a header Text; the 20 static cards below log body-evaluation counts. On iOS StaticCardView stays at 20 after first render; on Android it grows by +20 per tick (in both the eager and the lazy/stable-ids variants).
Offer
We're happy to contribute here — and, if you're open to it, to work on a PR against skipstone/skip-bridge with your guidance on the preferred design. Would you accept a change along these lines?
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start by tracing the generated code in SkipBridgeGenerated/MyCardView_Bridge.swift and MyCardView.kt, including Evaluate() and Swift_composableBody(Swift_peer), then run scene 1 of the linked skip-fuse-perf-repro. Done should be defined by unchanged bridged children being skippable without fresh subtree work during unrelated state updates, with the preferred design agreed across skip-bridge and skipstone.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- android, kotlin, swift
- Domain
- mobile, performance
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100