跪拜 Guibai
← Back to the summary

Recomposition Replaces Overdraw as Compose’s Performance Battlefield

What You Learned About Rendering Optimization in the XML Era Is Mostly Not the Point in Compose: Recomposition Is the New Battlefield

This is the second article in the “Android App Performance Optimization” series. The previous article cured a product list’s jank by killing Overdraw and flattening the hierarchy with ConstraintLayout. Now that the migration target is Jetpack Compose, most of that playbook stops being the point. The new battlefield is called Recomposition.

After this article you will know: how to locate recomposition problems with Layout Inspector and the Compiler Report; understand recomposition triggers and stability, including the new rules after Strong Skipping; and fix a search list page in four steps, verifying each step with real-device data.

Environment: based on Kotlin 2.0.20+ (the Compose compiler ships with Kotlin; Strong Skipping is enabled by default). All data comes from the companion Demo (compose-recomposition-demo/, Kotlin 2.0.21 + Compose BOM 2024.10.01). The test device is a Xiaomi Mi 6 (Snapdragon 835, Android 9, 60Hz), a 2017 phone chosen because it amplifies recomposition cost into visible dropped frames. Recomposition counts are measured with counters in a Release build; frame rates are always measured in Release builds.

1. The Problem: Why Does a Compose List Still Stutter?

The product manager is back.

“Every character typed into the search box makes the list jitter, and scrolling doesn’t feel as smooth as the XML version.”

Your first reaction is still the previous article’s checklist: is it Overdraw? Is the hierarchy too deep?

Rule out Overdraw first. Open Debug GPU Overdraw (Settings → Developer options → Debug GPU overdraw → Show overdraw areas) and look at the color scale.

![Debug GPU Overdraw view of the search page on Mi 6]

The page background is blue (1 layer: a Surface over the Window background), text and label backgrounds are green (2 layers), and only text on the labels is pink (3 layers). There is no red (4+ layers). Overdraw still exists in Compose (stacking multiple background or Surface modifiers can produce it), but it is clearly not the main cause here.

Then rule out hierarchy depth. The official documentation says: “Compose handles nested layouts efficiently, making them a great way to design a complicated UI. This is an improvement from Android Views, where you need to avoid nested layouts for performance reasons.” (Compose layout basics.) Compose’s layout system disallows measuring a child twice; however deep the hierarchy, measurement cost grows linearly, not multiplicatively like nested RelativeLayout.

Most of what the previous article taught (removing backgrounds, flattening layers) barely helps here. The problem is somewhere else.

The new performance killer has a new name: Recomposition. The CPU re-executes composable functions that did not need to run again. Finding it takes a different set of tools.

Open Layout Inspector and Expose the Problem

Android Studio menu:

View → Tool Windows → Layout Inspector

Run the app in a Debug build and Layout Inspector attaches automatically. In the component tree, turn on Show Recomposition Counts (under View Options at the top right of the Component Tree). Before interacting, click Reset to clear counts.

Prerequisites: recomposition counts require API 29+ (the official docs say: “To view recomposition counts, make sure your app is using an API level of 29 or higher”). The performance data in this article comes from a Mi 6 (Android 9 / API 28), which cannot display recomposition counts, so the Layout Inspector screenshots are taken from another API 29+ device (Xiaomi 13 Pro). The two screens have different heights, so the screenshots show a few more visible items than the Mi 6. All concrete numbers in this article use the counters on the Mi 6 and the measurement table at the end.

Type one character into the search box.

Each composable in the component tree now shows three count columns (in the current Layout Inspector; the official docs still describe two columns, recompositions/skips):

This article mainly looks at the first and third columns: how many times SearchScreen recomposed (to find the earthquake source), and how many times ProductItem was skipped (to see whether smart recomposition is working).

You will see a structure like this (illustrative; only the two key columns are kept for brevity):

SearchScreen              Recompositions 1   Skipped 0
  ├─ SearchInput          Recompositions 1   Skipped 0
  └─ LazyColumn
       ├─ ProductItem     Recompositions 1   Skipped 0
       ├─ ProductItem     Recompositions 1   Skipped 0
       ... (6 visible items, all: Recompositions 1 / Skipped 0)

![Layout Inspector after entering one character at Step 0]

