Keys, and why your list is janky
A lazy list reuses what it can. That is where its speed comes from, and it is also where its strangest bug comes from: state you attached to one price turns up on a different price after the list changes. This lesson reproduces that bug on the emulator, fixes it with keys, adds the other hint a lazy list can use, contentType, and then looks at the thing that actually makes lists stutter, work done inside each item, with measurements and an honest account of what an emulator can and cannot tell you.
The bug: state on the wrong price
Priya wants to mark prices she is watching. Price alerts arrive in a later module; for this experiment, a scratch copy of the project gets a "Watch" button under each card, with its state remembered in the item:
items(prices) { price ->
var watching by remember { mutableStateOf(false) }
Column {
PriceCard(price = price, modifier = Modifier.fillMaxWidth())
TextButton(onClick = { watching = !watching }) {
Text(if (watching) "Watching" else "Watch")
}
}
}
On the emulator, with "All" selected:
- Tap "Watch" under the first card, Lasalgaon onion. It says "Watching".
- Tap the "Tomato" chip. The list becomes Pune tomato and Nashik tomato.
- Pune tomato says "Watching". Priya never touched it.
- Tap "All". Lasalgaon onion is first again, and says "Watching" again.
Nothing crashed and nothing logged an error. A farmer who trusted that screen would set an alert on the wrong commodity.
The cause is how a lazy list identifies items when you give it nothing better: by position. The state remembered in "the first price" belongs to the first slot, whatever price is in it. Lasalgaon onion left, Pune tomato moved into the first slot, and inherited its state. This is not a lazy-list quirk you will meet once. It happens whenever a list is filtered, sorted, refreshed with a new price at the top, or has an item deleted, and Mandi Bhav's list is filtered on every keystroke.
Keys
A key tells the list which item is which, so state follows the item instead of the slot. It needs something that identifies a price and nothing else, and CommodityPrice has no such thing yet. Commodity and market together would do for today's sample data, but tomorrow's price for the same pair would collide with today's. Give the model an id, in model/CommodityPrice.kt:
/**
* One day's price for one commodity at one market.
*
* Prices are whole rupees per quintal (100 kg), the unit APMC markets report in.
* They are Long, never Double: a price is money, and money has to add up exactly.
*
* [id] identifies this price and nothing else: a list uses it as the item key,
* and navigation passes it to the detail screen instead of the whole object.
*/
data class CommodityPrice(
val id: String,
val commodity: String,
val market: String,
val date: LocalDate,
val minPrice: Long,
val maxPrice: Long,
val modalPrice: Long,
)
and each sample price one, in data/SamplePrices.kt:
val lasalgaonOnion = CommodityPrice(
id = "lasalgaon-onion-2026-11-07",
commodity = "Onion",
market = "Lasalgaon APMC",
date = day,
minPrice = 1_200,
maxPrice = 2_851,
modalPrice = 2_450,
)
and so on for all nine (pune-onion-2026-11-07, nashik-onion-2026-11-07, pune-tomato-2026-11-07, nashik-tomato-2026-11-07, vashi-potato-2026-11-07, vashi-wheat-2026-11-07, lasalgaon-soybean-2026-11-07, pune-tur-dal-2026-11-07). When prices come from a server in the networking module, the id will be the server's.
Then key the items. In the scratch copy, items(prices, key = { it.id }) and the same four steps: Pune tomato says "Watch", correctly. Tap "All" and Lasalgaon onion says "Watch" too: its remember state was thrown away when it left the list. Change remember to rememberSaveable and Lasalgaon onion comes back "Watching". A lazy list with keys keeps each key's saveable state while the item is out of the list or scrolled off screen, which is why the documentation insists the key type be one a Bundle can hold: a String, a number, an enum, a Parcelable.
In the real PricesScreen, every item now gets a key and a content type:
item(key = "search", contentType = "search", span = { GridItemSpan(maxLineSpan) }) {
stickyHeader(key = "chips", contentType = "chips") {
item(key = "no-results", contentType = "message", span = { GridItemSpan(maxLineSpan) }) {
items(
items = prices,
key = { price -> price.id },
contentType = { "price" },
) { price ->
PriceCard(price = price, modifier = Modifier.fillMaxWidth())
}
The chips in the LazyRow already had keys: "all" and the commodity name, which is unique within the row.
What makes a good key
| Candidate | Good key? | Why |
|---|---|---|
price.id |
Yes | Stable, unique, a String |
| The index | No | It is the default, and the bug above |
price.hashCode() |
No | A data class's hash changes when any field changes: a corrected price becomes a new item and loses its state |
price itself |
No | Not a type a Bundle can hold |
"${price.market}-${price.commodity}" |
Only for one day's prices | Tomorrow's price for the same pair collides with today's |
Break it on purpose. Give two sample prices the same id and launch:
java.lang.IllegalArgumentException: Key "lasalgaon-onion-2026-11-07" was already used. If you are using LazyColumn/Row please make sure you provide a unique key for each item.
A crash at launch is the good outcome; with server data it happens on the day the server sends a duplicate. A unit test guards the sample data, in app/src/test/java/com/riztechacademy/mandibhav/data/SamplePricesTest.kt:
package com.riztechacademy.mandibhav.data
import org.junit.Assert.assertEquals
import org.junit.Test
class SamplePricesTest {
@Test
fun everySamplePriceHasAUniqueId() {
// Ids are list keys, and from the navigation lesson on, navigation
// arguments too: a duplicate crashes the list and opens the wrong price.
val ids = SamplePrices.all.map { it.id }
assertEquals(ids.size, ids.toSet().size)
}
}
./gradlew testDebugUnitTest now runs 19 tests.
contentType
As cards scroll off one end and on at the other, a lazy layout reuses the composition of a card that left for a card that arrived, which is cheaper than building one from nothing. contentType tells it which items have the same shape, so it only ever reuses a price card for a price card, never the search field's composition for a card. Mandi Bhav has four shapes, and one of them, the price, is almost every item.
I could not measure a difference from contentType on this screen: with nine prices, and the header items near the top, there is little reuse to get wrong. It costs one line per item and matters on lists that mix shapes all the way down, a feed with headers between days or adverts between posts. Add it when you write the list, because nobody goes back to add it later.
The real cause of jank: work in the item
Keys and content types are hints. What makes a list stutter is the code inside each item, because a lazy list runs that code every time an item scrolls on screen. Kavita would like each card to say where its price ranks among the day's prices. The obvious version, in a scratch copy with 3,000 prices (about what Vashi reports on a busy day), with a timer around it:
itemsIndexed(prices) { index, price ->
val start = System.nanoTime()
// The trap: ranking every price, inside every item.
val rank = prices.sortedByDescending { it.modalPrice }.indexOf(price) + 1
android.util.Log.d("Lazy", "item $index: rank $rank took ${(System.nanoTime() - start) / 1000} us")
Column {
PriceCard(price = price, modifier = Modifier.fillMaxWidth())
Text("Rank $rank of ${prices.size} by modal price")
}
}
Every item sorts all 3,000 prices to find one number. Then the fix: rank once, outside the items, and look the answer up:
val ranks = remember(prices) {
prices.sortedByDescending { it.modalPrice }
.withIndex()
.associate { (i, p) -> p to i + 1 }
}
with val rank = ranks.getValue(price) in the item. (In the app, this belongs in the ViewModel beside the filtering, module 5's rule; remember is the scratch-copy shortcut.) Ten swipes through the list on a release build, each time:
| Ranking | Time per item (median of 77) | Time to rank once |
|---|---|---|
| Inside the item | 1,141 microseconds | — |
| Once, looked up per item | 5 microseconds | 1,035 microseconds |
That is the number to trust. A frame at 60 Hz has about 16 ms for everything; a little over a millisecond per item, two or three items arriving per frame during a fast scroll, on a laptop's processor, is a real share of it. On a mid-range phone it will be several times more, by an amount I could not measure without one.
Looking at jank honestly
Android counts frames for you. Reset the counters, scroll, and read them:
adb shell dumpsys gfxinfo com.riztechacademy.mandibhav reset
adb shell input swipe 540 1900 540 700 150
adb shell dumpsys gfxinfo com.riztechacademy.mandibhav
Here are the lines that matter, for the same ten swipes on the release builds above:
dumpsys gfxinfo line |
Ranking in the item | Ranking once |
|---|---|---|
| Total frames rendered | 500 | 499 |
| Janky frames | 13 (2.60%) | 17 (3.41%) |
| Janky frames (legacy) | 34 (6.80%) | 0 (0.00%) |
| 90th percentile | 18ms | 17ms |
| 99th percentile | 22ms | 19ms |
Read it carefully, because it does not say what you want it to. The main "Janky frames" count went the wrong way, 13 to 17, after a fix that made each item over 200 times cheaper. On an emulator the count of missed frame deadlines is mostly about the emulator: its host is sharing a laptop with everything else, and a fast host hides work that a phone would not. The older "legacy" count and the 99th percentile moved the right way, and Android's performance guide says frame times are a better guide than the count of janky frames for exactly this reason. A frame count from one run on an emulator is weather, not climate.
So the honest rules for this course, until the profiling module brings Macrobenchmark and a release build on a real device:
- Measure the cost of the code itself (the per-item timing above): it does not depend much on the emulator.
- Use frame counts for big differences only, from several runs, on a release build.
- Say what you could not measure. "1.1 ms per item on an emulator; not measured on a phone" is useful to the next developer. "Fixed the jank" is not.
Remove the timers and the scratch ranking when you are done; the logging is itself work in the item.
Check your work
Why Pune tomato said "Watching". Without keys, state belongs to the position; Pune tomato moved into the first slot after the filter, and inherited the first slot's state.
What a key changes. State follows the item with that key, wherever it moves.
Why remember lost Lasalgaon's state when it came back, and rememberSaveable kept it. remember state is discarded when the item leaves the composition; a keyed lazy list keeps rememberSaveable state for each key.
Why hashCode() is a bad key. It changes when any field changes, so a corrected price looks like a different item.
What happens with a duplicate key. An IllegalArgumentException at launch naming the key.
What contentType lets the list do. Reuse compositions only between items of the same shape.
What made the ranking slow, and the fix. A sort of 3,000 prices in every item; rank once and look up.
Why the janky-frame count got worse after a real fix. One emulator run is noise at this scale; frame times and per-item cost are better evidence.
Practice
- Add the ids, the keys and
contentTypeto the real project, and theSamplePricesTest. Run the 19 tests. - Reproduce the "Watching" bug in a scratch copy without keys, then fix it with a key. Then try
rememberagainstrememberSaveablewith the key, and explain the difference you see when the price leaves and comes back. - Break it on purpose: give Pune onion Lasalgaon onion's id. Run the test first and read its failure, then launch the app and read the crash.
- Reproduce the ranking measurement on your machine, debug and release. Compare your per-item times with the table.
- Run the
gfxinfosequence three times for the same build and write down how much the "Janky frames" count moves between runs of identical code. That spread is the smallest difference you can claim from it. - Harder: move the ranking into
PricesViewModelas aStateFlow<Map<String, Int>>of price id to rank, computed from the full list (not the filtered one), and write a test that the cheapest price ranks last. Then decide whether ranks should change when the farmer filters, and argue it.
Official documentation
- Lists and grids: item keys — Keys, why they must be
Bundletypes, andcontentType. - Follow best practices — Lazy layout keys, and keeping expensive calculations out of composition.
- Overview of measuring app performance — Why frame times beat janky-frame counts, and why not to measure a debug build.
- dumpsys — The
gfxinfoandmeminfocommands used in these two lessons. - Slow rendering — What jank is, and how the platform decides a frame was late.
Next: navigation, and a second screen to go to.
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