Skip to content

Stop Android navigation from rebuilding the whole screen (MOB-146) - #58

Merged
GenericJam merged 5 commits into
masterfrom
perf/mob-146-two-slot-android
Sep 6, 2026
Merged

Stop Android navigation from rebuilding the whole screen (MOB-146)#58
GenericJam merged 5 commits into
masterfrom
perf/mob-146-two-slot-android

Conversation

@GenericJam

Copy link
Copy Markdown
Owner

Stacked on #57 (the frame timing that makes this measurable).

Screens rendered through AnimatedContent(targetState = state, contentKey = { it.navKey }). AnimatedContent wraps each content in key(contentKey), so every push, pop and reset disposed the outgoing composition and built the incoming one from nothing — structurally what .id(currentNavVersion) did on iOS before MOB-129.

Now one mount point at a fixed structural position. That position is its identity, no key is used, and a navigation replaces the tree inside it. The slide is driven by an animated layer translation rather than an enter/exit transition, because those only fire on insert/remove and insert/remove is what cost the time.

Measured

Physical moto g power, 1600-node screen, baseline and fix back to back in one session, n=20/10/10:

transitionbeforeafter
none227 ms128 ms44% faster
push794 ms374 ms53% faster
pop784 ms440 ms44% faster

What this does not do, since an earlier draft of this work claimed otherwise: it does not make navigation as cheap as a re-render. Nothing in a Mob screen can be skipped — MobNode holds a Map and a List, so Compose infers it unstable, and this toolchain has no strong skipping — so every recomposition walks all 1600 nodes either way. What is removed is the dispose and the slot-table teardown and rebuild. Navigation still costs ~3x a re-render, before and after. GenericJam/mob#TBD-162 covers the rest.

Those earlier numbers (161ms push) were a measurement artifact: the tree was installed by a LaunchedEffect, which runs after composition, so the frame native_stats measured still showed the old tree. Rendering straight from state fixed the artifact and a blank-screen bug together.

iOS's two-slot presentation was implemented first, and removed

Parking the outgoing tree buys depth-1 retention there. Here a parked Compose subtree recomposes on every render of the active screen (measured: 6 recompositions of the parked node across 6 re-renders), taking a steady-state re-render from 151ms to 273ms — paying on the common path to save on the rare one. Worth revisiting under MOB-162, since the cause is the same unstable MobNode.

Every navigation re-keys the frame trackers

The gate has each tracked node remember the generation current when it composed and refuses older writes. AnimatedContent used to make the incoming tree a fresh composition, so that capture was renewed for free. With the mount point preserved, nodes Compose reuses would keep a superseded generation and their frame writes would be refused for everelement_frames silently losing ids, tap_id no longer finding them.

navKey is the epoch: it moves under the same if (transition != "none") in setRootJson that bumps the generation, so the two cannot drift apart. Verified on device across five consecutive navigations.

Two silent bugs found and fixed on the way

The app rendered blank. The animation was driven from LaunchedEffect(state); that cancels on key change and state is new every render, so any re-render landing during the 300ms slide cancelled animateTo and froze the offset off-canvas. The BEAM went on reporting the correct screen and assigns throughout — no probe an agent has would have caught it. Now keyed on navKey.

A black wedge on light themes. Only one screen is mounted during a slide and the window background beneath is hardcoded black in styles.xml. The container now paints MaterialTheme.colorScheme.background.

Also gone with the enter/exit transitions, and documented: a reset no longer cross-fades (the default for Mob.Socket.reset_to/4), and a push no longer parallaxes the outgoing screen.

Tests

The identity change is gone by both known routes (AnimatedContent and a bare key(state.navKey)), the slide parks before animating, the animation is keyed on navKey not state, and RenderNode sits inside the epoch provider — a provider wrapping an empty body passes a presence check while every tracked node reads the default epoch for ever. Mutation-checked.

Suite 418/420 (2 pre-existing MOB_DIR env failures), credo and ktlint clean.

🤖 Generated with Claude Code