The image above is real Layout Inspector output: each ProductItem under LazyColumn has recomposition counts, and Skipped Recompositions is empty (0), meaning not a single visible item was skipped. That is the root disease of Step 0.

The extra ProductRow in the screenshot is a shared layout function extracted in the Demo across five versions (see appendix). In the article code, ProductItem directly wraps a Row and has no such layer.

Layout Inspector only works with Debug builds and is not easy to automate. So the companion Demo inserts a counter line at the top of each composable under test:

SideEffect { RecompositionCounter.inc("ProductItem") }

SideEffect itself cannot be skipped: whenever the function body runs, it runs; whenever the function is skipped, it does not run. So it measures exactly how many times the function executed. The image below is the counter panel in a Release build after typing “o” after “ph”.

![Step 0/1/2 execution counts after typing “o”]

Look at Step 0 (the buggy version), the leftmost set: two signals appear at the same time.

  1. SearchScreen itself is recomposing. It is the epicenter of this recomposition.
  2. All six ProductItem composables are re-executed. The result set of “ph” → “pho” is identical; none of the product content changed. This is direct evidence that Compose’s “smart recomposition” did not work.

Note that LazyColumn only composes items visible on screen, so the count is the number of visible items, not the list length (500).

Next, two questions: why does SearchScreen recompose? Why can’t ProductItem be skipped?

2. Background Knowledge

2.1 The Three Phases of Compose Rendering, and the Key Difference from XML

Compose renders a frame in three phases:

  1. Composition: executes @Composable functions, decides “what to show”.
  2. Layout: measures and places each node, decides “where to put it”.
  3. Drawing: draws nodes onto a Canvas and submits to the GPU.

Compared with XML:

Dimension XML era Compose
Measurement passes RelativeLayout double measures; LinearLayout measures twice when weight is used Each child can be measured only once (intrinsics are pre-measurement size queries, not a second measurement)
Hierarchy depth Deeper means slower; flattening required Deep hierarchies stop being a primary problem
Main optimization target Reduce wasted Measure/Draw work Reduce wasted work in the Composition phase

The last row is the key. For parts whose state did not change, Compose can skip Composition and reuse the previous result. So the core goal of Compose performance optimization is: let as many composables as possible be skipped when state changes, not re-executed.

2.2 What Triggers Recomposition: Where State Is Read Determines the Recomposition Scope

Compose uses a Snapshot System to track reads and writes of state. When each @Composable function executes, the system records which states it read. When state changes:

state query changes
   ↓
find all recomposition scopes that read query
   ↓
re-execute them next frame = Recomposition

Core rule: wherever you read state, that is the epicenter of recomposition.

Here is an easy-to-miss detail: the recomposition scope is “the nearest non-inline composable function”. Column, Row, and Box are inline functions, and their content lambdas do not form their own scopes. So reading state inside Column { } recomposes the outer function that wraps the Column. To shrink the recomposition scope, the code that reads state must be extracted into a standalone composable function; wrapping it in a Column alone does nothing. That is what Step 1 does.

If query is read at the top level of SearchScreen, the entire SearchScreen function body re-executes. Whether its child composables can escape depends on stability, as covered next.

2.3 Stability: Whether Child Composables Can Be Skipped

When a parent recomposes, Compose checks each child composable’s parameters one by one: unchanged parameters mean the child can be skipped. How “changed” is determined depends on whether the parameter type is stable or unstable:

// ✅ Stable: primitive types, String, function types, enums,
//    and classes where every property is a val whose type is stable (not limited to data classes)
data class A(val id: Int, val name: String)

// ❌ Unstable: contains a var property
data class B(val id: Int, var name: String)

// ❌ Unstable: contains a List interface (the compiler cannot confirm it is not a MutableList)
data class C(val id: Int, val tags: List<String>)

Comparison rules (Strong Skipping is enabled by default in Kotlin 2.0.20+):

Stable type Unstable type
Comparison equals() content equality === instance equality
Before Strong Skipping skip if parameters unchanged if one parameter is unstable, the whole function cannot skip
After Strong Skipping skip if parameters unchanged skip only if the instance is unchanged; a changed instance cannot skip

This table is the key to the whole article. Strong Skipping gives composables with unstable parameters eligibility to be skipped, but it does not mean stability no longer matters:

