跪拜 Guibai
← All articles
Android

Recomposition Replaces Overdraw as Compose’s Performance Battlefield

By 词源派 ·
Read original on juejin.cn ↗ Google Translate ↗ Alt translation

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.

Summary

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.

Takeaways
— Compose renders in three phases: Composition, Layout, Drawing; when inputs have not changed, the optimization target is skipping Composition.
— State read location determines recomposition scope; because Column, Row, and Box are inline, reading state inside their content lambdas recomposes the nearest non-inline enclosing composable.
— Stable types are compared with equals(), unstable with === under Strong Skipping; a newly created unstable instance never skips, even when its content is equal.
— Strong Skipping makes most composables skippable; in the Demo all 23 named composables were skippable, so an unstable parameter marker in Compose Compiler Report is the real warning.
— ProductUi was unstable because tags was a List<String>; switching to kotlinx ImmutableList changed Compiler Report to stable and made equal-content instances skippable.
— Keeping map out of Composition and wrapping filter in remember(products, query) avoids recreating ProductUi instances on every keystroke.
— LazyColumn matches items by position without a key; key = { it.id } reduces visible recompositions when the result set changes and means only newly entering items compose.
— derivedStateOf turns firstVisibleItemIndex > 0 into a boolean that only recomposes when it flips; reading firstVisibleItemScrollOffset inside Modifier.offset { } moves that read to Layout, so scroll does not recompose any composable.
— On Mi 6 Release builds, scroll dropped-frame rate improved from 34.1% to 11.1% and P90 from 40.4ms to 16.6ms across the four steps.
— Typing jank stayed at 50-56% even after ProductItem executions fell about 80%, so recomposition optimization shortened tail frame times but did not remove all dropped frames on the Mi 6.
Conclusions

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.

Concepts & terms
Recomposition
In Jetpack Compose, re-executing a composable function when a state it reads has changed, even if the function’s inputs did not meaningfully change. It is the main performance cost Compose optimization targets.
Strong Skipping
Compose compiler feature enabled by default with Kotlin 2.0.20+. It lets composables with unstable parameters skip recomposition when the parameter instances are unchanged, shifting the problem from stability annotations to instance churn.
Stability (Compose)
A compiler-inferred property of a type used in composable parameters. Stable types are compared with equals() and can be skipped when content is equal; unstable types are compared with === and need the exact same instance to skip.
derivedStateOf
Compose state helper that derives a value from other state and only notifies recomposition when the derived output changes, filtering rapid input changes such as a scroll index that updates every item.
Compose Compiler Report
Static report generated by the Compose compiler showing which composables are skippable and which classes and parameters are stable or unstable. It is enabled through composeCompiler reportsDestination and metricsDestination.
Source: juejin.cn ↗ Google Translate ↗ Backup ↗