Stop Android navigation from rebuilding the whole screen (MOB-146) - #58
Merged
Conversation
`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
changed the base branch from
perf/mob-146-android-frame-timing
to
masterSeptember 6, 2026 02:08
Uh oh!
There was an error while loading. Please reload this page.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #57 (the frame timing that makes this measurable).
Screens rendered through
AnimatedContent(targetState = state, contentKey = { it.navKey }). AnimatedContent wraps each content inkey(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:
nonepushpopWhat 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 —
MobNodeholds aMapand aList, 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 framenative_statsmeasured still showed the old tree. Rendering straight fromstatefixed 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 ever —
element_framessilently losing ids,tap_idno longer finding them.navKeyis the epoch: it moves under the sameif (transition != "none")insetRootJsonthat 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 andstateis new every render, so any re-render landing during the 300ms slide cancelledanimateToand 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 onnavKey.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 paintsMaterialTheme.colorScheme.background.Also gone with the enter/exit transitions, and documented: a
resetno longer cross-fades (the default forMob.Socket.reset_to/4), and a push no longer parallaxes the outgoing screen.Tests
The identity change is gone by both known routes (
AnimatedContentand a barekey(state.navKey)), the slide parks before animating, the animation is keyed onnavKeynotstate, andRenderNodesits 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_DIRenv failures), credo and ktlint clean.🤖 Generated with Claude Code