So after Strong Skipping, the new problem pattern is instance churn: objects of unstable types are recreated on every recomposition.

Use Compiler Report to See Stability

The Compose compiler can output its stability inference as reports. In the module build.gradle.kts (note: the composeCompiler block lives at the top level of the file, not inside android {}; apply the Compose compiler plugin first):

plugins {
    alias(libs.plugins.android.application)
    alias(libs.plugins.kotlin.android)
    alias(libs.plugins.compose.compiler)   // org.jetbrains.kotlin.plugin.compose
}

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

Run ./gradlew assembleRelease. Four files appear under build/compose_compiler/:

<module>_release-composables.txt   whether each composable is skippable, and whether each parameter is stable
<module>_release-classes.txt       whether each class is inferred as stable or unstable, plus the reason
<module>_release-composables.csv   same as above, table form, convenient for filtering
<module>_release-module.json       module-level statistics, including compiler switches

First look at module.json to confirm Strong Skipping is on (actual Demo output):

"featureFlags": {
    "StrongSkipping": true,
    "IntrinsicRemember": true,
    "OptimizeNonSkippingGroups": false
}

Then look at the two reports for the buggy version (actual Demo output, abridged):

// composables.txt
restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun ProductItem(
  unstable product: ProductUi      // ← look at unstable before the parameter, not just skippable
)

// classes.txt
unstable class ProductUi {
  stable val id: Int
  stable val name: String
  stable val price: String
  stable val imageUrl: String
  unstable val tags: List<String>  // ← culprit
  <runtime stability> = Unstable
}

Under Strong Skipping, almost everything is skippable: in the Demo’s composables.csv, all 23 named composables are skippable, including ProductItem with its unstable parameter. The real warning signal is unstable before the parameter. (If your project uses Kotlin below 2.0.20, or Strong Skipping is manually disabled, then missing skippable is itself the problem.)

2.4 Two Key Tools: remember and derivedStateOf

remember: cache computed results across recompositions

// ❌ Re-sorts on every recomposition
val sorted = contacts.sortedWith(comparator)

// ✅ Recomputes only when contacts or comparator changes
val sorted = remember(contacts, comparator) { contacts.sortedWith(comparator) }

derivedStateOf: state rate reduction

val listState = rememberLazyListState()

// ❌ firstVisibleItemIndex changes every time one item is scrolled past;
//    the composable that reads it recomposes every time
val showFab = listState.firstVisibleItemIndex > 0

// ✅ All you care about is the boolean “is greater than 0”;
//    recomposition triggers only when that boolean actually flips
val showFab by remember { derivedStateOf { listState.firstVisibleItemIndex > 0 } }

The essence of derivedStateOf: it puts a filter between a rapidly changing input state and recomposition, and notifies the UI only when the output value changes.

Conversely, if the output changes just as often as the input, it is pure overhead. For example, derivedStateOf { "$firstName $lastName" } cannot filter out any recomposition because any name change changes the result; write remember(firstName, lastName) { ... }, or just compute it directly.

3. Concrete Example

Case: a product search list page — a search box on top (real-time filtering), a LazyColumn product list (500 products), and a FAB at the bottom right that follows scroll offset.

Step 0: Diagnose First, Look at Tools, Then Change Code

Here is the full baseline code (later steps only show the changed parts). Dependencies: Compose Material3, material-icons-core, lifecycle-runtime-compose, Coil (coil-compose).

// ===== Data layer =====
data class Product(
    val id: Int,
    val name: String,
    val priceCent: Long,
    val imageUrl: String,
    val tags: List<String>
)

interface ProductRepository {
    fun productsFlow(): Flow<List<Product>>
}

// ===== UI layer model: format the price before handing to UI =====
data class ProductUi(
    val id: Int,
    val name: String,
    val price: String,
    val imageUrl: String,
    val tags: List<String>            // ← List interface: compiler judges Unstable
)

fun Product.toUi() = ProductUi(
    id = id,
    name = name,
    price = "¥%.2f".format(priceCent / 100.0),
    imageUrl = imageUrl,
    tags = tags
)

