Compose rules that keep screens fast
You have met every Compose performance idea in this course already, one at a time: stability and the compiler report in module 3, derivedStateOf in module 5, keys and work inside list items in module 6, frame timing and system traces in module 13. This lesson turns them into rules you can apply while writing a screen, and checks each against Mandi Bhav with numbers rather than opinions. It also does the thing a junior developer rarely does with a performance report: reads it as a list of suspects, measures them, and changes nothing that the measurement does not justify.
Recomposition counts below come from a scratch release build with one log line per composable, written in a SideEffect so it runs once per completed composition. Android Studio's Layout Inspector shows the same counts in a window, but they cannot be copied into a lesson, or into a pull request.
Rule 1: read the compiler's report before guessing
Module 3 turned on the Compose compiler's reports for one class. Turn them on for the whole app, with metrics as well:
composeCompiler {
reportsDestination = layout.buildDirectory.dir("compose_compiler")
metricsDestination = layout.buildDirectory.dir("compose_compiler")
}
Build from clean (./gradlew clean :app:compileDebugKotlin; an incremental build reports only the files it recompiled, and this lesson's first attempt reported 152 composables instead of 195 because of it). The metrics file, app/build/compose_compiler/debug/app-module.json, for Mandi Bhav at this module's checkpoint:
"totalComposables": 195,
"restartableComposables": 192,
"skippableComposables": 166,
"knownUnstableArguments": 14,
"totalArguments": 2845,
"memoizedLambdas": 249,
"totalLambdas": 249,
"featureFlags": { "StrongSkipping": true, … }
Two lines say most of it. Every lambda passed to a composable is remembered by the compiler (249 of 249), which is strong skipping at work and the reason you no longer wrap callbacks in remember by hand. And 14 of 2,845 arguments are of a type the compiler knows is unstable. app-composables.txt names them; searching it for unstable found five lines:
unstable windowSizeClass: WindowSizeClass? = @dynamic <expression>
unstable weekRange: LongRange?
unstable windowSizeClass: WindowSizeClass? = @dynamic <expression>
unstable lastFetched: Instant?
unstable viewModel: SaleSlipViewModel? = @dynamic <expression>
| Parameter | Of | Type from |
|---|---|---|
windowSizeClass |
MandiBhavApp, PricesScreen |
androidx.window, not built with the Compose compiler |
weekRange |
WeekRow on the detail screen |
Kotlin's LongRange |
lastFetched |
UpdatedText |
java.time.Instant |
viewModel |
SaleSlipSection |
A ViewModel, which is never stable and never needs to be |
Instant is the interesting one. It is LocalDate's sibling, immutable, from the Java library, so the compiler cannot see inside it, exactly the case module 3 fixed with a line in stability_config.conf. The temptation is to add java.time.Instant to that file now and call the screen optimised.
Rule 2: a suspect is not a culprit until you have counted
Under strong skipping an unstable parameter is compared by identity, ===, rather than by equals(). So it costs something only when an equal but different object arrives. Does it? Typing "pune" into the search field, one letter at a time, and counting what recomposed:
| Composable | Recompositions for 4 keystrokes |
|---|---|
PricesScreen |
4 |
PriceSearchField |
4 |
PriceCard |
2, both cards that had newly scrolled into view |
CommodityChips |
0 |
UpdatedText |
0 |
UpdatedText never recomposed. Its Instant comes from repository.lastFetched() through distinctUntilChanged(), which drops equal values and so keeps passing the same object; the ViewModel's combine reuses its latest value when only the query changes. Identity held, so the unstable type cost nothing. Adding Instant to the stability file would be correct, and it would also change nothing anyone could measure, so this module did not do it.
CommodityChips is the surprise. Its commodities: List<String> is a new list on every keystroke, built by commoditiesIn(prices) in the combine, and the report marks it neither stable nor unstable. The generated code settles it: for that parameter the compiler calls Composer.changed, which compares with equals(), unless the caller has told it the argument is unstable. Six equal strings, so the chips skipped.
The report tells you where to look; the count tells you whether to act. A pull request that "fixes" 14 unstable arguments with stability annotations and no counts is a pull request that adds promises the compiler will believe, for no measured gain.
Rule 3: read fast-changing state as late as possible
Compose works in three phases for each frame: composition (run the composable functions), layout (measure and place), and drawing. State read during composition recomposes when it changes; state read only inside a layout or drawing lambda skips composition entirely and redoes only that phase.
Kavita asks for the search field to fade as it scrolls away. The obvious version reads the scroll position at the top of PricesScreen:
val searchAlpha = if (gridState.firstVisibleItemIndex == 0) {
1f - (gridState.firstVisibleItemScrollOffset / 300f).coerceIn(0f, 1f)
} else 0f
…
PriceSearchField(…, modifier = Modifier.fillMaxWidth().alpha(searchAlpha))
The deferred version reads the same values inside graphicsLayer, whose lambda runs in the drawing phase:
PriceSearchField(
…,
modifier = Modifier.fillMaxWidth().graphicsLayer {
alpha = if (gridState.firstVisibleItemIndex == 0) {
1f - (gridState.firstVisibleItemScrollOffset / 300f).coerceIn(0f, 1f)
} else 0f
},
)
Both fade identically. Five swipes down and five up, three runs each, on the release build:
PricesScreen recompositions |
PriceSearchField recompositions |
|
|---|---|---|
| No fade | 0, 0, 0 | 1, 1, 1 |
| Read in composition | 20, 20, 19 | 7, 7, 7 |
Read in graphicsLayer |
0, 0, 0 | 1, 1, 1 |
Twenty recompositions of the whole screen, every chip and visible card checked again, for an effect the deferred version produced with none. Why 20 and not one per frame? Because Compose records a state read only when the code actually runs: once the field had scrolled away, firstVisibleItemIndex was not 0, the offset was never read, and only the index, which changes a few times a screen, was being watched.
The frame counters, honestly:
dumpsys gfxinfo, three runs |
99th percentile | Janky frames (legacy) |
|---|---|---|
| No fade | 48, 19, 23 ms | 47, 0, 22 |
| Read in composition | 26, 38, 34 ms | 30, 105, 82 |
Read in graphicsLayer |
30, 36, 26 ms | 42, 84, 7 |
With nine prices on an emulator, the frame numbers overlap; module 13's rule applies, frame counts from one emulator are weather. The recomposition count is the measurement that does not depend on the host, and it is the one to put in the pull request: "fade adds 0 recompositions per scroll (was 20)".
The same rule covers Modifier.offset { } (the lambda form) over Modifier.offset(x, y), and drawBehind { } for anything painted from changing state.
Rule 4: derive yes-or-no state once
Module 5 measured derivedStateOf: a "Back to top" decision read straight from the scroll position recomposed the screen 207 times in one swipe down and back; derived, 3 times. Mandi Bhav's grid does the same with the first visible item:
val showBackToTop by remember {
derivedStateOf { gridState.firstVisibleItemIndex > BACK_TO_TOP_AFTER_ITEMS }
}
Use it when many changes of the input produce few changes of the answer. Do not use it when the answer changes as often as the input (derivedStateOf { query.trim() }); it adds bookkeeping and saves nothing.
Rule 5: give lists stable keys and a content type
Module 6 showed state attaching to the wrong card without keys. Every lazy item in Mandi Bhav has a key, price.id for cards and fixed strings for the headers, and a contentType. In the typing run above, only the two cards that newly scrolled into view composed; the ones already on screen, keyed and holding equal CommodityPrice values, skipped.
Rule 6: do the work before composition
A composable body can run on every frame of an animation. Sorting, filtering, formatting a date with a new formatter, building a list: do it in the ViewModel, or once in a remember keyed on its input. Lesson 3 put a number on the ViewModel's own cost (up to 5.4 ms a keystroke with 3,009 prices, on the main thread), and on the trap in moving it carelessly.
The rules, as a review checklist
| Rule | How to check it | Mandi Bhav |
|---|---|---|
| Read the report | reportsDestination and metricsDestination, clean build, search unstable |
14 unstable arguments of 2,845; 249 of 249 lambdas remembered |
| Count before changing | A SideEffect log line, or Layout Inspector's counts |
Instant and List parameters skipped while typing; left alone |
| Defer fast reads | Scroll, and count recompositions of the screen | The fade: 20 recompositions in composition, 0 in graphicsLayer |
| Derive yes-or-no state | Does the answer change far less often than the input? | showBackToTop |
| Keys and content types | Every item and items in a lazy list |
All of them |
| Work outside composition | Any sorted, filter, map or formatter built in a body |
None in Mandi Bhav's bodies |
Check your work
What does memoizedLambdas: 249 out of 249 tell you? Strong skipping remembers every lambda passed to a composable, so you do not need to wrap callbacks in remember yourself.
Why did UpdatedText not recompose while typing, though its parameter is unstable? The same Instant object kept arriving, because distinctUntilChanged() keeps the first of equal values, and an unstable parameter is compared by identity.
Why was adding Instant to the stability file not part of the checkpoint? It would be correct but changes nothing measurable; the count showed no recompositions to save.
How many times did PricesScreen recompose during the scroll with the fade read in composition, and with it read in graphicsLayer? 19 or 20 per run, against 0.
Why only 20, not one per frame? The offset was read only while the first item was visible; after that only the index, which changes rarely, was being watched.
Why put the recomposition count in the pull request rather than the frame percentiles? On an emulator the percentiles overlapped between versions; the count does not depend on the host.
Practice
- Turn on reports and metrics in your copy, build from clean, and find your numbers. Then build again without
cleanafter touching one file and comparetotalComposables. - Add a
SideEffectlog line toPriceCardand type in the search field. Which cards recompose, and why those? - Put the fade from Rule 3 into your copy, read in composition, and count. Move the read into
graphicsLayerand count again. Remove the log lines afterwards; a log call in a body is itself work. - Break it on purpose: remove
derivedStateOffromshowBackToTopand readgridState.firstVisibleItemIndex > 3directly. Count recompositions during five swipes. Is it as bad as module 5's 207? Explain the difference from the type of state being read. - Harder:
WeekRowtakesweekRange: LongRange?, built anew bypriceWeek()on each emission. Design a measurement that would show whether this costs anything on the detail screen, run it, and decide whether to addkotlin.ranges.LongRangeto the stability file. Write the decision and the number in the commit message.
Official documentation
- Follow best practices — Android's list of Compose performance rules, including deferring reads and avoiding backwards writes.
- Jetpack Compose phases — Composition, layout and drawing, and which state reads trigger which phase.
- Diagnose stability issues — Reading
app-composables.txt,app-classes.txtand the metrics file. - Strong skipping mode — How unstable parameters and lambdas are compared now, and when to opt a composable out.
Next: reviewing an Android change.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Selected students who finish a course can join our internship, with real-world projects and code review from a working engineer.
About the internship