Techbase Academy
Android · Lesson 57

Compose Performance

13 phasesPerformance ClinicAndroid Studio
Phase 1 of 13
Lesson Introduction

Performance is not the first thing a new Compose developer worries about, and that is correct: Compose is fast by default. But every serious app eventually meets a screen that stutters while scrolling, a start-up that feels slow on a modest phone, or a composable that recomposes hundreds of times for no visible reason. Many users in our market use phones with limited memory and older processors, so performance is also inclusion.

This lesson covers Debugging recomposition, Performance gotchas, More performance tips and Practical performance problem solving from Pathway 4 of the Jetpack Compose for Android developers programme (https://developer.android.com/courses/pathways/jetpack-compose-for-android-developers-4), and the Jetpack Compose performance codelab (https://developer.android.com/codelabs/jetpack-compose-performance). We follow the codelab's scientific approach: measure, find the cause, change one thing, measure again.

By the end you will be able to:

  • Explain recomposition, skipping and stability and what strong skipping changes.
  • Find unnecessary recomposition with the Layout Inspector and with logging.
  • Apply the main fixes: lazy list keys, remember, derivedStateOf, deferred state reads, stable models, graphicsLayer and lambda modifiers.
  • Avoid backwards writes and heavy work in composition.
  • Read compiler reports and metrics.
  • Measure properly with release builds, Macrobenchmark and Baseline Profiles.
  • Run the measure → hypothesise → change → verify loop.

Big idea: do not optimise by superstition. A change that cannot be measured is a guess, and a "fix" that adds complexity without a measured gain is a bug waiting to happen.

✏️ Your Task
Practise what you just learned about Lesson Introduction. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 2 of 13
Prerequisite Concepts
  • The three phases and recomposition (lesson 37).
  • Lazy lists, keys and content types (lesson 39).
  • Animations and graphicsLayer (lessons 41 and 56).
  • State, remember, derivedStateOf (lessons 19 and 54).
  • Release builds and R8 (lesson 49).
  • Android Studio tools, Layout Inspector, Profiler (lesson 44).
✏️ Your Task
Practise what you just learned about Prerequisite Concepts. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 3 of 13
Conceptual Understanding: Where Time Goes

On a 60 Hz screen a frame has 16.6 ms to be ready (about 8 ms at 120 Hz). Everything has to fit: input, composition, layout, drawing, and the GPU work.

text
 |<------------------- 16.6 ms frame budget ------------------->|
 | input | composition | layout | draw (record) | GPU render ...  |
          ^ your Kotlin  ^ your measure code
   If the sum is bigger than the budget: a frame is dropped = jank.

Compose does as little work as possible by being smart about what it re-runs:

Mechanism Idea
Recomposition When state changes, re-run only the composables that read it
Skipping If all parameters are unchanged, do not re-run the composable
Phase skipping If state is read only in the layout or draw phase (inside a lambda), skip composition altogether
Lazy composition LazyColumn composes only what is visible

Performance problems come from defeating these mechanisms: reading fast-changing state too high in the tree, passing parameters that change every time, doing heavy work during composition, or composing too much.

Stability, skipping and strong skipping

For skipping, Compose needs to compare the old and new parameters.

Parameter kind Comparison
Stable type (primitives, String, functions with stable captures, classes marked @Stable or @Immutable, classes the compiler proves stable) equals()
Unstable type (classes with vars that are not Compose state, or types from other modules the compiler cannot see, or collections such as List<T> whose implementation could be mutable) Historically: never skipped. With strong skipping: compared by instance equality (===)

Strong skipping mode is on by default from Kotlin 2.0.20 with the Compose compiler plugin (older Kotlin versions can enable it in the composeCompiler { } block). With strong skipping, composables with unstable parameters can be skipped too, as long as the same instances are passed, and lambdas are automatically remembered according to the same rules. This removes a large class of problems that older tutorials warn about ("make everything immutable or your app is slow"). However:

  • Instance equality means that a new List object created on every recomposition (items.filter { ... } in the body) is a different instance even if its contents are the same. The child will recompose.
  • A stable-annotated class (@Immutable, @Stable) is compared with equals() instead, which can skip more, but you are promising to follow the rules; a wrong promise gives stale UI.

So the modern guidance is: do not annotate everything. First measure. If a composable with unstable parameters recomposes too often, fix the cause (create the object once, move the computation to the ViewModel, use remember), or use @Immutable/@Stable or the stability configuration file only where measurements show a benefit.

✏️ Your Task
Practise what you just learned about Conceptual Understanding: Where Time Goes. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 4 of 13
Step-by-Step Build 1: Measure First, in a Release-Like Build

Never judge Compose performance in a debug build, on an emulator with an overloaded laptop, or on your fastest phone.

Why debug builds are slow:

  • No R8 optimisation; extra checks and debuggable code paths.
  • Compose in debug mode has additional runtime checks and tooling.
  • Code is interpreted or JIT-compiled at first use. Release builds use ahead-of-time (AOT) compilation guided by Baseline Profiles where available.

Checklist for a fair measurement:

  1. Build the release variant (lesson 49), minified. For local testing of a signed-less release you can use a benchmark or profileable build type that copies release but is signed with the debug key and marked profileable:
build.gradle.kts
android {
 buildTypes {
 create("benchmark") {
 initWith(getByName("release"))
 signingConfig = signingConfigs.getByName("debug")
 matchingFallbacks += listOf("release")
 isDebuggable = false
 proguardFiles("benchmark-rules.pro") // keep rules if needed
 }
 }
}
  1. Install on a real, mid-range or low-end device that represents your users.
  2. Let it cool down; repeat measurements several times; close other apps.
  3. Use the Profiler or Macrobenchmark (below) to record, not your eyes.

The performance loop

text
1. MEASURE      Record the problem (frame times, recomposition counts, start-up time).
2. DIAGNOSE     Find the cause (Layout Inspector, system trace, compiler report).
3. HYPOTHESISE  "Reading scroll offset in composition recomposes the whole header."
4. CHANGE ONE   Make a single change.
5. VERIFY       Measure again, with the same method. Keep it only if it helped.
✏️ Your Task
Practise what you just learned about Step-by-Step Build 1: Measure First, in a Release-Like Build. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 5 of 13
Step-by-Step Build 2: Debugging Recomposition

The Layout Inspector

Run a debug build for diagnosis (counts are the same as in release; timings are not). Open Layout Inspector (lesson 44), enable Show recomposition counts, interact (type, scroll, tap), and look at the Recomposition columns: recomposition count and skip count per composable. What to look for:

  • A composable recomposing every frame while something else animates or scrolls.
  • A child that recomposes whenever its parent does (skip count stays 0), even though its inputs did not "really" change.
  • A huge subtree under a node with a high count.

Logging recompositions with a tiny helper

A simple counting helper for experiments:

RecompositionCounter.kt
package com.example.perf
import android.util.Log
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.remember
class Ref(var value: Int)
@Composable
inline fun LogCompositions(tag: String, msg: String) {
 val ref = remember { Ref(0) }
 SideEffect { ref.value++ }
 Log.d(tag, "Compositions: $msg ${ref.value}")
}

Place LogCompositions("Perf", "ItemRow") at the top of a composable you suspect. The count tells you how many times it committed a recomposition. Remove it afterwards: logging inside composables costs time itself.

Common causes you will find

text
CAUSE                                         SYMPTOM IN INSPECTOR
Reading fast state high in the tree           Large parent + all children recompose together
New object every recomposition (list, lambda) Child never skips
Unkeyed lazy items after an insert            Many items recompose
State read in composition for an animation    Count increases ~60 times per second
Expensive calculation in the body             Frame time spikes; count may be modest
✏️ Your Task
Practise what you just learned about Step-by-Step Build 2: Debugging Recomposition. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 6 of 13
Step-by-Step Build 3: Deferring State Reads

Compose has three phases. Where you read state decides which phases re-run. Reading state in the composition phase (directly in the composable body) re-runs composition, layout and draw. Reading it in a lambda that runs in layout or draw re-runs only that phase.

The problem: a parallax header

kotlin
// BAD: scroll offset read in composition, so the whole screen recomposes on every pixel scrolled
@Composable
fun Header(scrollState: ScrollState) {
 val offset = scrollState.value / 2 // read in composition
 Image(
 painter = painterResource(R.drawable.banner),
 contentDescription = null,
 modifier = Modifier.offset(y = offset.dp)
 )
}

The fix: read inside a lambda modifier

kotlin
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.layout.offset
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.IntOffset
@Composable
fun Header(scrollState: ScrollState) {
 Image(
 painter = painterResource(R.drawable.banner),
 contentDescription = null,
 modifier = Modifier.offset { IntOffset(0, scrollState.value / 2) } // read in the layout phase
 )
}

Modifier.offset { ... } (lambda version) reads scrollState.value during layout, so scrolling re-runs layout only, with no recomposition. For drawing-only effects such as fade, scale or rotation, use Modifier.graphicsLayer { alpha = ...; translationY = ... } or drawBehind { ... }, which read in the draw phase. Equivalent pairs:

Slow (composition read) Faster (deferred read)
Modifier.offset(x = state.dp) Modifier.offset { IntOffset(...) }
Modifier.alpha(a) Modifier.graphicsLayer { alpha = a }
Modifier.background(color) where color animates Modifier.drawBehind { drawRect(color) }
Modifier.rotate(deg) Modifier.graphicsLayer { rotationZ = deg }
Modifier.size(animatedDp) lambda layout modifier, or avoid animating size in a hot path

The lambda parameter pattern also applies to your own APIs: accept () -> Float instead of Float when the value changes quickly:

kotlin
@Composable
fun ProgressBar(progress: () -> Float, modifier: Modifier = Modifier) {
 Box(
 modifier
 .fillMaxWidth()
 .height(8.dp)
 .drawBehind {
 drawRect(Color.LightGray)
 drawRect(Color.Green, size = size.copy(width = size.width * progress()))
 }
 )
}
// caller: ProgressBar(progress = { animatedProgress })   // state read in the draw phase only
✏️ Your Task
Practise what you just learned about Step-by-Step Build 3: Deferring State Reads. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 7 of 13
Step-by-Step Build 4: The Gotchas

Gotcha 1: Expensive work in the body

kotlin
@Composable
fun StudentList(students: List<Student>, query: String) {
 // BAD: sorting and filtering runs on EVERY recomposition (every keystroke elsewhere, every animation frame)
 val shown = students.filter { it.name.contains(query, ignoreCase = true) }.sortedBy { it.name }
 LazyColumn { items(shown) { StudentRow(it) } }
}

Fix with remember keyed on the inputs:

kotlin
@Composable
fun StudentList(students: List<Student>, query: String) {
 val shown = remember(students, query) {
 students.filter { it.name.contains(query, ignoreCase = true) }.sortedBy { it.name }
 }
 LazyColumn { items(shown, key = { it.id }) { StudentRow(it) } }
}

Even better, for heavy work or data that is more than UI shaping, do it in the ViewModel (on a background dispatcher) and expose the result as state (lesson 61), so that composition only reads the finished list.

Gotcha 2: Lazy lists without keys

Without key, an insertion at the top shifts every item's identity, so many items recompose and lose state (lesson 39). Always pass key = { it.id }, and contentType for mixed lists.

Gotcha 3: Backwards writes

Writing to state after it has been read in the same composition makes Compose recompose again, possibly forever:

kotlin
@Composable
fun BackwardsWrite() {
 var count by remember { mutableIntStateOf(0) }
 Text("Count: $count") // reads count
 count++ // WRITES count after reading it: recomposition loop!
}

State must change in events (clicks, callbacks, effects), never in composition. The same hazard appears when a child writes to state that a parent already read.

Gotcha 4: Reading scroll position in composition

kotlin
// BAD: recomposes on every scroll position change
val showButton = listState.firstVisibleItemIndex > 0
// GOOD: only recomposes when the boolean flips
val showButton by remember { derivedStateOf { listState.firstVisibleItemIndex > 0 } }

Gotcha 5: Creating objects and lambdas that change every time

kotlin
@Composable
fun Screen(vm: VM) {
 // BAD with unstable params: a new Modifier-like object or list each recomposition
 Child(items = vm.items.map { it.copy(selected = it.id == vm.selectedId) })
}

Compute derived lists in the ViewModel (or with remember(vm.items, vm.selectedId)), so the instance only changes when the data does. With strong skipping, lambdas are remembered automatically; in older projects, wrap callbacks that capture unstable values with remember (or pass stable method references such as vm::onClick).

Gotcha 6: Overdraw and too-deep trees

Backgrounds on top of backgrounds make the GPU paint pixels many times. Avoid stacking opaque backgrounds, and prefer one Surface per region. Flatten unnecessary Box/Column nesting; use ConstraintLayout or a custom layout (lesson 56) only when it simplifies measurement. Compose handles deep nesting better than Views, but it is not free.

Gotcha 7: Images

Load images sized to the display (Coil does this by default for AsyncImage; lesson 31). Decoding a 4000-pixel photo to show a 100-dp thumbnail wastes memory and time.

Gotcha 8: Using Modifier factories badly

Modifier.composed { } allocates per use and is discouraged now; prefer Modifier.Node APIs or plain modifier chains. Create modifier chains in a remember only when they are large and constant.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 4: The Gotchas. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 8 of 13
Step-by-Step Build 5: Stability in Practice and the Compiler Reports

What the compiler thinks

The Compose compiler can write reports that say, for every composable, whether it is skippable and for every class, whether it is stable. Enable in the app module:

build.gradle.kts
composeCompiler {
 reportsDestination = layout.buildDirectory.dir("compose_compiler")
 metricsDestination = layout.buildDirectory.dir("compose_compiler")
}

Build the release variant (./gradlew assembleRelease) and open the files in app/build/compose_compiler/:

File What it shows
*-classes.txt Each class as stable, unstable, runtime with the reason per field
*-composables.txt Each composable as restartable, skippable, and each parameter stable/unstable
*-module.json Counts: skippable and restartable totals

A fragment of a classes report:

text
unstable class Student {
  stable val id: Int
  stable val name: String
  unstable val tags: List<String>
  <runtime stability> = Unstable
}

List<String> is considered unstable because List is an interface that could be backed by a mutable list. Remedies, in order of preference:

  1. Do nothing if strong skipping is on and the instance is stable across recompositions (the list comes from the ViewModel and does not change unless the data does).
  2. Use kotlinx.collections.immutable (ImmutableList, persistentListOf()), which the compiler treats as stable.
  3. Wrap in a small @Immutable data class: @Immutable data class Tags(val values: List<String>).
  4. Add the type to a stability configuration file (a text file with class patterns, referenced from composeCompiler { stabilityConfigurationFiles.add(...) }; check the Compose compiler documentation for the exact option name in your version) when the class lives in a library you do not control.

@Stable and @Immutable

Annotation Promise
@Immutable All public properties never change after construction
@Stable If a public property changes, Compose is notified (for example via mutableStateOf), and equals is consistent

Break the promise and the UI shows stale data. Use these annotations only for measured wins.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 5: Stability in Practice and the Compiler Reports. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 9 of 13
Step-by-Step Build 6: Macrobenchmark and Baseline Profiles

Macrobenchmark

Macrobenchmark measures real, user-visible performance of a release-like app on a device: start-up time and frame timing during scrolling. It lives in its own Gradle module (com.android.test plugin; Android Studio has a template: File → New → Module → Benchmark → Macrobenchmark).

StartupBenchmark.kt
package com.example.benchmark
import androidx.benchmark.macro.CompilationMode
import androidx.benchmark.macro.FrameTimingMetric
import androidx.benchmark.macro.StartupMode
import androidx.benchmark.macro.StartupTimingMetric
import androidx.benchmark.macro.junit4.MacrobenchmarkRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.uiautomator.By
import androidx.test.uiautomator.Direction
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class StartupBenchmark {
 @get:Rule
 val rule = MacrobenchmarkRule()
 @Test
 fun startupNoCompilation() = rule.measureRepeated(
 packageName = "com.example.perf",
 metrics = listOf(StartupTimingMetric()),
 compilationMode = CompilationMode.None(),
 iterations = 10,
 startupMode = StartupMode.COLD
 ) {
 pressHome()
 startActivityAndWait()
 }
 @Test
 fun scrollList() = rule.measureRepeated(
 packageName = "com.example.perf",
 metrics = listOf(FrameTimingMetric()),
 compilationMode = CompilationMode.DEFAULT,
 iterations = 5,
 startupMode = StartupMode.WARM
 ) {
 startActivityAndWait()
 val list = device.findObject(By.res("studentList"))
 list.setGestureMargin(device.displayWidth / 5)
 list.fling(Direction.DOWN)
 device.waitForIdle()
 }
}

(Expose testTag("studentList") as a resource id with Modifier.semantics { testTagsAsResourceId = true } on a root composable so UI Automator can find it, in the app code.) Run it on a device with the benchmark build type. The results show StartupTimingMetric (time to initial display) and FrameTimingMetric (frame duration and overrun percentiles such as P50, P90, P99). Compare before and after a change; P99 frame times reveal the worst jank.

Baseline Profiles

When a user opens an app for the first time after install, much of your code and Compose's library code is interpreted or JIT-compiled, so the first launch and first scroll are slower. A Baseline Profile is a list of classes and methods that matter at start-up and in critical journeys; it is packaged in the app and used at install time so the system can pre-compile them (AOT). Google reports significant start-up and frame-time improvements, commonly in the tens of percent, and Compose libraries ship their own profiles, but your own screens benefit from yours.

Steps (check the official guide for the current plugin version and DSL, which are updated often):

  1. Add the Baseline Profile Gradle plugin (androidx.baselineprofile) and a :baselineprofile module (Android Studio's Baseline Profile Generator module template does this).
  2. Write a generator test that exercises your critical user journeys:
BaselineProfileGenerator.kt
package com.example.baselineprofile
import androidx.benchmark.macro.junit4.BaselineProfileRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.uiautomator.By
import androidx.test.uiautomator.Direction
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class BaselineProfileGenerator {
 @get:Rule
 val rule = BaselineProfileRule()
 @Test
 fun generate() = rule.collect(packageName = "com.example.perf") {
 pressHome()
 startActivityAndWait()
 device.findObject(By.res("studentList"))?.fling(Direction.DOWN)
 device.waitForIdle()
 }
}
  1. Run the Gradle task the plugin creates (for example ./gradlew :app:generateBaselineProfile). It writes baseline-prof.txt into your app's src/main.
  2. Build the release app: the profile is included in the AAB and applied by Google Play when installing (and by ProfileInstaller on other installs).
  3. Measure again with Macrobenchmark using CompilationMode.Partial(BaselineProfileMode.Require) versus CompilationMode.None() and compare.

Re-generate the profile when your key screens change.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 6: Macrobenchmark and Baseline Profiles. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 10 of 13
Step-by-Step Build 7: A Worked Problem-Solving Session

The scenario (adapted from the codelab's idea): a Students screen with 500 rows is janky. Here is how to approach it.

1. Measure. Macrobenchmark FrameTimingMetric on a release-like build on a low-end phone shows P90 frame duration 28 ms (budget 16 ms).

2. Diagnose. Layout Inspector with recomposition counts: while scrolling, StudentRow composables each recompose 40 times, and the header recomposes on every scroll event.

3. Find causes in the code:

kotlin
@Composable
fun StudentsScreen(students: List<Student>, listState: LazyListState) {
 val showHeader = listState.firstVisibleItemIndex == 0 // cause A: read in composition
 LazyColumn(state = listState) { // cause B: no keys
 item { if (showHeader) Banner() }
 items(students) { s ->
 val ranking = students.sortedByDescending { it.score }.indexOf(s) + 1 // cause C: O(n log n) per row!
 StudentRow(s, ranking)
 }
 }
}

4. Fix one by one, measuring:

kotlin
@Composable
fun StudentsScreen(students: List<Student>, listState: LazyListState) {
 val showHeader by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } } // A
 val rankings = remember(students) { // C
 students.sortedByDescending { it.score }.mapIndexed { i, s -> s.id to i + 1 }.toMap()
 }
 LazyColumn(state = listState) {
 item(key = "banner") { if (showHeader) Banner() }
 items(students, key = { it.id }) { s -> // B
 StudentRow(s, rankings.getValue(s.id))
 }
 }
}

5. Verify. The counts drop to 0–1 recompositions per row; P90 frame time falls to 11 ms. Record it; if one change gave no improvement, revert it.

6. Guard. Add a Macrobenchmark to CI so regressions are caught.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 7: A Worked Problem-Solving Session. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 11 of 13
Guided Practice Exercises
🎯 Your Challenge

Exercise 1: Defer the read

This floating button fades as the user scrolls, and the whole screen recomposes on every scroll event. Fix it so only the draw phase re-runs.

kotlin
@Composable
fun FadingFab(scroll: ScrollState) {
 val alpha = (1f - scroll.value / 400f).coerceIn(0f, 1f)
 FloatingActionButton(onClick = {}, modifier = Modifier.alpha(alpha)) { Icon(Icons.Default.Add, "Add") }
}
kotlin
import androidx.compose.foundation.ScrollState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
@Composable
fun FadingFab(scroll: ScrollState) {
 FloatingActionButton(
 onClick = {},
 modifier = Modifier.graphicsLayer {
 alpha = (1f - scroll.value / 400f).coerceIn(0f, 1f) // read in the draw phase
 }
 ) { Icon(Icons.Default.Add, contentDescription = "Add") }
}

Because scroll.value is now read inside the graphicsLayer lambda, scrolling invalidates only the layer, not the composition. Verify with recomposition counts: the FAB's count should stay constant while scrolling.

🎯 Your Challenge

Exercise 2: Backwards write

Explain why this composable loops, and fix it.

kotlin
@Composable
fun Total(items: List<Item>) {
 var total by remember { mutableIntStateOf(0) }
 total = items.sumOf { it.price }
 Text("Total: $total")
}

The write to total happens during composition, and Text reads total in the same composition. Writing state that has already been read, or that another part of the tree reads, invalidates those readers, so Compose schedules another recomposition. Depending on the values this wastes work or loops. State must not be updated in composition. Fix: derive the value instead of storing it:

kotlin
@Composable
fun Total(items: List<Item>) {
 val total = remember(items) { items.sumOf { it.price } }
 Text("Total: $total")
}

A derived value needs no state at all.

🎯 Your Challenge

Exercise 3: Plan an experiment

A teammate says "our app is slow, let's add @Immutable to all our model classes." Write a measured plan for investigating instead, in five steps, naming one tool per step.

(1) Define the problem and measure: Macrobenchmark FrameTimingMetric and StartupTimingMetric on a release-like build on a low-end device. (2) Diagnose recomposition: Layout Inspector with recomposition counts during the slow interaction. (3) Check stability facts: Compose compiler reports for the slow composables to see whether parameters are unstable and whether strong skipping already skips them. (4) Change one thing (for example derivedStateOf, keys, deferred reads, or @Immutable on one class that the reports and counts implicate) and re-measure the same benchmark. (5) Keep or revert based on numbers, and add the benchmark to CI. Blanket annotations are risky: a wrong stability promise causes stale UI and may not fix anything.

✏️ Your Task
Practise what you just learned about Guided Practice Exercises. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 12 of 13
Common Mistakes (and How to Fix Them)

Mistake #1: Profiling a debug build. Fix: release-like builds on real devices.

Mistake #2: Optimising without measuring. Fix: the measure-change-verify loop.

Mistake #3: Reading fast state in composition. Fix: lambda modifiers, graphicsLayer, derivedStateOf.

Mistake #4: Heavy work in composable bodies. Fix: remember(keys) or the ViewModel.

Mistake #5: Missing lazy keys. Fix: key = { it.id }.

Mistake #6: Writing state in composition. Fix: events and effects.

Mistake #7: Annotating everything @Immutable. Fix: only where measured, and keep the promise.

Mistake #8: New list instances every recomposition. Fix: build once in the ViewModel or remember.

Mistake #9: Skipping Baseline Profiles for production apps. Fix: generate and measure them.

Mistake #10: Leaving log helpers in release. Fix: remove diagnostics before shipping.

✏️ Your Task
Practise what you just learned about Common Mistakes (and How to Fix Them). Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 13 of 13
Knowledge Check

Question 1. What does strong skipping change?

Composables with unstable parameters can be skipped when the same instances are passed (compared with ===), and lambdas are automatically remembered; it is on by default from Kotlin 2.0.20 with the Compose compiler plugin.

Question 2. Why is Modifier.offset { ... } better than Modifier.offset(x = ...) for a fast-changing value?

The lambda form reads the state in the layout phase, so changes re-run layout only and avoid recomposition.

Question 3. What does derivedStateOf give you with scroll position?

It converts a fast-changing value into a value that changes rarely (such as a boolean), so readers recompose only when the result changes.

Question 4. What are Baseline Profiles for?

They list critical classes and methods so the system can pre-compile them at install time, improving start-up and early frame times.

Question 5. What are the steps of the performance loop?

Measure, diagnose, hypothesise, change one thing, and verify by measuring again; revert changes that do not help.

Go deeper: official resources

✏️ Your Task
Practise what you just learned about Knowledge Check. Open Android Studio, type the examples by hand, change a value, and observe what happens.
🏗️ Build It — Mini Project
Performance Clinic

Project goal: Take a deliberately slow screen and make it fast, proving every improvement with numbers.

Requirements:

  1. Create a Students screen with 1,000 rows containing at least the five gotchas from Build 4 (no keys, sorting in the row, scroll read in composition, new list instances, a backwards-style recomposition trigger).
  2. Create a benchmark build type and a Macrobenchmark module with a scroll test.
  3. Record the baseline numbers (P50/P90 frame duration) on a real device or the best emulator you have.
  4. Use the Layout Inspector recomposition counts and the compiler reports to list your findings.
  5. Fix one gotcha at a time, re-measuring after each; keep a table of results.
  6. Generate a Baseline Profile and compare start-up with and without it.
  7. Write a short report: what helped, what did not, what you reverted.

Stretch goal: add the benchmark to a GitHub Actions workflow that fails the build if P90 frame time regresses by more than 10%.

The fixed screen:

StudentsScreen.kt
package com.example.perf
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.platform.testTag
data class Student(val id: Int, val name: String, val score: Int)
@Composable
fun StudentsScreen(students: List<Student>, query: String) {
 val listState = rememberLazyListState()
 val showBanner by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } }
 val shown = remember(students, query) {
 students.filter { it.name.contains(query, ignoreCase = true) }
 }
 val rankings = remember(students) {
 students.sortedByDescending { it.score }
 .mapIndexed { index, s -> s.id to index + 1 }
 .toMap()
 }
 Column(Modifier.semantics { testTagsAsResourceId = true }) {
 LazyColumn(Modifier.testTag("studentList"), state = listState) {
 item(key = "banner", contentType = "banner") {
 if (showBanner) Text("Top of the class list")
 }
 items(shown, key = { it.id }, contentType = { "student" }) { s ->
 Text("${rankings.getValue(s.id)}. ${s.name} - ${s.score}")
 }
 }
 }
}