class SearchViewModel(repository: ProductRepository) : ViewModel() {
    val products: StateFlow<List<Product>> = repository.productsFlow()
        .stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
}
// ===== Page (buggy version) =====
@Composable
fun SearchScreen(viewModel: SearchViewModel) {
    val products by viewModel.products.collectAsStateWithLifecycle()
    var query by remember { mutableStateOf("") }               // ❌ ① query read at the top level
    val listState = rememberLazyListState()

    // ❌ ② filter + map run on every recomposition: creates brand-new ProductUi instances
    val uiItems = products
        .filter { it.name.contains(query, ignoreCase = true) }
        .map { it.toUi() }

    // ❌ ③ every scrolled item makes SearchScreen recompose
    val showFab = listState.firstVisibleItemIndex > 0

    Box(Modifier.fillMaxSize()) {
        Column {
            SearchInput(query = query, onQueryChange = { query = it })
            LazyColumn(state = listState) {
                items(uiItems) { ProductItem(it) }              // ❌ ④ no key
            }
        }
        if (showFab) {
            ScrollFab(listState, Modifier.align(Alignment.BottomEnd).padding(16.dp))
        }
    }
}

// Named SearchInput to avoid clashing with Material3's SearchBar
@Composable
fun SearchInput(query: String, onQueryChange: (String) -> Unit) {
    OutlinedTextField(
        value = query,
        onValueChange = onQueryChange,
        singleLine = true,
        placeholder = { Text("Search products") },
        modifier = Modifier.fillMaxWidth().padding(12.dp)
    )
}

@Composable
fun ProductItem(product: ProductUi) {
    Row(Modifier.fillMaxWidth().padding(12.dp)) {
        AsyncImage(
            model = product.imageUrl,
            contentDescription = null,
            modifier = Modifier.size(80.dp)
        )
        Column(Modifier.padding(start = 12.dp)) {
            Text(product.name, style = MaterialTheme.typography.bodyMedium, maxLines = 2)
            Text(
                product.price,
                style = MaterialTheme.typography.titleMedium,
                color = MaterialTheme.colorScheme.error
            )
            Row { product.tags.forEach { TagChip(it) } }
        }
    }
}

@Composable
fun TagChip(text: String) {
    Text(
        text = text,
        style = MaterialTheme.typography.labelSmall,
        modifier = Modifier
            .padding(end = 6.dp)
            .background(MaterialTheme.colorScheme.secondaryContainer, RoundedCornerShape(10.dp))
            .padding(horizontal = 6.dp, vertical = 2.dp)
    )
}

// ❌ ⑤ reads scroll offset in the function body: scroll recomposes every frame
@Composable
fun ScrollFab(listState: LazyListState, modifier: Modifier = Modifier) {
    val offsetPx = listState.firstVisibleItemScrollOffset
    val offsetDp = with(LocalDensity.current) { (offsetPx / 2).toDp() }
    FloatingActionButton(onClick = { }, modifier = modifier.offset(y = offsetDp)) {
        Icon(Icons.Default.KeyboardArrowUp, contentDescription = "Back to top")   // available in the core icon set
    }
}

Two details: collecting the Flow uses collectAsStateWithLifecycle() (collection stops automatically while the screen is not visible), the recommended approach on Android. The icon is KeyboardArrowUp rather than ArrowUpward, because ArrowUpward is not in the core icon set and would force adding the very large material-icons-extended artifact.

Runtime symptom (leftmost set in the comparison image above): SearchScreen is recomposing, and all six ProductItem composables are re-executed.

Then use Compiler Report to find the cause (actual output in section 2.3): ProductItem’s parameter is unstable product: ProductUi, and ProductUi is unstable because of unstable val tags: List.

The two pieces of evidence put the causal chain together:

key press → query changes → SearchScreen recomposes (epicenter is at the top)
     → function body re-runs filter + map → brand-new ProductUi instances
     → ProductUi is unstable, compared with === → a new instance is always “not equal”
     → every visible ProductItem cannot be skipped

The code has five problems baked in (①~⑤ in the comments), and the next steps fix them one by one.

Step 1: Shrink the Recomposition Scope, Sink the Query State

Root cause: query is read at the top of SearchScreen, making the entire page function the epicenter.

Fix: put query and the list into a standalone composable (a real function, see 2.2), so SearchScreen no longer reads query.

