Lesson 08 / 19
Rendering Smoothness
Smooth scrolling depends on every frame staying within its budget. The lesson measures a scrolled list in which every frame recreates all the items, shows how a stable key narrows the recreated work, and shows why the rule does not see the size of an overrun.
Published Updated
In this lesson
- Defines the frame budget and the dropped frame
- Measures from the model how many items each frame recreates in a scrolled list
- Shows that the same code can come out within or all depending on the list length
- Measures that a stable key limits recomposition to the items entering the screen and reduces the overrun from all to minority
Contents
In the previous lesson the first frame was drawn, and the heavy work of the cold start moved to another budget. The user is now looking at the screen and scrolling a list. As the scrolling continues, every frame is redrawn; every frame has to be ready within a time that depends on the screen’s refresh rate. We call this time the frame budget. A frame that cannot make the budget becomes a dropped frame; the user sees it as a stutter.
In this lesson we do not measure time, because the time of a measurement environment is not a reliable number. Instead we count the work done per frame: how many items were recreated in a frame. The budget is in the same unit. The measure is the course’s measure, that is, in how many frames this budget was exceeded.
The measure is built with the same two classes in every lesson of the course. Budget carries the name and limit of a measurement, Overrun counts the samples that exceed the limit and gives one of four overruns, and countOverruns counts the four overruns in a fixed order.
// Overrun.kt — classifies in how many samples a measurement exceeds its budget
class Budget(val name: String, val limit: Int)
class Overrun(val label: String, val budget: Budget, samples: List<Int>) {
val total: Int = samples.size
val over: Int = samples.count { it > budget.limit }
val verdict: String = when {
over == 0 -> "within"
over == total -> "all"
over * 2 < total -> "minority"
else -> "majority"
}
fun line() {
println("$label: budget=${budget.name}<=${budget.limit} | over=$over/$total -> $verdict")
}
}
fun countOverruns(overruns: List<Overrun>) {
for (name in listOf("within", "minority", "majority", "all")) {
println("$name: ${overruns.count { it.verdict == name }}")
}
}
The Scrolled List
Let a list have twenty items, and let five of them fit on the screen at the same time. Scrolling moves the list’s first visible item in every frame. The helper file below defines the item, the visible window and the crudest frame work.
// Frames.kt — computes the items recreated in one frame of a scrolled list
class Item(val key: Int)
fun buildItems(count: Int): List<Item> = List(count) { Item(it) }
fun visible(items: List<Item>, first: Int, window: Int): List<Item> =
items.drop(first).take(window)
fun frameWork(items: List<Item>): Int = items.size
Item carries an item and the key given to it. visible returns the items that fit on the screen, starting from the first visible item. frameWork sets up the crudest rule: the frame recreates all the items of the list. We first look at the scrolling itself. Over eight frames, the position of the first visible item comes from a fixed sequence: a slow scroll, a pause and then a fast scroll.
// Main.kt — the items entering the screen over eight frames
fun main() {
val items = buildItems(20)
val positions = listOf(0, 1, 2, 2, 3, 8, 9, 9)
for ((index, first) in positions.withIndex()) {
val keys = visible(items, first, 5).map { it.key }
println("frame-$index: $keys")
}
}
frame-0: [0, 1, 2, 3, 4] frame-1: [1, 2, 3, 4, 5] frame-2: [2, 3, 4, 5, 6] frame-3: [2, 3, 4, 5, 6] frame-4: [3, 4, 5, 6, 7] frame-5: [8, 9, 10, 11, 12] frame-6: [9, 10, 11, 12, 13] frame-7: [9, 10, 11, 12, 13]
PD1. The scrolling sequence is the input of the model. Frame-3 is a pause, because the first visible item did not change. Frame-5 is a scroll where the finger drags fast: the visible window jumped five items from one frame to the next, so none of the items in the previous frame stayed on the screen. It is not claimed that a real scroll looks like this; the sequence was chosen to show the rule.
Everything in Every Frame
We measure the crude rule against the budget. Let the frame budget be four items. In a list of twenty items the work of each frame is twenty, because the frame recreates not the five items visible on the screen but all of the list. We take the measurement with three list lengths. The samples are the return values of a model function: frameWork is called for each frame.
// Main.kt — all items are recreated in every frame
fun main() {
val positions = listOf(0, 1, 2, 2, 3, 8, 9, 9)
val budget = Budget("frame", 4)
val overruns = listOf(20, 12, 4).map { count ->
val items = buildItems(count)
val samples = positions.map { frameWork(items) }
Overrun("list-$count", budget, samples)
}
overruns.forEach { it.line() }
countOverruns(overruns)
}
list-20: budget=frame<=4 | over=8/8 -> all list-12: budget=frame<=4 | over=8/8 -> all list-4: budget=frame<=4 | over=0/8 -> within within: 1 minority: 0 majority: 0 all: 2
In the lists of twenty and twelve items, eight of the eight frames exceeded the budget: all. In the list of four items none exceeded it: within. The code is the same, and only the length of the list changed. The reason is the rule the model sets up: the frame work depends not on the number of items visible on the screen but on the number of items in the list. While the list is short the rule goes unnoticed; as it grows, every frame gets heavier in the same proportion.
PD2. The budget being four items is a lesson decision. The frame budget is in reality a time, and how many items fit in that time depends on how complex an item is, on the device and on the screen’s refresh rate. The four items in the model are a choice of measurement unit, not a claim about a fitting limit. That all items have the same weight is also an assumption.
Here, all is not the name of bad code but of a situation the rule can see: every frame exceeded the budget. The rule does not say by how much the frames exceeded it. Every frame of the twenty-item list is five times the budget, and that of the twelve-item list three times; both fall on the same line, all. To see the size you either look at the output of frameWork or tighten the budget step by step.
On the platform, a frame measurement library is used to track dropped frames in production. The piece below listens to each frame of the window and records the dropped frames.
// Platform code: tracking dropped frames
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.metrics.performance.JankStats
class ListActivity : ComponentActivity() {
private lateinit var jankStats: JankStats
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
jankStats = JankStats.createAndTrack(window) { frameData ->
if (frameData.isJank) {
recordJank(frameData.states)
}
}
}
override fun onResume() {
super.onResume()
jankStats.isTrackingEnabled = true
}
override fun onPause() {
super.onPause()
jankStats.isTrackingEnabled = false
}
}
This piece is platform code, and here we claim no output, duration or file path. JankStats gives a data object for each frame, and this object says whether that frame was dropped. The model sets up the rule of this measurement; the model is not the tool itself. The real library decides a dropped frame by time, not by the model’s step count.
Reading the Frame Budget
The frame budget is the upper limit of the amount of work set aside for drawing a frame. The screen asks for a new frame at regular intervals; a frame that is not ready within that interval is dropped, and the user sees it as a stutter. In the model we use the number of work steps instead of the interval, because we do not measure time in this repository. The budget is the largest number of work steps in one frame, and the rule counts how many frames pass this limit.
This framing has two consequences. The first is that even a single expensive frame counts as an overrun; and the user often notices even a single stutter. The second is that the rule does not know the order of the frames. A scroll in which three frames in a row exceed the budget and three overruns scattered among other frames are reported with the same number. For the user, though, the first is a noticeable freeze and the second a slight roughness.
Recomposition is the source of cost in this framework. If a frame builds every visible item from scratch, the work grows with the number of items. If only the changed items are recreated in the list, the work stays bounded by the number of changed items. A stable key is the identity that makes it possible to tell which item changed.
Why Scroll Speed Matters
In the lesson’s scrolling sequence the window advanced one item in most frames, did not advance at all in one frame, and jumped five items at once in one frame. The costs of these three situations are not the same. In the paused frame there is no new item; in a slow scroll every frame brings one item; in a fast scroll the whole window is renewed. A smart list does almost no work in the first case, little in the second, and a window’s worth of work in the third.
This difference affects the design of the measurement. A list tested only with slow scrolling comes out clean, and the same list tested with fast scrolling exceeds the budget. For this reason frame samples should be chosen not from a single way of using the list but from the mix of movements the user really makes. In the model we wrote the mix ourselves; in a real app the mix comes from usage records, and only those records, not a model, know it.
Work Outside the Window
A lazy list can prepare slightly more than what fits on the screen in advance; when the user scrolls, the item is ready. This preparation does not remove the work, it only pulls its time earlier than the scroll. In terms of the frame budget the gain is real, because the work leaves the scrolling frames; but the preparation is also paid somewhere, and that place is another budget. The same principle held for the deferred work in the startup lesson: work can be moved from one budget to another, and an improvement is not complete unless the two budgets are read together.
The limit of the model is clear here. frameWork counts only the items recreated in one frame and writes the pre-prepared items nowhere. Someone who wants to measure the effect of preparing ahead must add a second counter to the model; it was not added in this lesson, because the lesson answers a single question: in how many frames recreation exceeds the frame budget.
Tying the measurement to the frame budget has another benefit: the goal is clear. “The list should be smooth” is a wish; “in the twenty-item list, none of the eight frames should exceed four items” is a sentence that can be tested. A testable goal shows, when a change is made, whether the result improved, without leaving room for debate. Keeping the budget and the sample sequence together with the code ensures that this sentence is not forgotten over time.
Finding which item the frames that exceed the frame budget come from is the second step. The first step is knowing how many frames exceed it; the second is looking for what those frames have in common. In this lesson the common point was the first frame and the five-item jump in the scroll sequence, and both could be read from the samples. In a real app, the information is provided by the output in which the profiling tool gives a per-frame breakdown.
Common Misconceptions
“If the list is short, there is no rendering problem.” A short list stays within the budget; the lesson showed this with the four-item list. But this does not mean the same code will keep the budget in a long list. The work grows with the number of items, and a measurement is valid only for the dimension it tested.
“A stable key makes rendering faster.” The key does not reduce the work itself; it determines which work will not be repeated. If the key is chosen wrongly, for example if the position number is used as the identity, then when items change places the same item appears with a different identity and the work starts over.
“A stutter can be summarized with a single number.” The number of exceeding frames gives the frequency of the stutter; it does not give its size or its consecutiveness. That is why the lesson’s bounding measurement teaches reading an improvement not by the overrun category alone but together with the per-frame work.
“Rendering is only about rendering code.” The work of a frame also includes the preparations done before drawing: data transformation, layout computation, determining the visible items. In the model all of these were gathered inside frameWork; in a real app the profiling tool says which part is expensive.
The Bounding Measurement
The remedy for the crude rule is to give every item a stable key and to recreate only the item whose key changed or which has newly become visible. In a scrolled list an item is recognized by the same key unless it leaves and enters the screen; the platform does not have to rebuild its content. The piece below is its counterpart on the platform.
// Platform code: scrolled list with a stable key
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
@Composable
fun MessageList(messages: List<Message>) {
LazyColumn {
items(messages, key = { it.id }) { message ->
MessageRow(message)
}
}
}
A lazy list creates only the items that fit on the screen, and the key gives each item an identity that does not change. In the model we set it up as follows: the work of a frame is the number of items that were not visible in the previous frame. In the first frame there is no previous frame, so the whole window is recreated.
// Main.kt — with a stable key only the items entering the screen are recreated
fun keyedWork(previous: List<Item>, current: List<Item>): Int {
val seen = previous.map { it.key }.toSet()
return current.count { it.key !in seen }
}
fun main() {
val items = buildItems(20)
val positions = listOf(0, 1, 2, 2, 3, 8, 9, 9)
val windows = positions.map { visible(items, it, 5) }
val samples = windows.mapIndexed { index, now ->
keyedWork(if (index == 0) emptyList() else windows[index - 1], now)
}
println("work: $samples")
val overrun = Overrun("keyed", Budget("frame", 4), samples)
overrun.line()
countOverruns(listOf(overrun))
}
work: [5, 1, 1, 0, 1, 5, 1, 0] keyed: budget=frame<=4 | over=2/8 -> minority within: 0 minority: 1 majority: 0 all: 0
The same scroll, the same budget and the same twenty items: the overrun dropped from all to minority. The recreated work fell from twenty to one, sometimes to zero. But two frames still exceed the budget. The first is the first frame; when the list is built for the first time, all five items are new. The second is the fifth frame of the fast scroll; because the window jumped five items at once, all five count as new. The key narrows recreation to the items entering the screen, but if the number of entering items is larger than the budget it does not save the frame.
PD3. The “newly visible item” in the model and recreation in a real list are not exactly the same. In reality, if an item’s content changes, it is recreated even when its key stays the same; in the model the content does not change. The key prevents unnecessary recreation only by preserving an item’s identity. The rule does not see this difference either.
We say plainly what the rule does not see. The difference between all and minority is a difference of class in the rule’s eyes, but in terms of the work it is much bigger: the total work fell from twenty items times eight frames, that is one hundred and sixty items, to fourteen. The rule does not give this ratio, only the number of exceeding frames. If we want to see the size of an overrun, we look at the samples themselves, that is, the work: line. The rule does not say the cause either: the overrun in the first frame comes from the list setup and the overrun in the fifth frame from the fast scroll, and the two are counted as the same number of overruns.
Summary
- This lesson’s overrun: all. In the twenty-item list every frame recreated all the items, and eight of the eight frames exceeded the four-item budget.
- The frame budget is in reality a time; in this lesson it was measured as a work count in the same unit, as the items recreated per frame, and no time was written.
- The same code came out within in the four-item list and all in the twelve- and twenty-item lists: in the crude rule the frame work depends not on the visible items but on the list length.
- A stable key limited recomposition to the items entering the screen and reduced the overrun from the all class to the minority class. The remaining two overruns were the first frame and the five-item jump.
- The rule sees how many frames overrun; it does not see the size or the cause of the overrun.
Next Step
Even when scrolling is smooth, every frame leaves new objects in memory. Some of them are not freed after use and stay in memory even when the screen is closed. The next lesson, Memory Management, measures in how many cycles the number of retained objects exceeds the budget when a listener that is never unregistered holds a screen on every open-and-close cycle.
To keep your progress and take notes, Log in
My notes
Log in to take notes.