B05 — Janky list, and the fix
Build a list that janks on purpose, measure it, then fix it three ways and measure again. A before-and-after you can reproduce on your own device, with the trace to prove it.
Performance advice is easy to repeat and hard to believe. This project makes the belief cheap: you build a list that janks, you measure it, you fix it, and you measure the same thing again.
What you are building#
A LazyColumn of rows that do expensive work during composition: parse a string, allocate a list,
and do a little formatting. A second screen shows the fixed version, which moves the work off the
composition path and caches the result. A control switches between them so the comparison is one tap.
Steps#
- Create the project and the slow list
Build a
LazyColumnwith a few hundred rows. In each row, do the expensive work inline: allocate a large list, format a date with a new formatter, and compute a derived value. Do it inside the composable body so it runs on every composition.1You should seeA scrollable list that visibly stutters. - Make the jank reproducible
Turn off animations and use a fixed scroll distance, for example via
adb shell input swipewith the same coordinates. Reproducibility is what makes the measurement meaningful.You should seeThe same scroll produces the same stutter. - Measure with gfxinfo
Reset the counters, scroll, and read the summary. Record the number; it is your baseline.
You should seeA jank percentage above zero. - Record a trace
Record with the lab 10 config while scrolling, then find a janky frame and confirm that the main thread slice is the cause.
You should seeA janky frame in FrameTimeline. - Fix one: hoist the work
Move the expensive computation out of the composable body and into a
rememberkeyed on the row's stable inputs, or into the ViewModel. The work happens once per item instead of once per recomposition.You should seeThe jank percentage drops. - Fix two: stable keys and types
Give the list stable keys and make the row's data class stable so Compose can skip unchanged items. An unstable type forces recomposition even when the values are equal.
You should seeFewer recompositions of unchanged rows. - Fix three: add a baseline profile
Generate and install a baseline profile so the hot code is compiled ahead of time. The gain is usually smaller than the structural fixes, which is itself a useful lesson about ordering.
You should seeA smaller improvement, concentrated at startup and first scroll. - Measure again
Repeat the exact same scroll and read
gfxinfo. Compare the two numbers and the two traces.You should seeA clearly lower jank percentage than the baseline.
The slow row does its work in the composable body:
@Composable
fun SlowRow(item: Item) {
// Runs on every composition of this row.
val formatted = expensiveFormat(item)
Text(text = formatted)
}The fix hoists it out of the composition path:
@Composable
fun FastRow(item: Item) {
val formatted = remember(item.id) { expensiveFormat(item) }
Text(text = formatted)
}adb shell dumpsys gfxinfo com.example.jankylist reset
adb shell input swipe 540 1600 540 400 300
adb shell dumpsys gfxinfo com.example.jankylist
adb shell dumpsys gfxinfo com.example.jankylist framestats | head -40Example output
Illustrative output — numbers differ per device.Verification#
The comparison is the verification. You should be able to show a before number, an after number, and a trace for each that attributes the difference to a specific change. If the after number is noise, the scroll was not reproducible or the device was doing something else.
Text description of this diagram
An annotated timeline with the app main thread, the RenderThread, SurfaceFlinger and two CPU lanes. The main thread row shows a long activity creation slice followed by a first draw; the RenderThread row shows its own work starting after the main thread hands over; the SurfaceFlinger row shows composition and presentation. The point for this project is that the list's expensive composition appears as a wide main-thread slice, and the fix reduces that slice rather than the RenderThread work.
What you learned#
- Jank is a missed deadline, not a feeling. It is measurable with
gfxinfoand attributable with a trace. - The order of fixes matters. Removing work from the composition path usually beats compiler optimisation; the compiler helps the code that remains.
- Stability and keys are about skipping work, not about correctness. They reduce recomposition without changing what is drawn.
- A baseline profile is not a substitute for structure. It makes the hot path faster; it does not make a slow path disappear.
Read Jank, measured for the frame model, and Lab 12 — Jank for the trace analysis.
Extend it#
- Add a second slow path that runs on the main thread after composition, such as an image decode, and compare its cost with the composition cost.
- Replace the Compose list with a
RecyclerViewand compare the jank profiles; the two toolkits fail differently. - Add a Macrobenchmark that runs the scroll in a test and asserts on frame timing, so the comparison becomes a regression test rather than a manual ritual.
Sources#
Sources
Every version number, API level, class name and date on this page is traced to one of these. Pages are re-verified against them; last_verified above says when.
- docsCompose performancedeveloper.android.comaccessed 2026-10-27↩
- docsการแสดงผลช้า | App qualitydeveloper.android.comaccessed 2026-10-27↩
- docsMacrobenchmark overviewdeveloper.android.comaccessed 2026-10-27↩
- docsRendering | App qualitydeveloper.android.comaccessed 2026-10-27↩
- docsBaseline Profiles overviewdeveloper.android.comaccessed 2026-10-27↩
- docsProfile Guided Optimizationdeveloper.android.comaccessed 2026-10-27↩
- docsInspect GPU rendering speeddeveloper.android.comaccessed 2026-10-27↩