@Composable
fun SearchScreen(viewModel: SearchViewModel) {
    val products by viewModel.products.collectAsStateWithLifecycle()
    val listState = rememberLazyListState()
    val showFab = listState.firstVisibleItemIndex > 0           // Step 4 handles this later

    Box(Modifier.fillMaxSize()) {
        SearchContent(products = products, listState = listState)
        if (showFab) {
            ScrollFab(listState, Modifier.align(Alignment.BottomEnd).padding(16.dp))
        }
    }
}

@Composable
private fun SearchContent(products: List<Product>, listState: LazyListState) {
    var query by remember { mutableStateOf("") }               // ✅ query sunk here
    val uiItems = products
        .filter { it.name.contains(query, ignoreCase = true) }
        .map { it.toUi() }                                     // still inside Composition; Step 2 handles it

    Column {
        SearchInput(query = query, onQueryChange = { query = it })
        LazyColumn(state = listState) {
            items(uiItems) { ProductItem(it) }
        }
    }
}

Verification (typing): in the middle image of the comparison above, SearchScreen executes 0 times when typing; the epicenter has moved to SearchContent.

But note: ProductItem is still executed 6 times, not skipped at all. Shrinking the epicenter scope does not automatically make children skippable. The next step is what actually stops ProductItem.

Unexpected gain (scrolling): this step’s benefit is much larger for scrolling than for typing. The image below shows counts after 10 upward scrolls on each version:

![Step 0 vs Step 1 execution counts after 10 scrolls]

In Step 0, SearchScreen recomposes when every item scrolls past (problem ③), and each recomposition re-maps a new batch of ProductUi, so all visible items recompose along with it: ProductItem executed 431 times. In Step 1, SearchScreen still recomposes, but filter + map have moved inside SearchContent, and both parameters of SearchContent (products and listState) are unchanged, so the whole function is skipped. ProductItem only runs the 54 times for newly scrolled-in items.

Averaged over 5 runs (10 up and 10 down scrolls), ProductItem dropped from 785 to 106 executions, and scroll dropped-frame rate fell from 34.1% to 20.4%. This also shows that sinking state is worth more than “moving unrelated content out”: every line of computation inside the epicenter function re-executes with the epicenter.

Step 2: Fix Instance Churn So ProductItem Can Truly Be Skipped

Root cause: ProductUi is unstable, and every recomposition maps new instances. Combined, === is never equal.

Two roads; the recommendation is to take both.

Route A: stop the instance churn. map should not be inside Composition. Map only when data changes, in the ViewModel:

class SearchViewModel(repository: ProductRepository) : ViewModel() {
    // ✅ maps only when the data source changes; instances stay the same afterwards
    val products: StateFlow<List<ProductUi>> = repository.productsFlow()
        .map { list -> list.map { it.toUi() } }
        // add .flowOn(Dispatchers.Default) for large lists to move mapping off the main thread
        .stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
}

@Composable
private fun SearchContent(products: List<ProductUi>, listState: LazyListState) {
    var query by remember { mutableStateOf("") }
    // ✅ filter only picks existing instances; it creates no new ProductUi
    val uiItems = remember(products, query) {
        products.filter { it.name.contains(query, ignoreCase = true) }
    }
    // ... the rest unchanged
}

Route B: make the type stable. Replace List with kotlinx immutable collections so the comparison changes from === to equals(); even when the instance changes, equal content can still skip. For example, when the data source refreshes and re-maps a batch of new instances with equal content, Route A cannot block it, but Route B can. It acts as insurance on top of Route A:

// build.gradle.kts (the Demo uses 0.3.8; check the latest version on Maven Central)
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.8")
data class ProductUi(
    val id: Int,
    val name: String,
    val price: String,
    val imageUrl: String,
    val tags: ImmutableList<String>      // ✅ stable
)

fun Product.toUi() = ProductUi(
    id = id, name = name,
    price = "¥%.2f".format(priceCent / 100.0),
    imageUrl = imageUrl,
    tags = tags.toImmutableList()
)

What if you cannot change the type? Two cases:

composeCompiler {
    // Kotlin 2.0.x syntax; newer versions use the plural form stabilityConfigurationFiles.add(...)
    stabilityConfigurationFile = rootProject.layout.projectDirectory.file("stability_config.conf")
}
// stability_config.conf: one class name per line, wildcards supported
kotlin.collections.List
com.thirdparty.model.*

