Recomposition Replaces Overdraw as Compose’s Performance Battlefield
XML-era remedies such as flattening hierarchies and removing backgrounds stop working when Compose re-executes composables on the CPU. The measured sequence here turns a sluggish search list into a smooth one on a 60Hz device, with scroll dropped-frame rate falling from 34.1% to 11.1% and P90 frame time from 40.4ms to 16.6ms.
Migrating from XML layouts to Jetpack Compose moves UI performance work from the GPU canvas to the CPU’s recomposition decisions. Overdraw still exists, and deep hierarchies stop being a meaningful multiplier because Compose measures each child only once. The dominant cost is recomposition: composable functions re-executing when their inputs did not change. On a search list page, Layout Inspector showed SearchScreen recomposing and all six visible ProductItems re-executing after a keystroke that did not change the result set.
The fix has four steps. Query state sinks into a dedicated composable so keystrokes don’t rebuild the whole page. Filtering and mapping leave Composition; the ViewModel maps once and a remember(products, query) keeps instances stable, while kotlinx-collections-immutable turns ProductUi stable so equals() rather than === decides skippability. LazyColumn gets key = { it.id }. Finally, showFab uses derivedStateOf and ScrollFab receives a () -> Int read inside Modifier.offset { }, moving scroll state reads from Composition to Layout.
On a Mi 6 at 60Hz, scroll dropped-frame rate fell from 34.1% to 11.1% and P90 frame time from 40.4ms to 16.6ms; ProductItem executions during 20 scrolls went from 785 to about 107. The typing scenario behaved differently: ProductItem executions dropped about 80%, but jank stayed around 50-56%. Only the tail got shorter (P90 from 57ms to 40ms after Step 2). So recomposition optimization trims tail latency and can eliminate scroll jank, but it is not a blanket guarantee; frame data from dumpsys gfxinfo, not recomposition count, is the final check.
Stability after Strong Skipping is less about type annotation and more about object lifecycle: an unstable type is harmless only if callers stop creating fresh instances, which is why moving map into the ViewModel matters more than the annotation itself.
The typing scenario is the honest result: recomposition counters can show an 80% drop while the user-visible jank rate stays flat, so optimization work should stop at frame data rather than at satisfying green counts.
Compose’s promise that nested layouts are efficient does not eliminate performance work; it relocates it to the decision layer, where the cost is CPU time spent executing code that should be skipped.