GenericJamand others added 5 commits September 5, 2026 14:26
`Mob.RenderStats` could time the BEAM half of a render but not the native
half, and on a dense screen the native half is most of the cost. On Android it
could not see any of it: there was no native implementation at all, so
`native_summary/1` returned `{:error, :unsupported}` — indistinguishable from
"you forgot to enable it".
MOB-146 argues Android navigation disposes and recreates the composition, the
same defect MOB-129 fixed on iOS. Without a number that fix lands with no
before and no after, so this is the prerequisite the ticket asks for.
The ring buffer lives here rather than in the NIF, unlike iOS where it sits in
C beside it. The measurement can only be taken on the main thread, so keeping
the buffer next to the writer avoids a JNI hop per sample. Mirrors
elementFrames, which is built in Kotlin for the same reason.
The closing bracket is the part that fails silently with a plausible number,
so both rejected options are documented in place:
- A MessageQueue.IdleHandler is the literal analogue of the
CFRunLoopObserver(.beforeWaiting) iOS uses, and it is wrong here. Compose
requests its frame through Choreographer, and a vsync callback arrives
asynchronously rather than sitting in the queue, so between request and
vsync the queue is genuinely empty. The handler fires there — before any of
the measured work — and reports the cost of a field write.
- Registering the frame callback from inside a posted Runnable fails less
visibly. ViewRootImpl.scheduleTraversals installs a sync barrier that holds
non-async messages until doTraversal runs, so with a traversal already
pending the post is held while Compose recomposes at frame V, and it
registers for V+1. Measured: removing that outer post dropped a one-field
re-render from 266ms to 221ms and a pop from 916ms to 788ms — the bracket
had been absorbing roughly an extra frame, biased upward exactly under the
load being characterised.
So the callback is registered straight from the calling thread against a
Choreographer captured on the main thread at init, then a post from inside it
closes the bracket after the synchronous traversal.
Baseline, physical moto g power, 1600-node screen: none 221ms p50, push 818ms,
pop 788ms. Navigation costs 3.7x a re-render of the same tree. Corroborated
before it was believed — the platform logged "Davey! duration=1414ms" and
"Choreographer: Skipped 67 frames" in the same window, and the measured
figures sit just below, which is right for a bracket that closes before GPU
swap.
Review fixes:
- Clear the buffer when ENABLING only. Clearing on disable too destroyed the
window the caller was about to read — native_disable/0 documents that
samples stay readable, and the natural shape is enable, drive, disable,
read. It failed by returning an empty summary, which looks like the feature
being off.
- Escape `transition` into the JSON. It reaches the buffer from
nif_set_transition, which takes any atom up to 15 chars verbatim; one
containing a quote costs the reader the whole window, not the one sample.
iOS gets this free from NSJSONSerialization.
- System.nanoTime rather than elapsedRealtimeNanos, which counts deep sleep —
a run spanning a screen-off would report minutes.
- Start the clock after MobJson.parseNode. setRootJson runs synchronously from
the NIF, so set_root_us already spans the parse; measuring it here too
double-counts against anyone summing the two windows.
- Cache the main Handler instead of allocating one per sample.
Tests assert both halves exist and are @JvmStatic, that the emitted keys are
the ones Mob.RenderStats parses, that the bracket rides a frame callback
registered without an intervening post, that the clock ignores deep sleep,
that enabling clears and disabling does not, and that the transition is
escaped. Comments are stripped before matching, per the convention in
native_frame_stats_test.exs — this file names its rejected alternatives, so a
plain substring search would find them in the prose explaining why they are
not used.
Requires the matching mob NIFs. MobBridge.kt is generated once and never
re-rendered, so existing apps must be regenerated.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Screens rendered through `AnimatedContent(targetState = state,
contentKey = { it.navKey })`. AnimatedContent wraps each content in
`key(contentKey)`, so every push, pop and reset disposed the outgoing
composition and built the incoming one from nothing — structurally what
`.id(currentNavVersion)` did on iOS before MOB-129.
Now one mount point at a fixed structural position. That position is its
identity, no key is used, and a navigation replaces the tree inside it. The
slide is driven by an animated layer translation rather than an enter/exit
transition, because those only fire on insert/remove and insert/remove is what
cost the time.
Measured on a 1600-node screen, physical moto g power, baseline and fix back
to back in one session, n=20/10/10:
transition before after
none 227ms 128ms 44% faster
push 794ms 374ms 53% faster
pop 784ms 440ms 44% faster
What this does NOT do, since an earlier draft of this work claimed otherwise:
it does not make navigation as cheap as a re-render. Nothing in a Mob screen
can be skipped — MobNode holds a Map and a List, so Compose infers it
unstable, and this toolchain has no strong skipping — so every recomposition
walks all 1600 nodes either way. What is removed is the dispose and the
slot-table teardown and rebuild. Navigation still costs about 3x a re-render,
before and after. MOB-162 covers the rest.
iOS's two-slot presentation was implemented first and removed. Parking the
outgoing tree buys depth-1 retention there; here a parked Compose subtree
recomposes on every render of the ACTIVE screen (measured: 6 recompositions of
the parked node across 6 re-renders), taking a steady-state re-render from
151ms to 273ms. Retention was paying on the common path to save on the rare
one. It becomes worth revisiting under MOB-162.
Every navigation re-keys the frame trackers, and has to. The gate has each
tracked node remember the generation current when it composed and refuses
older writes; AnimatedContent used to make the incoming tree a fresh
composition, so that capture was renewed for free. With the mount point
preserved, nodes Compose reuses would keep a superseded generation and their
frame writes would be refused for ever — element_frames silently losing ids
and tap_id no longer finding them. navKey is the epoch: it moves under the
same `if (transition != "none")` in setRootJson that bumps the generation, so
the two cannot drift apart. Verified on device across five consecutive
navigations.
Two bugs found and fixed during the work, both silent:
The animation was driven from LaunchedEffect(state). LaunchedEffect cancels on
key change and `state` is new on every render, so any re-render landing during
the 300ms slide cancelled animateTo and left the offset frozen off-canvas —
the app rendered BLANK while the BEAM went on reporting the correct screen and
assigns. Nothing an agent can query would have caught it. Now keyed on navKey.
The container background is painted from MaterialTheme.colorScheme.background.
Only one screen is mounted during a slide and the window background beneath is
hardcoded black, so a light-themed app got a black wedge sweeping across it.
Also gone with the enter/exit transitions, and documented: a reset no longer
cross-fades, and a push no longer parallaxes the outgoing screen.
Tests assert the identity change is gone by both known routes, that the slide
parks before animating, that the animation is keyed on navKey rather than
state, and that RenderNode sits INSIDE the epoch provider — a provider
wrapping an empty body passes a presence check while every tracked node reads
the default epoch for ever. Mutation-checked.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pre-merge review found that preserving the composition quietly broke more than
the frame registry.
`setRootJson` clears `lazyListStates` on navigation and its own comment says
why — old list state would scroll the wrong list to a stale position. That
clear had stopped working: the map entry went, but `MobLazyList` holds the
state in a `remember`, and with the composition preserved a list landing in the
same slot keeps the OBJECT and never consults the map again. An id-less list
with no `on_end_reached` has a null identity, so it retained across every
navigation. A `:scroll` view's `rememberScrollState()` is keyless and had the
same problem.
The same applies to anything else disposal used to reset. A `text_field`'s
local text and a `slider`'s thumb are keyed on the incoming `value` prop, which
re-seeds only when the value DIFFERS — two screens whose field is empty is the
common case, so a push carried the user's typed text into the new screen. An
id-less sheet dismissed on the way out arrived already dismissed and would
never show or fire :on_dismiss again.
All five now take the slot epoch as part of their remember key, which is the
same signal the frame trackers already use and moves on exactly the right
events.
Focus and the keyboard are deliberately left retained, and the decision record
says so and why: resetting them wants a finer signal than the epoch, and
keeping typing alive is more often right than wrong.
Also verified on device, since a draw-phase translation produces no layout pass
during the slide: element_frames after a push matches element_frames after a
subsequent re-render, so the registry is not left holding off-canvas
coordinates.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Synthetic input, frame timing and the navigation change all land in the same
unreleased version. The three-way merge left two ### Added blocks in it;
folded into one, with Added before Changed per Keep a Changelog.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The text_field, slider, scroll and sheet composables are top-level functions,
not members of the object, so the bare reference did not resolve and the
native build failed. Caught only because the device check that followed
silently ran against the previous build and 'passed' — the screen it claimed
proved the fix had never scrolled.
Re-verified on device with a probe that fails loudly if the precondition does
not hold: screen A scrolled to 47722, screen B opens at 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@GenericJam
GenericJam changed the base branch from perf/mob-146-android-frame-timing to masterSeptember 6, 2026 02:08
@GenericJam
GenericJam merged commit 3749b88 into masterSep 6, 2026
1 check passed
Sign up for freeto 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.

1 participant

@GenericJam