The Demo measured that adding the line kotlin.collections.List changes the Step 0 report from unstable class ProductUi to stable class ProductUi, and the tags line to stable val tags: List.

Annotations and configuration files are both ways of overriding the compiler’s judgment. If the guarantee does not hold (for example, a MutableList that is later mutated really is passed in), the UI can get “should refresh but did not” bugs that are hard to debug. So prefer immutable collections, and treat annotations/config files as fallbacks.

Compiler Report verification (actual Demo output):

// composables.txt
restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun ProductItem(
  stable product: ProductUi
)

// classes.txt
stable class ProductUi {
  ...
  stable val tags: ImmutableList<String>
  <runtime stability> =
}

is empty; this is actual output, not a typo: stability is decided at compile time and needs no runtime check. Unstable classes would print Unstable here.

Runtime verification: back to the rightmost image in the comparison above. After typing “o” and the result set does not change (“ph” and “pho” both match Phone and Headphone), ProductItem executes 0 times; all 6 items are skipped.

When verifying, choose an input that truly does not change the result set. Examples like “typing a space” or “pinyin IME intermediate states” are unreliable: when query becomes “phone ” (with trailing space), products whose names do not contain that trailing space are filtered out; pinyin IME composed text (for example “shou”) also passes through onValueChange into query, and the result set usually becomes empty.

Still, when input genuinely changes the result set, some items still recompose. Without a key, Compose can only match items by position. When the list changes, a different product sits at a position, and its parameter is different. That is the next step.

Step 3: Add Keys to LazyColumn So Items Match by Identity, Not Position

Root cause: LazyColumn uses position (index) as an item’s identity by default. After filtering or reordering, the product that was at position 3 moves to position 2; Compose sees “the parameter at position 2 changed” and recomposes.

LazyColumn(state = listState) {
    items(
        items = uiItems,
        key = { it.id }                 // ✅ business id as identity
    ) { ProductItem(it) }
}

With a key, Compose knows “this item only moved position”; if content is unchanged it reuses and skips it, and only newly entering items need composition.

Verification: typing from “p” to “ph” changes the result set (Laptop and Speaker are filtered out):

![With and without key from “p” to “ph”]

Walking through positions matches the measured numbers:

Position at “p” at “ph” no key (compare by position) with key (compare by id)
1 Phone X1 Phone X1 same, skip already on screen, skip
2 Laptop X1 Headphone X1 different, recompose already on screen, skip
3 Headphone X1 Phone X2 different, recompose already on screen, skip
4 Speaker X1 Headphone X2 different, recompose new entry, compose
5 Phone X2 Phone X3 different, recompose new entry, compose
6 Laptop X2 Headphone X3 different, recompose new entry, compose
Total 5 3

Pitfalls recorded:

Step 4: Defer State Reads So Scrolling No Longer Triggers Composition

The first three steps fix “while typing” recomposition. Two scrolling problems remain: problem ③ (showFab makes SearchScreen recompose as items switch) and problem ⑤ (ScrollFab recomposes every frame).

4.1 Use derivedStateOf to rate-limit visibility (problem ③):

@Composable
fun SearchScreen(viewModel: SearchViewModel) {
    val products by viewModel.products.collectAsStateWithLifecycle()
    val listState = rememberLazyListState()

    // ✅ SearchScreen recomposes only when true/false flips
    val showFab by remember { derivedStateOf { listState.firstVisibleItemIndex > 0 } }

    Box(Modifier.fillMaxSize()) {
        SearchContent(products = products, listState = listState)
        if (showFab) {
            ScrollFab(
                scrollOffset = { listState.firstVisibleItemScrollOffset },   // note: pass a lambda
                modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp)
            )
        }
    }
}

4.2 Defer the offset read to the Layout phase (problem ⑤):

// ✅ receives () -> Int, not Int
@Composable
fun ScrollFab(scrollOffset: () -> Int, modifier: Modifier = Modifier) {
    FloatingActionButton(
        onClick = { },
        modifier = modifier.offset { IntOffset(0, scrollOffset() / 2) }   // lambda executes in Layout phase
    ) {
        Icon(Icons.Default.KeyboardArrowUp, contentDescription = "Back to top")
    }
}

