Finding jank on a Rp1,5 juta phone: what actually worked for our Compose feed
Numbers from a real feed screen, measured on the phone our users actually have
Contents
Promised Trey a proper post after his budget Android review, so here it is. Context: big app, Compose UI, and most of our users are on phones that cost Rp1,5 to 2,5 juta (roughly 90 to 160 USD). Our feed felt "fine" on our flagship dev phones. On the cheap test device it was not fine.
1. Measure first, on the right phone
Quick check, no setup:
adb shell dumpsys gfxinfo com.example.app reset
# scroll the feed for 30 seconds
adb shell dumpsys gfxinfo com.example.app
Look at "Janky frames" and the 90th, 95th and 99th percentile frame times. Our feed: 8.2% janky frames on the cheap phone, 1.1% on a flagship. The 50th percentile was fine on both. The 99th percentile on the cheap phone was 61 ms. That is what users feel as "laggy".
For repeatable numbers we run Macrobenchmark with FrameTimingMetric in CI:
@RunWith(AndroidJUnit4::class)
class FeedScrollBenchmark {
@get:Rule val rule = MacrobenchmarkRule()
@Test
fun scrollFeed() = rule.measureRepeated(
packageName = "com.example.app",
metrics = listOf(FrameTimingMetric()),
iterations = 10,
startupMode = StartupMode.WARM,
compilationMode = CompilationMode.Partial(),
) {
startActivityAndWait()
val feed = device.findObject(By.res("feed_list"))
feed.setGestureMargin(device.displayWidth / 5)
repeat(3) { feed.fling(Direction.DOWN) }
device.waitForIdle()
}
}
(For Compose you need Modifier.semantics { testTagsAsResourceId = true } near the root, so By.res can find the test tag.)
2. What we found, biggest first
a. Unstable parameters, so every item recomposed
Our feed item took a List<Tag> and a domain model from another module. Compose treated both as unstable, so every item recomposed whenever anything in the parent changed. The compiler reports showed it straight away:
// build.gradle.kts (module)
composeCompiler {
reportsDestination = layout.buildDirectory.dir("compose_compiler")
metricsDestination = layout.buildDirectory.dir("compose_compiler")
}
Fix: a UI model with ImmutableList from kotlinx.collections.immutable and @Immutable on the class. (Strong skipping mode in newer Compose compiler versions helps a lot here too, check which version you are on.) Recompositions per scroll dropped about 70%.
b. No keys in LazyColumn
LazyColumn {
items(items = posts, key = { it.id }, contentType = { it.type }) { post ->
FeedItem(post)
}
}
Without key, inserting a new post at the top shifted state for every item. contentType lets Compose reuse compositions between items of the same type.
c. Reading scroll state in composition
A "scroll to top" button read listState.firstVisibleItemIndex directly, so the whole screen recomposed on every scroll frame. Fix:
val showButton by remember {
derivedStateOf { listState.firstVisibleItemIndex > 3 }
}
d. Decoding thumbnails at full size
Thumbnails were decoded at 1080px and then shown at 120dp. Setting the size explicitly in the image request, so the loader decodes at display size, cut memory a lot and removed most of the GC pauses during scroll.
e. Baseline Profile
We generated a Baseline Profile covering startup and feed scroll. Without it, the first scroll after install runs a lot of code interpreted or JIT compiled. On the cheap phone, first-scroll jank dropped from 14% to 6%. The biggest single win for "first impression", and it is mostly configuration.
Result
before after
janky frames (cheap) 8.2% 1.9%
p99 frame time (cheap) 61 ms 24 ms
janky frames (flagship) 1.1% 0.4%
What I would tell other Android teams
- Your dev phone is lying to you. Buy the phone your median user has and keep it on your desk.
- Look at p95 and p99 frame time, not the average. The average hides everything.
- Most of our wins were "stop doing work", not clever code.
And no, rewriting it in a cross-platform framework would not fix any of this. Sorry anoop.
Written by
Dita Anggraini
Android engineer, Jakarta. Kotlin, Compose, and apps that must work on a Rp1,5 juta phone on a moving TransJakarta bus.
Get the best of #thumb_zone weekly
One email each Monday with the most useful posts. No spam; unsubscribe in one click.
Comments
Sign in to join the conversation.
Sign in to comment