Results table (example numbers; yours will differ):

text
Change                              P50 ms   P90 ms   Row recompositions/scroll
Baseline (all five gotchas)           16.1     31.4        ~40
+ keys and contentType                14.8     27.9        ~25
+ remember(rankings) (no per-row sort) 9.7     15.2        ~25
+ derivedStateOf for the banner        9.5     14.1         ~2
+ stable list instance from VM         9.4     13.8         ~1
+ Baseline Profile (start-up only)     --      --        cold start 780 ms -> 610 ms

Report points: the biggest win came from removing sorting inside each row (an accidental O(n² log n)); derivedStateOf and keys mostly cut recomposition counts, with modest frame-time gains; stability annotations were not needed under strong skipping once the list instance stopped changing; one attempted change (@Immutable on Student) showed no measurable difference and was reverted.

1

Open your finished project in Android Studio. Check that the .gitignore file the wizard created is in the project root. It keeps local.properties, build folders and keystores out of Git. Never commit passwords or signing keys.

2

Choose Git > Create Git Repository… (or VCS > Enable Version Control Integration in older versions), select the project folder, then open Git > Commit, tick every file, and write the message “Add Android lesson 57 project”.

3

On github.com click New repository, name it android-lesson-57-compose-performance, set it to Public, leave Add a README unticked, and click Create repository.

4

Back in Android Studio choose Git > Manage Remotes…, add the repository URL as origin, then choose Git > Push… and push your main branch. Prefer the terminal? Run git remote add origin <your-url> then git push -u origin main.

5

Add a README.md with a screenshot of your app running and a short description. Employers and clients read READMEs first. Your project is now part of your public portfolio. 🎉

Lesson 57 complete! 🎉

You covered:

  • Lesson Introduction
  • Prerequisite Concepts
  • Conceptual Understanding: Where Time Goes
  • Step-by-Step Build 1: Measure First, in a Release-Like Build
  • Step-by-Step Build 2: Debugging Recomposition
  • Step-by-Step Build 3: Deferring State Reads
  • Step-by-Step Build 4: The Gotchas
  • Step-by-Step Build 5: Stability in Practice and the Compiler Reports
  • Step-by-Step Build 6: Macrobenchmark and Baseline Profiles
  • Step-by-Step Build 7: A Worked Problem-Solving Session
  • Guided Practice Exercises
  • Common Mistakes (and How to Fix Them)
  • Knowledge Check
All 72 lessons in Google Android Studio ·Techbase Academy ·© 2025–2026 Techbase Consultant Services, Ibadan
Esc
↑ ↓ to moveEnter to openEsc to close