Why does this stop recomposition? The state is read in Modifier.offset { }’s lambda, which executes in the Layout phase, so when the scroll offset changes, Compose only needs to re-layout and does not need to re-execute any composable function. Previously, reading firstVisibleItemScrollOffset directly in the function body was a Composition-phase read, and every frame recomposed.

The same idea applies to the Draw phase: reading state inside Modifier.drawBehind { }, Modifier.drawWithContent { }, or Modifier.graphicsLayer { } lambdas means state changes only redraw; even layout does not need to be redone.

Verification: counts after 10 upward scrolls on both versions:

![Step 3 vs Step 4 counts after 10 scrolls]

SearchScreen drops from 53 to 1 execution (the one when the FAB appears), ScrollFab drops from 510 to 1 (the initial composition). Scroll dropped-frame rate drops from 17.9% to 11.1%.

This example only demonstrates “read timing”; it is not a recommended interaction. firstVisibleItemScrollOffset resets to zero every time one item scrolls by, so the FAB would periodically “jump back”, visually sawtooth-like. The official docs’ analogous example uses ScrollState.value, a monotonically increasing scroll distance. In a real project, fading the FAB with graphicsLayer { alpha = ... } is a more common way to make it respond to scroll.

Quantitative Comparison: Real Device Measurements

Measurement method: Xiaomi Mi 6 (Android 9, 60Hz, 16.7ms frame budget), Release build (R8 on), full AOT compile before measurement with adb shell cmd package compile -m speed -f to remove JIT warm-up interference. Frame data comes from adb shell dumpsys gfxinfo. Each Step runs 1 warm-up round (discarded), then averages 5 rounds. Typing and scrolling are injected by adb. The scripts and raw data are in compose-recomposition-demo/ and can be fully reproduced.

Recomposition counts

Scenario Metric Step0 Step1 Step2 Step3 Step4
Single key press SearchScreen 1 0 0 0 0
Key press, unchanged result set (“ph”→“pho”) ProductItem 6 6 0 0 0
Key press, changed result set (“”→”p”) ProductItem 6 6 3 2 2
Key press, changed result set (“p”→”ph”) ProductItem 6 6 5 3 3
Scroll 20 times (10 up, 10 down) SearchScreen 98 105 106 104 2
Scroll 20 times ScrollFab 799 982 987 981 1
Scroll 20 times ProductItem 785 106 107 105 107

Step 0’s SearchScreen and ScrollFab counts are lower than Step 1 because Step 0 is so laggy that the same swipe gesture has a shorter inertia, scrolls fewer items, and renders fewer frames.

Frame data

Scenario Metric Step0 Step1 Step2 Step3 Step4
Scrolling dropped-frame rate 34.1% 20.4% 20.0% 17.9% 11.1%
Scrolling P90 frame time 40.4ms 19.4ms 18.6ms 18.2ms 16.6ms
Scrolling P99 frame time 61.0ms 24.8ms 25.6ms 24.4ms 23.0ms
Typing (40 key presses) ProductItem total executions 204 209 64 40 40
Typing dropped-frame rate 53.6% 51.2% 56.5% 53.8% 55.0%
Typing P90 frame time 57.0ms 56.0ms 43.6ms 41.2ms 40.0ms
Typing P99 frame time 77.0ms 79.4ms 52.8ms 53.6ms 54.4ms

[scroll dropped-frame rate chart] [scroll P90 chart] [typing P90 chart]

Three things can be read from the data:

  1. The two big jumps in the scrolling scenario come from Step 1 and Step 4. Step 1 removed the chain reaction where every scrolled item re-mapped and all visible items recomposed; Step 4 removed the FAB recomposing every frame. Steps 2 and 3 target the typing scenario; the differences between them in scrolling data are normal fluctuation (dropped-frame rate between rounds of the same Step can differ by about 8 percentage points).
  2. The long tail of the typing scenario gets visibly shorter: after Step 2, P90 drops from about 57ms to 40ms, and P99 drops from about 77ms to 53ms.
  3. But the typing dropped-frame rate barely moves, staying at 50%-56%. In other words, on the Mi 6, even when every ProductItem is skipped, the frame produced by each key press still exceeds the 16.7ms budget. The remaining cost is outside this article’s recomposition scope and needs a System Trace (Perfetto) to break down further. Reducing invalid recomposition can cut the long tail, but it does not guarantee eliminating dropped frames. This is why every optimization step has to be validated with real data, not just recomposition counts.

4. Summary

XML Era vs Compose Era: Full Comparison of Optimization Techniques

Dimension XML era (previous article) Compose era (this article)
Performance enemy Overdraw + too-deep hierarchy Unnecessary Recomposition
Diagnosis tool Debug GPU Overdraw (color scale) Layout Inspector recomposition/skip counts
Static analysis Layout Inspector hierarchy tree Compose Compiler Report
Root cause GPU draws pixels it should not draw CPU executes functions it should not execute
Direction flatten hierarchy / remove repeated backgrounds shrink recomposition scope / guarantee skipability
Core means ConstraintLayout state sinking / stable types / derivedStateOf / deferred reads
Effect verification Profile GPU Rendering dumpsys gfxinfo / Macrobenchmark (Release builds)

Cheat Sheet: 5 Compose Rendering Optimization Principles

✅ 1. Wherever you read state is the recomposition epicenter
       sink state reads to the smallest scope that truly needs them (extract a standalone function; inline Column/Row/Box do not count)
       computations in the epicenter function re-execute with the epicenter

✅ 2. Use derivedStateOf to rate-limit frequently changing state
       you care about the “result” (like index > 0), not the “process” (the index itself)
       do not use it when output changes as often as input

✅ 3. Unstable types are compared by instance; instance churn makes them unskippable
       · do not repeatedly create new objects for unstable types in Composition (move map into ViewModel or remember)
       · make types stable when possible: List -> ImmutableList
       · use the stability configuration file for third-party types you cannot change; use @Immutable / @Stable for your own classes
       · check Compiler Report for unstable parameter markers; do not only look at skippable

✅ 4. LazyColumn must have a key
       key = { item.id }, a business id, not an index

✅ 5. Defer high-frequency state reads to the Layout / Draw phase
       lambda forms of Modifier.offset { } / drawBehind { } / graphicsLayer { }
       -> state changes only trigger layout or drawing, not recomposition

Methodology

XML-era optimization optimized the “canvas”: make the GPU draw fewer layers of pixels it should not draw. Compose-era optimization optimizes “decisions”: make the runtime execute fewer functions it should not execute.

The mindset is the same, reducing invalid work, but the executor changes from GPU to CPU and the tool changes from a color-scale image to recomposition counts. With that in mind, when a Compose performance problem appears, do not first ask “is there Overdraw here?” Ask: “Is this composable being forced to run when it should have been skipped? What are the conditions for it to be skipped?”

Finally, close with frame data, not recomposition counts. The typing scenario in this article is a counterexample: recomposition counts fell by 80%, while the dropped-frame rate did not move. Counts tell you whether you did useless work; frame data tells you whether the user can feel it.

Next Article Preview

Rendering optimization handles smoothness after content appears. But before content appears, the user waits through cold start.

Next article will break down Android cold start end to end: from tapping the icon to the first frame, what the system and the app each do, how to quantify each stage, and where to apply the optimization knife.

Appendix: Verification Project

The companion project is open source: compose-recomposition-demo. The five packages step0 through step4 correspond to the five versions in the article. Two differences from the article code: each measured composable starts with a SideEffect counter, and ProductItem’s layout is extracted into a shared function ProductRow for reuse across the five versions.

File Purpose
RecompositionCounter.kt execution counter; can be cleared or dumped to logcat via broadcasts com.demo.RESET / com.demo.DUMP
measure_typing.sh per-key counting (“p” → “h” → “o”)
measure_frames.sh count and gfxinfo frame statistics for scroll/typing scenarios (includes AOT compile and warm-up round)
analyze.py 5-round averages, chart generation
compiler_reports/ original Compose Compiler reports cited in the article, plus comparison versions after adding the stability config file
results_*.txt all raw measurement data

Known limitations: the counter panel only counts executions, not skips (Layout Inspector has the Recompositions/Skipped Recompositions two-column counts); adb input occasionally emits protocol fault and drops keys; in the typing scenario, Step 4’s fifth round was invalidated and re-measured, with the original record kept in the data files.