Frame budget: 4.17ms — one-quarter of the standard 60fps budget (16.67ms). Every unnecessary recomposition, allocation, layout pass, or draw call is a dropped frame.
Table of Contents
- The Rendering Pipeline at 240Hz
- Requesting 240Hz from the System
- The Three Phases and Phase Deferral
- Recomposition: The #1 Enemy
- Stability System Deep Dive
- Strong Skipping Mode
- State Management for 240fps
- Layout Phase Optimization
- Draw Phase Optimization
- Modifier System Performance
- Animation at 240fps
- Scroll and Lazy List Performance
- Text Rendering
- Image Loading
- Memory and Allocation
- Overdraw and Layer Management
- Baseline Profiles and R8
- Profiling and Benchmarking
- Master Checklist
1. The Rendering Pipeline at 240Hz
Frame Budget Comparison
| Refresh Rate | Frame Budget | Devices |
|---|---|---|
| 60Hz | 16.67ms | Standard |
| 90Hz | 11.11ms | Mid-range |
| 120Hz | 8.33ms | Flagships (Pixel, Samsung, Nothing) |
| 144Hz | 6.94ms | Gaming phones |
| 240Hz | 4.17ms | Ultra-high refresh / gaming / XR |
The Pipeline Per Frame
VSync Signal
-> Choreographer callback
-> Input handling (~0.2ms)
-> Animation updates (~0.3ms)
-> Traversal: Composition -> Layout -> Draw (~2ms target)
-> Sync RenderNodes to RenderThread (~0.2ms)
-> RenderThread: GPU command execution (~1.5ms)
-> SurfaceFlinger: buffer submission
-> Display
Budget split at 240Hz:
- Main Thread (composition + layout + draw recording): ~2ms
- Sync to RenderThread: ~0.2ms
- RenderThread (GPU commands): ~1.5ms
- Buffer/overhead: ~0.47ms
Choreographer and VSync
Android’s Choreographer synchronizes all UI work with display VSync signals. Compose’s MonotonicFrameClock on Android wraps Choreographer — it automatically adapts to whatever refresh rate the display operates at. No Compose-specific code changes needed to support higher refresh rates.
RenderThread
The RenderThread is a dedicated thread that takes RenderNode display lists from the main thread and issues GPU commands. When using graphicsLayer{}, transform updates (translation, rotation, scale, alpha) happen on the RenderThread without touching the main thread — the cached display list is reused with only the transform matrix updated.
HWUI Pipeline
Android’s HWUI (Hardware UI) pipeline manages:
RenderNodetree mirroring the View/Compose hierarchy- Display list recording (draw commands captured, not executed)
- GPU texture management and upload
- Hardware layer caching
Each graphicsLayer in Compose creates a RenderNode. RenderNodes save CPU time, not GPU time — the GPU does identical work, but the CPU avoids regenerating drawing commands by reusing cached blocks.
2. Requesting 240Hz from the System
Surface.setFrameRate() (API 30+)
// Request 240Hz from the surface
surfaceView.holder.surface.setFrameRate(
240f,
Surface.FRAME_RATE_COMPATIBILITY_DEFAULT
)
Window Display Mode
val window = (context as Activity).window
val display = window.windowManager.defaultDisplay
val modes = display.supportedModes
// Find the highest refresh rate mode
val bestMode = modes.maxByOrNull { it.refreshRate }
window.attributes = window.attributes.apply {
preferredDisplayModeId = bestMode?.modeId ?: 0
}
Adaptive Refresh Rate (Android 15+, LTPO panels)
Android 15 introduced ARR which decouples VSync rate from display refresh rate:
- VSync can run at 240Hz while the display refreshes at any divisor
- The system intelligently adjusts refresh rate based on content cadence
frameIntervalNshint tells the compositor about expected frame cadence
240Hz Reality Check
- Hardware: Some gaming phones (ASUS ROG, Nubia RedMagic) support 165-240Hz touch sampling, but actual 240Hz display panels are rare. Most flagships max at 120Hz with LTPO (1-120Hz variable).
- AOSP: Shows 240Hz VSync period examples (4.16ms) with actual frame presentation at divisor rates.
- Challenge: At 4.17ms, any composition work during animation is likely to cause frame drops. Draw-phase-only animations via
graphicsLayer{}become mandatory, not optional.
3. The Three Phases and Phase Deferral
Phase Cost Hierarchy
| Phase | Purpose | What It Triggers | Cost |
|---|---|---|---|
| Composition | Build/update UI tree | Layout + Drawing | Highest |
| Layout | Measure + place | Drawing | Medium |
| Drawing | Render pixels | Nothing further | Lowest |
Critical insight: Reading state in an earlier phase triggers ALL subsequent phases.
Where to Read State
| Where State is Read | Phases Re-run | Best For |
|---|---|---|
@Composable body / non-lambda modifier | Composition -> Layout -> Drawing | Content that changes structure |
Modifier.offset { } lambda | Layout -> Drawing | Position-only changes |
Modifier.graphicsLayer { } lambda | Drawing only | Visual transforms |
Modifier.drawBehind { } / Canvas { } | Drawing only | Color/visual-only changes |
Phase Deferral Examples
// WORST: Composition phase read — triggers ALL three phases
var color by remember { mutableStateOf(Color.Red) }
Box(Modifier.background(color)) // 240 recompositions/sec at 240fps
// MIDDLE: Layout phase read — triggers layout + draw
var offsetX by remember { mutableStateOf(8.dp) }
Text(modifier = Modifier.offset {
IntOffset(offsetX.roundToPx(), 0) // Skips composition
})
// BEST: Draw phase read — triggers ONLY draw
var color by remember { mutableStateOf(Color.Red) }
Canvas(modifier = modifier) {
drawRect(color) // Skips composition AND layout
}
Lambda State Providers Pattern
// BAD: reads scroll value in parent's composition scope
@Composable
fun SnackDetail() {
val scroll = rememberScrollState(0)
Title(snack, scroll.value) // Composition-phase read every scroll pixel
}
// GOOD: defers read to child via lambda
@Composable
fun SnackDetail() {
val scroll = rememberScrollState(0)
Title(snack) { scroll.value } // Lambda — read deferred
}
@Composable
private fun Title(snack: Snack, scrollProvider: () -> Int) {
Column(
modifier = Modifier.graphicsLayer {
translationY = -scrollProvider().toFloat() / 2 // Read in DRAW phase
}
) {
Text(snack.name)
}
}
Golden Rule
Read state as late as possible. If it’s for positioning, read it in the layout phase. If it’s for visual appearance (alpha, rotation, color, custom drawing), read it in the draw phase. Lambda modifiers are the mechanism to achieve this.
4. Recomposition: The #1 Enemy
At 240fps, a single unnecessary recomposition costs you ~24% of your frame budget. The most impactful optimizations are those that eliminate recompositions entirely.
Value vs Lambda Modifiers
// BAD: Every frame of animation triggers Composition -> Layout -> Drawing
val angle by animateFloatAsState(targetValue = targetAngle)
Icon(modifier = Modifier.rotate(angle)) // Value overload
// GOOD: Only draw phase runs (zero recomposition)
val angle by animateFloatAsState(targetValue = targetAngle)
Icon(modifier = Modifier.graphicsLayer { rotationZ = angle }) // Lambda overload
Side Effects: Key Selection Is Critical
// BAD: unstable key restarts effect every recomposition
LaunchedEffect(viewModel::loadData) { viewModel.loadData() }
// GOOD: stable key — effect only restarts when userId changes
LaunchedEffect(userId) { viewModel.loadData(userId) }
// GOOD: Unit key — runs once, never restarts
LaunchedEffect(Unit) { viewModel.initialize() }
rememberUpdatedState: Avoid Unnecessary Restarts
@Composable
fun SplashScreen(onTimeout: () -> Unit) {
val currentOnTimeout by rememberUpdatedState(onTimeout)
LaunchedEffect(Unit) {
delay(3000)
currentOnTimeout() // Always calls latest lambda without restarting delay
}
}
DisposableEffect: Avoid Rapidly Changing Keys
// BAD: key changes every frame during scroll
DisposableEffect(scrollPosition) { // setup/teardown 240x/sec
val listener = addScrollListener()
onDispose { listener.remove() }
}
// GOOD: stable key
DisposableEffect(listState) {
val listener = addScrollListener(listState)
onDispose { listener.remove() }
}
Avoid Backwards Writes
// CATASTROPHIC: infinite recomposition loop
@Composable
fun BadComposable() {
var count by remember { mutableIntStateOf(0) }
Text("$count")
count++ // Writing state AFTER reading it
}
Keep State Granular
// BAD: single large state object — changing any field recomposes everything
@Composable
fun Screen(state: ScreenState) {
// Title, items, loading indicator ALL recompose even if only isLoading changed
}
// GOOD: separate state per concern
@Composable
fun Screen(viewModel: ScreenViewModel) {
val title by viewModel.title.collectAsStateWithLifecycle()
val items by viewModel.items.collectAsStateWithLifecycle()
val isLoading by viewModel.isLoading.collectAsStateWithLifecycle()
TitleBar(title) // Only recomposes when title changes
ItemsList(items) // Only recomposes when items change
LoadingOverlay(isLoading) // Only recomposes when loading changes
}
movableContentOf: Preserve State Across Layout Changes
@Composable
fun AdaptiveLayout(isVertical: Boolean) {
val content = remember {
movableContentOf {
CheckboxItem("Option A")
CheckboxItem("Option B")
}
}
if (isVertical) Column { content() }
else Row { content() }
// Checkboxes MOVE without recomposition. Checked state preserved.
}
5. Stability System Deep Dive
How the Compiler Determines Stability
Automatically stable:
- All primitives (
Int,Long,Float,Boolean,Char, etc.) String- Function types (lambdas) — with caveats under strong skipping
- Enum classes, sealed classes
- Data classes where all public properties are
valof stable types
Automatically unstable:
- Any class with
varproperties - Standard Kotlin collections (
List,Set,Map) — interfaces whose impl may be mutable - Classes from external modules not compiled with Compose compiler
@Immutable vs @Stable
// @Immutable: once constructed, nothing changes. Strongest guarantee.
@Immutable
data class Snack(
val id: Long,
val name: String,
val tags: ImmutableSet<String> = persistentSetOf()
)
// @Stable: properties may change, but Compose is notified via snapshot state.
@Stable
class UiState {
var isLoading by mutableStateOf(false)
var items by mutableStateOf<List<Item>>(emptyList())
}
Decision Framework: @Immutable vs @Stable
Use @Immutable when:
- The class is genuinely immutable (all
val, no mutable containers) - The class comes from a module not compiled with the Compose compiler
- You need the compiler to treat it as stable for skipping (
equals()comparison)
Use @Stable when:
- The class wraps
mutableStateOfproperties - New instances with identical data arrive frequently (Room queries, API responses, mapped DTOs) — you need
equals()comparison, not reference equality - Data sources allocate a new object per item even when nothing changed
Drop both annotations when the ViewModel holds and passes the same reference, or for enums and sealed classes — the compiler already infers these.
The Collections Problem
Standard Kotlin collections are unstable because List<T> is an interface — val list: List<String> = mutableListOf() is legal.
// build.gradle.kts
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.7")
}
// Before: UNSTABLE — composable never skips
fun HighlightedSnacks(snacks: List<Snack>)
// After: STABLE — composable can skip
fun HighlightedSnacks(snacks: ImmutableList<Snack>)
Alternative: stable wrapper class when you can’t migrate the collection type:
@Immutable
data class SnackCollection(val snacks: List<Snack>)
// STABLE via @Immutable wrapper — composable can skip
fun HighlightedSnacks(snacks: SnackCollection)
Multi-Module Stability
Classes from modules not compiled with Compose compiler are always unstable.
Fix 1: Lightweight annotation dependency
// In your data-layer module (no full Compose runtime needed)
dependencies {
compileOnly("androidx.compose.runtime:runtime-annotation:1.9.0")
}
Fix 2: Stability configuration file for classes you can’t modify:
// stability_config.conf
java.time.LocalDateTime
java.time.ZonedDateTime
com.datalayer.*
com.datalayer.**
composeCompiler {
stabilityConfigurationFile = rootProject.layout.projectDirectory.file("stability_config.conf")
}
Compiler Reports
// build.gradle.kts (per module)
composeCompiler {
reportsDestination = layout.buildDirectory.dir("compose_compiler")
metricsDestination = layout.buildDirectory.dir("compose_compiler")
}
./gradlew assembleRelease
# Reports in: <module>/build/compose_compiler/
Key files:
| File | Content |
|---|---|
module.json | Aggregate stats — target skippable/restartable ratio near 1.0 |
composables.txt | Per-function stability — look for restartable without skippable |
classes.txt | Per-class stability — one unstable field makes entire class unstable |
Reading composables.txt:
restartable skippable scheme("[...]") fun SnackCard( <-- GOOD
restartable scheme("[...]") fun HighlightedSnacks( <-- BAD (not skippable)
unstable snacks: List<Snack> <-- root cause
Reading module.json — the big picture:
{
"skippableComposables": 64,
"restartableComposables": 76,
"knownStableArguments": 890,
"knownUnstableArguments": 30,
"unknownStableArguments": 1
}
Target skippableComposables / restartableComposables approaching 1.0 — with strong skipping it should be nearly 100%. Watch knownUnstableArguments: each one is a parameter forced into reference-equality (===) comparison.
Default parameter markers in composables.txt:
@static— constant default, no state read (good)@dynamic— default reads observable state (CompositionLocal,remember) — expected for theme values likeMaterialTheme.colorScheme.primary, but investigate unexpected occurrences
classes.txt — one unstable field poisons the class:
unstable class Snack {
stable val id: Long
stable val name: String
unstable val tags: Set<String> <-- root cause
<runtime stability> = Unstable
}
A single unstable field makes the entire class unstable. Here Set<String> (an interface) is the culprit — swap for ImmutableSet.
For classes you cannot modify (third-party, Java stdlib), list them in the stability configuration file. This is a contract with the compiler — listing a class that IS mutable causes missed recompositions.
6. Strong Skipping Mode
Enabled by default since Kotlin 2.0.20. Two fundamental changes:
- All restartable composables become skippable regardless of parameter stability
- All lambdas inside composable functions are automatically memoized
Parameter Comparison Rules
| Parameter Type | Old Behavior | Strong Skipping |
|---|---|---|
| Stable params | Skip via equals() | Skip via equals() (unchanged) |
| Unstable params | Always recompose | Skip if same instance (===) |
| Lambdas (unstable captures) | Always recompose | Auto-memoized with remember |
What Lambda Auto-Memoization Generates
The compiler rewrites every lambda inside a composable, keying the remember on the lambda’s captures:
// What you write:
val lambda = { use(unstableObject); use(stableObject) }
// What the compiler generates:
val lambda = remember(unstableObject, stableObject) {
{ use(unstableObject); use(stableObject) }
}
This is why a lambda capturing a per-frame-changing value still produces a fresh instance every frame — the remember key changes. Capture stable identifiers (an id, not the whole object) to maximize memoization hits.
Critical Gotcha: Mutable Collections NOT Fixed
Strong skipping compares unstable params by reference (===). Same MutableList reference + mutated contents = composable will NOT recompose:
// BUG: same reference, different content
list.add("Bar") // Won't trigger recomposition
// FIX: new reference
list = list.toMutableList().apply { add("Bar") }
Critical Gotcha: LazyListScope Lambdas NOT Auto-Memoized
LazyListScope is NOT a composable scope — lambdas inside aren’t auto-memoized:
// PROBLEM: onAction lambda NOT auto-memoized
LazyColumn {
items(items) { item ->
ItemCard(onAction = { viewModel.handleAction(item.id) })
}
}
// FIX: manually remember
LazyColumn {
items(items, key = { it.id }) { item ->
val onAction = remember(item.id) { { viewModel.handleAction(item.id) } }
ItemCard(onAction = onAction)
}
}
Opting Out
@NonSkippableComposable // Prevent skipping
@Composable fun AlwaysRecompose() { }
val lambda = @DontMemoize { } // Prevent memoization
When @Stable Still Matters
Keep @Stable when new instances with the same data arrive frequently (Room queries, API responses, mapped DTOs) — you need equals() comparison, not reference equality.
7. State Management for 240fps
derivedStateOf: Reduce Recomposition Frequency
Only triggers recomposition when its computed result changes — not when inputs change.
// BAD: recomposes on every scroll pixel
val showButton = listState.firstVisibleItemIndex > 0
// GOOD: recomposes only when boolean flips
val showButton by remember {
derivedStateOf { listState.firstVisibleItemIndex > 0 }
}
When NOT to use: When the derived value changes at the same rate as the input:
// WRONG: no benefit, adds overhead
val fullName by remember { derivedStateOf { "$firstName $lastName" } }
// CORRECT: just use remember
val fullName = remember(firstName, lastName) { "$firstName $lastName" }
Always wrap with remember:
// BAD: creates new derivedStateOf every recomposition
val filtered by derivedStateOf { items.filter { it.isActive } }
// GOOD: single instance
val filtered by remember { derivedStateOf { items.filter { it.isActive } } }
Advanced — structuralEqualityPolicy for derived values that produce equal-content but distinct-reference results, so recomposition fires only on structural change:
val expensiveResult by remember {
derivedStateOf(structuralEqualityPolicy()) {
computeExpensiveList(input) // Triggers only if list CONTENT changes
}
}
snapshotFlow: Bridge to Flow Without Recomposition
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex }
.distinctUntilChanged()
.debounce(300)
.collect { index -> analyticsTracker.trackScrollPosition(index) }
}
derivedStateOf vs snapshotFlow
| Aspect | derivedStateOf | snapshotFlow |
|---|---|---|
| Creates | State<T> | Flow<T> |
| Purpose | UI recomposition optimization | Side effects, ViewModel communication |
| Used in | Composable body | LaunchedEffect / coroutines |
| Flow operators | Not available | Full Flow API (debounce, filter, etc.) |
| Triggers recomposition | Yes (when result changes) | No |
remember: Cache Expensive Computations
val sortedContacts = remember(contacts, comparator) {
contacts.sortedWith(comparator) // Computed once until inputs change
}
val paint = remember { Paint().apply { isAntiAlias = true } }
8. Layout Phase Optimization
Single-Pass Layout Enforcement
Compose enforces single-pass layout. Each node measures children once, passing constraints down and sizes back up.
Intrinsic Measurements Are Expensive
Intrinsic measurements (IntrinsicSize.Min, IntrinsicSize.Max) trigger additional layout passes:
// EXPENSIVE: extra pass
Row(modifier = Modifier.height(IntrinsicSize.Min)) { ... }
// CHEAPER: explicit size
Row(modifier = Modifier.height(48.dp)) { ... }
SubcomposeLayout: Avoid in Hot Paths
SubcomposeLayout runs synchronous composition during measurement — the most expensive phase inside the already-expensive layout phase.
Measured impact (real chat UI, HackerNoon June 2025):
| Metric | SubcomposeLayout | Standard Layout | Improvement |
|---|---|---|---|
| P50 Frame Duration | 6.3ms | 5.9ms | 6.7% |
| P90 Frame Duration | 11.0ms | 10.5ms | 4.7% |
| P95 Frame Duration | 12.9ms | 12.3ms | 4.8% |
| P99 Frame Duration | 16.2ms | 15.0ms | 8.0% |
| P99 Frame Overrun | 1.4ms | 0.2ms | 85.7% |
Also:
- No intrinsic measurement support — throws if intrinsics are requested
- Slot reuse isn’t free — still requires reactivation and state reconciliation
- Blocks pausable composition (can’t benefit from Compose 1.9+ optimization)
When to Use Each Layout Approach
| Approach | When |
|---|---|
Standard Layout | Children are known at composition time |
| Intrinsic Measurements | Need child sizes but not conditional composition |
SubcomposeLayout | Composition truly depends on measurement constraints (lazy lists, adaptive layouts) |
BoxWithConstraints | Sparingly — never inside list items or frequently recomposed composables |
Replace with standard Layout + Ref pattern:
val textLayoutRef = remember { Ref<TextLayoutResult>() }
Layout(
content = {
Text(text = message.text, onTextLayout = { textLayoutRef.value = it })
MessageFooter(message)
}
) { measurables, constraints ->
val textPlaceable = measurables[0].measure(constraints.copy(maxWidth = maxWidthPx))
val footerPlaceable = measurables[1].measure(constraints)
// Single-pass positioning
}
Flatten Deep Hierarchies
// DEEP: Multiple layout passes through tree
Column { Row { Box { Text("A") }; Box { Text("B") } } }
// FLAT: Single layout pass
Layout(content = content) { measurables, constraints ->
val placeables = measurables.map { it.measure(constraints) }
layout(constraints.maxWidth, constraints.maxHeight) {
placeables[0].placeRelative(0, 0)
placeables[1].placeRelative(placeables[0].width, 0)
}
}
Custom Layout vs Box/Column/Row Tradeoffs
| Composable | Overhead | Notes |
|---|---|---|
| Column/Row | Minimal | Straightforward single-pass measure strategy |
| Box | Slightly more | Stacking/overlapping, still very efficient |
Custom Layout | Can be lower for complex UIs | Flattens deep hierarchies into a single Layout call |
| ConstraintLayout | Higher | Constraint resolution cost, but reduces nesting |
Compose’s single-pass model means deep nesting is far cheaper than in the View system — but at 4.17ms, flattening hot composables into a custom Layout still removes measurable per-frame overhead.
Separate Measurement from Placement
When only position changes (not size), defer state reads to placement:
Layout(content = { content() }) { measurables, constraints ->
val placeables = measurables.map { it.measure(constraints) }
layout(constraints.maxWidth, constraints.maxHeight) {
placeables.forEachIndexed { i, placeable ->
placeable.placeRelative(0, i * itemHeight + scrollOffset()) // Only placement affected
}
}
}
9. Draw Phase Optimization
drawWithCache: Cache Objects Between Frames
At 240fps, eliminating per-frame allocations is critical. drawWithCache caches Path, Brush, Shader objects until size or read state changes:
Modifier.drawWithCache {
// Created ONCE and cached
val path = Path().apply {
moveTo(0f, 0f); lineTo(size.width, 0f); lineTo(size.width, size.height); close()
}
val brush = Brush.verticalGradient(listOf(Color.Red, Color.Blue))
onDrawBehind {
// Only transforms change per frame — path and brush reused
translate(left = 0f, top = animationValue.value) {
drawPath(path, brush)
}
}
}
drawWithContent: Drawing Order Control
drawWithContent lets you interleave custom drawing before or after the composable’s own content — useful for overlays and spotlight effects without an extra layer:
Modifier.drawWithContent {
drawContent() // composable content first
drawRect( // overlay drawn on top
Brush.radialGradient(
listOf(Color.Transparent, Color.Black),
center = pointerOffset,
radius = 100.dp.toPx(),
)
)
}
Compositing Strategies
// AUTO (default): offscreen buffer if alpha < 1.0 or RenderEffect set
Modifier.graphicsLayer { alpha = 0.5f }
// OFFSCREEN: always rasterize to texture. Required for BlendMode operations
Modifier.graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen }
// MODULATE_ALPHA: MOST EFFICIENT — no offscreen buffer, per-draw-instruction alpha
// Only correct for non-overlapping content
Modifier.graphicsLayer {
compositingStrategy = CompositingStrategy.ModulateAlpha
alpha = 0.75f
}
Offscreen Layer Trade-offs
Offscreen layers render RenderNode commands to a GPU texture; subsequent frames only issue “draw this texture,” which is very fast. But:
- They cost RAM and bandwidth
- If the layer changes frequently, you do the work twice — regenerate the texture and copy it to screen
Use offscreen for stable, expensive-to-recompose layouts; avoid it for rapidly changing content.
Capture a Composable to Bitmap
rememberGraphicsLayer records the draw pass once and exports it without re-running composition:
val graphicsLayer = rememberGraphicsLayer()
Box(
modifier = Modifier
.drawWithContent {
graphicsLayer.record { this@drawWithContent.drawContent() }
drawLayer(graphicsLayer)
}
.clickable {
coroutineScope.launch {
val bitmap = graphicsLayer.toImageBitmap()
}
}
)
graphicsLayer Properties (All Draw-Phase Only)
Modifier.graphicsLayer {
translationX = animatedX
translationY = animatedY
rotationZ = animatedAngle
scaleX = animatedScale
scaleY = animatedScale
alpha = animatedAlpha
shadowElevation = animatedElevation
clip = true
shape = RoundedCornerShape(animatedCornerRadius)
}
10. Modifier System Performance
Three Custom Modifier APIs
| API | Performance | Why |
|---|---|---|
ModifierNodeElement + Modifier.Node | Best | Persistent node, no composition overhead |
Modifier.Element (legacy) | Middle | Simple interface |
Modifier.composed {} | Worst | Creates mini-composition scope per instance, GC pressure |
The clickable modifier migrated from composed to Modifier.Node, reporting ~80% performance improvement. Modifier.composed is slow because it creates a mini-composition scope per instance, its lambda can’t be cached, its equality comparison is broken (so it can never skip), and it generates short-lived garbage.
CombinedModifier Internals
Modifier.then() builds a recursive CombinedModifier (a cons list), not a flat list. Each Modifier.Element is a single behavior (layout, drawing, gesture). Elements added first are applied first — which is why ordering changes both correctness and cost.
Modifier.Node Architecture
// 1. Factory
fun Modifier.circle(color: Color) = this then CircleElement(color)
// 2. Element — ephemeral config, compared via equals
private data class CircleElement(val color: Color) : ModifierNodeElement<CircleNode>() {
override fun create() = CircleNode(color)
override fun update(node: CircleNode) { node.color = color }
}
// 3. Node — persistent, survives recomposition
private class CircleNode(var color: Color) : DrawModifierNode, Modifier.Node() {
override fun ContentDrawScope.draw() { drawCircle(color) }
}
Combined Node with Manual Invalidation
class SampleNode(var color: Color, var size: IntSize) :
DelegatingNode(), LayoutModifierNode, DrawModifierNode {
override val shouldAutoInvalidate: Boolean get() = false
fun update(color: Color, size: IntSize) {
if (this.color != color) { this.color = color; invalidateDraw() } // Only draw
if (this.size != size) { this.size = size; invalidateMeasurement() } // Only layout
}
}
Node Interface Selection
| Purpose | Interface | Use Case |
|---|---|---|
| Drawing | DrawModifierNode | Badges, overlays, decorations |
| Sizing/Layout | LayoutModifierNode | Aspect ratio, min touch size |
| Parent data | ParentDataModifierNode | Weights, alignment data |
| Accessibility | SemanticsModifierNode | Labels, merged controls |
| Gestures | PointerInputModifierNode | Swipe, drag detection |
| Coordinates | GlobalPositionAwareModifierNode | Anchors, tooltips |
| Composition locals | CompositionLocalConsumerModifierNode | RTL, density-aware logic |
Node Lifecycle and Coroutines
A node’s coroutineScope is tied to attachment — launch animations in onAttach, and they are automatically cancelled in onDetach:
override fun onAttach() {
coroutineScope.launch {
animatable.animateTo(1f, infiniteRepeatable(tween(1000)))
}
// Cancelled automatically on onDetach()
}
Modifier Chain Optimization
// BAD: new modifier chain allocated every recomposition
@Composable
fun AnimatedItem() {
Box(modifier = Modifier.padding(16.dp).fillMaxWidth().height(48.dp).background(Color.White))
}
// GOOD: allocated once, reused
val itemModifier = Modifier.padding(16.dp).fillMaxWidth().height(48.dp).background(Color.White)
@Composable
fun AnimatedItem() {
Box(modifier = itemModifier)
}
Ordering matters: Layout (size/constraints) -> Appearance (background, clip) -> Interactions (clickable, pointer input).
11. Animation at 240fps
API Performance Ranking
| API | Recomp Cost | Best For |
|---|---|---|
Animatable + graphicsLayer{} | None (draw-only) | Gesture-driven, physics-based |
animate*AsState + graphicsLayer{} | None (draw-only) | Simple state transitions |
InfiniteTransition + graphicsLayer{} | None (draw-only) | Continuous effects |
| Any API without lambda modifier | Every frame | Avoid at 240fps |
The Mandatory Pattern
// This animation pattern is 240fps-safe:
val animatable = remember { Animatable(0f) }
LaunchedEffect(Unit) {
animatable.animateTo(1f, animationSpec = tween(500))
}
Box(modifier = Modifier.graphicsLayer {
alpha = animatable.value // Draw phase ONLY
scaleX = animatable.value
scaleY = animatable.value
})
animate*AsState Internals
animate*AsState is the simplest API — it creates and remembers an Animatable at the call site internally. It is optimized for single-value, state-driven animations with minimal overhead, but it cannot be cancelled at runtime until removed from the tree, and it triggers recomposition each frame unless its read is deferred via graphicsLayer{}.
Animatable: The Low-Level Control Surface
Animatable is the core coroutine-based API. It has the lowest overhead when used directly and exposes the levers animate*AsState hides:
val offsetX = remember { Animatable(0f) }
offsetX.snapTo(touchX) // zero-latency sync with touch
offsetX.animateTo( // spring physics
targetValue = 0f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioLowBouncy,
stiffness = Spring.StiffnessLow
)
)
offsetX.animateDecay( // fling, platform-matched physics
initialVelocity = flingVelocity,
animationSpec = splineBasedDecay(density)
)
offsetX.updateBounds(lowerBound = 0f, upperBound = maxOffset)
snapToprovides zero-latency state synchronization with touch eventsanimateDecayuses spline-based decay matching platform fling physicsupdateBoundsclamps the animation range- Spring animations handle interruptions smoothly with velocity continuity guaranteed — an in-flight animation retargeted mid-flight carries its current velocity into the new spring, so gesture handoff never visibly stutters
Text Animation
Text(
text = "Animated",
style = TextStyle(textMotion = TextMotion.Animated), // Avoids layout recalculation
modifier = Modifier.graphicsLayer {
scaleX = animatedScale; scaleY = animatedScale
translationY = animatedOffset
}
)
Custom Physics Loop with withFrameNanos
LaunchedEffect(Unit) {
var prev = withFrameNanos { it }
while (isActive) {
withFrameNanos { now ->
val dt = (now - prev) / 1_000_000_000f
prev = now
// Frame-rate-independent physics
velY += gravity * dt
ballY += velY * dt
if (ballY > boundary) { ballY = boundary; velY = -velY * bounce }
}
}
}
Canvas(Modifier.fillMaxSize()) {
drawCircle(Color.Red, radius = 20f, center = Offset(ballX, ballY))
}
withFrameNanos suspends until the next frame and delegates to the MonotonicFrameClock in the coroutine context (AndroidUiFrameClock wraps Choreographer). Key properties:
- Frame time values are strictly monotonically increasing
- Values may be normalized to the target frame time, not necessarily wall-clock “now”
- Throws
IllegalStateExceptionif noMonotonicFrameClockis in theCoroutineContext - In UI tests, continuations resumed inside the callback dispatch only after all frame callbacks complete
TargetBasedAnimation: Manual Playback
When you need to drive an animation’s clock yourself (custom sequencing, scrubbing), TargetBasedAnimation exposes value-from-nanos directly:
val anim = remember {
TargetBasedAnimation(
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMedium
),
typeConverter = Float.VectorConverter,
initialValue = 0f,
targetValue = 300f
)
}
var animValue by remember { mutableFloatStateOf(0f) }
LaunchedEffect(anim) {
val startTime = withFrameNanos { it }
do {
val playTime = withFrameNanos { it } - startTime
animValue = anim.getValueFromNanos(playTime)
} while (!anim.isFinishedFromNanos(playTime))
}
Box(Modifier.graphicsLayer { translationY = animValue })
Frame Rate Control: Skip Frames for Non-Critical Animations
// Animate every 4th frame (60fps at 240Hz) to save power
class SkippingFrameClock(
private val delegate: MonotonicFrameClock,
private val frameSkip: Int = 3
) : MonotonicFrameClock {
override suspend fun <R> withFrameNanos(onFrame: (Long) -> R): R {
repeat(frameSkip) { delegate.withFrameNanos { } }
return delegate.withFrameNanos(onFrame)
}
}
LookaheadScope (Stable since Compose 1.8)
Pre-calculates target layout in a lookahead pass, then animates toward it:
LookaheadScope {
Box(
modifier = Modifier
.animateBounds(animationSpec = spring(
dampingRatio = Spring.DampingRatioLowBouncy,
stiffness = Spring.StiffnessMediumLow
))
.then(if (expanded) Modifier.fillMaxWidth().height(300.dp)
else Modifier.width(100.dp).height(100.dp))
)
}
At 240fps, the extra lookahead pass must fit within 4.17ms.
Two-pass system:
- Lookahead pass — all layouts determine their target/destination measurements and positions
- Approach pass — layouts run measurement/placement approach logic to gradually reach the destination
SubcomposeLayout-based components (TabRow, Scaffold, BoxWithConstraints) now work correctly under lookahead. Combined with pausable composition (1.9+), lookahead measurements can be paused and resumed across frames.
approachLayout for custom interpolation — drive the constraints yourself between current and lookahead size:
Modifier.approachLayout(
isMeasurementApproachInProgress = { lookaheadSize ->
currentAnimatedSize != lookaheadSize // true while animating
}
) { measurable, _ ->
val animatedConstraints = Constraints.fixed(
animatedWidth.roundToInt(),
animatedHeight.roundToInt()
)
val placeable = measurable.measure(animatedConstraints)
layout(placeable.width, placeable.height) { placeable.place(0, 0) }
}
SharedTransitionScope
| Resize Mode | Performance | Use Case |
|---|---|---|
ScaleToBounds | Faster — no relayout | Text, fixed-aspect content |
RemeasureToBounds | Slower — re-measures every frame | Avoid at 240fps |
SharedTransitionScope is built on top of LookaheadScope. At 240fps, RemeasureToBounds can trigger up to 240 measurements/sec — unlikely to fit in 4.17ms for complex content — so ScaleToBounds is strongly preferred.
Overlay rendering: shared elements render into a SharedTransitionScope overlay layer during transitions, drawn on top of all other content. Use renderInSharedTransitionScopeOverlay() for elements like bottom bars that must stay on top.
Constraints to plan around:
- No View/Compose interop support — no Dialog, no ModalBottomSheet
ContentScaleis not animated (snaps to the end value)- Clean up shared elements after the transition by observing
SharedTransitionScope.isTransitionActive - Keep shared-element content simple — avoid complex composable trees inside the shared element
12. Scroll and Lazy List Performance
Mandatory Optimizations
LazyColumn(
state = rememberLazyListState(),
) {
items(
items = items,
key = { it.id }, // 1. Stable keys — CRITICAL
contentType = { it.type }, // 2. ContentType for recycling
) { item ->
ItemCard(item)
}
}
Compose keeps a limited composition cache per contentType. When a recycled slot’s type matches the incoming item, the composition slot is reused (ViewHolder-style) instead of fully recomposed — critical for heterogeneous lists where each row type has different structure:
contentType = { message ->
when (message) {
is Message.TextMessage -> "text"
is Message.ImageMessage -> "image"
is Message.VideoMessage -> "video"
}
}
LazyLayoutCacheWindow (Compose 1.9+)
val cacheWindow = LazyLayoutCacheWindow(ahead = 150.dp, behind = 100.dp)
val state = rememberLazyListState(cacheWindow = cacheWindow)
At 240fps, the viewport shifts significantly per frame during fast flings. Larger cache windows ensure items are pre-composed before entering the viewport.
Nested Prefetch
LazyColumn {
items(sections, key = { it.id }) { section ->
val rowState = rememberLazyListState(
prefetchStrategy = LazyListPrefetchStrategy(nestedPrefetchItemCount = 6)
)
LazyRow(state = rowState) { ... }
}
}
Pausable Composition (Compose 1.9+, Default December 2025)
The runtime monitors frame time budgets and pauses composition work when time runs out, resuming in the next frame. Combined with CacheWindow APIs, scroll jank dropped to 0.2% in internal benchmarks — matching View system performance.
Nested Scrolling Architecture
Compose is nested-scroll-by-default: every scrollable participates in the chain via NestedScrollConnection (parent) and NestedScrollDispatcher (child). The four hooks let a parent intercept scroll and fling around its child:
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val consumed = consumeHeaderCollapse(available.y) // before child
return Offset(0f, consumed)
}
override fun onPostScroll(
consumed: Offset, available: Offset, source: NestedScrollSource
): Offset = Offset.Zero // leftover after child
override suspend fun onPreFling(available: Velocity): Velocity =
Velocity.Zero // before child fling
override suspend fun onPostFling(
consumed: Velocity, available: Velocity
): Velocity {
val decay = splineBasedDecay<Float>(density)
AnimationState(0f, available.y).animateDecay(decay) {
childScrollState.dispatchRawDelta(value)
}
return available
}
}
}
Box(Modifier.nestedScroll(nestedScrollConnection)) { LazyColumn { /* ... */ } }
dispatchRawDelta vs scrollBy: use dispatchRawDelta when scrolling from inside a NestedScrollConnection so the offset is not re-dispatched through the chain (which would infinite-loop).
SnapFlingBehavior and Pager Fling
HorizontalPager(
state = pagerState,
flingBehavior = PagerDefaults.flingBehavior(
state = pagerState,
pagerSnapDistance = PagerSnapDistance.atMost(3),
lowVelocityAnimationSpec = tween(durationMillis = 500),
highVelocityAnimationSpec = spring(stiffness = Spring.StiffnessMediumLow),
snapAnimationSpec = spring(stiffness = Spring.StiffnessMedium)
)
)
Custom Fling Behavior
class CustomFlingBehavior(
private val decaySpec: DecayAnimationSpec<Float>,
private val velocityMultiplier: Float = 1.0f,
) : FlingBehavior {
override suspend fun ScrollScope.performFling(initialVelocity: Float): Float {
val velocity = initialVelocity * velocityMultiplier
var lastValue = 0f
AnimationState(0f, velocity).animateDecay(decaySpec) {
val delta = value - lastValue
lastValue = value
val consumed = scrollBy(delta)
if (abs(delta - consumed) > 0.5f) cancelAnimation()
}
return 0f
}
}
Velocity-Tracking Fling Behavior
Expose the live fling velocity so composables can drive draw-phase effects (e.g. motion blur, speed-based scaling) off it:
class VelocityTrackingFlingBehavior(
private val decaySpec: DecayAnimationSpec<Float>,
) : FlingBehavior {
var currentVelocity by mutableFloatStateOf(0f)
private set
override suspend fun ScrollScope.performFling(initialVelocity: Float): Float {
var lastValue = 0f
currentVelocity = initialVelocity
AnimationState(0f, initialVelocity).animateDecay(decaySpec) {
val delta = value - lastValue
lastValue = value
val consumed = scrollBy(delta)
currentVelocity = velocity
if (abs(delta - consumed) > 0.5f) cancelAnimation()
}
currentVelocity = 0f
return 0f
}
}
13. Text Rendering
TextMeasurer with LRU Cache
val textMeasurer = rememberTextMeasurer(cacheSize = 20)
val measured = textMeasurer.measure(
text = AnnotatedString("Measure Once"),
style = TextStyle(fontSize = 16.sp),
constraints = Constraints(maxWidth = maxWidthPx)
)
Canvas(Modifier.fillMaxSize()) {
drawText(measured, topLeft = Offset(10f, 50f)) // Bypasses Compose layout
}
TextMeasurerholds an internal LRU cache- Parameters like
color,brush,shadoware ignored during layout — enabling cache hits when only non-layout attributes change - Even a slight change in
fontSize,maxLines, or one character creates a distinct cache entry
Background Text Prefetch (Google I/O 2025)
Text layout caches can now be pre-warmed on a background thread, eliminating jank from cold text measurement during scrolling.
AnnotatedString for Styled Content (Single Pass)
Building mixed styles into one AnnotatedString lays out in a single pass — far cheaper than stacking multiple Text composables for inline style changes:
val styledText = buildAnnotatedString {
append("Normal ")
withStyle(SpanStyle(fontWeight = FontWeight.Bold, color = Color.Red)) {
append("Bold & Red")
}
}
Text(styledText)
Preloaded FontFamily
val customFont = FontFamily(
Font(R.font.roboto_regular, FontWeight.Normal),
Font(R.font.roboto_bold, FontWeight.Bold)
)
// Use consistently to avoid re-loading
14. Image Loading
API Comparison
| Composable | Subcomposition | Performance |
|---|---|---|
AsyncImage | No | Best |
rememberAsyncImagePainter | No | Good |
SubcomposeAsyncImage | Yes | Worst |
Never use SubcomposeAsyncImage inside LazyColumn — subcomposition during measurement.
Coil 3 Performance
v3.4.0: runtime 25-40% faster, allocations reduced 35-48%.
Painter Stability
Painter is explicitly NOT marked @Stable. Passing it as a parameter causes unnecessary recompositions:
// BAD: Painter is unstable
fun Avatar(painter: Painter) { Image(painter = painter, ...) }
// GOOD: Pass URL
fun Avatar(url: String) { AsyncImage(model = url, ...) }
Bitmap.prepareToDraw()
Uploads texture to GPU before first draw. Most image libraries do this automatically; Coil had a bug where this wasn’t called (fixed in recent versions).
Coil Best Practices
- Scale down images — load at display size, not original resolution;
AsyncImageauto-downsamples to the optimal load size - Use WEBP — smaller than JPEG/PNG
- Lazy layouts free memory for off-screen images
- Prefer vectors over bitmaps where they don’t pixelate
- Share a single
ImageLoaderinstance across the app
15. Memory and Allocation
At 240fps, GC pauses are frame-killers. A minor GC pause of 2-3ms consumes over half your frame budget.
Avoid Allocations in Hot Paths
Composition phase:
remember {}caches objects across recompositions — use it for any object creation- Lambda allocations inside composables are handled by strong skipping (auto-memoized)
- But LazyListScope lambdas are NOT auto-memoized — manually
rememberthem
Layout phase:
- Don’t create
Constraints,IntOffset,IntSizeobjects in lambda modifiers unnecessarily - Reuse measurement results
Draw phase:
- Use
drawWithCacheto cachePath,Brush,Shader,Paintobjects - Don’t allocate
Offset,Size,Colorin hot draw loops — usedrawCircle(color, radius, center)with primitives where possible
Avoid Autoboxing
// BAD: Int gets boxed to Integer in generic State<T>
var count by remember { mutableStateOf(0) } // State<Int> -> boxing
// GOOD: primitive-aware state holder
var count by remember { mutableIntStateOf(0) } // No boxing
var progress by remember { mutableFloatStateOf(0f) }
var enabled by remember { mutableStateOf(false) } // Boolean is fine, small type
Value Classes for Wrapper Types
@JvmInline
value class UserId(val value: String)
@JvmInline
value class Pixels(val value: Float)
At runtime, these are erased to the underlying type — zero allocation overhead.
Lambda Capturing
// Captures `item` — allocates closure object
items.forEach { item ->
Child(onClick = { viewModel.onClick(item) }) // New lambda per item per recomposition
}
// With strong skipping in @Composable scope: auto-memoized
// In LazyListScope: manually remember
val onClick = remember(item.id) { { viewModel.onClick(item) } }
Offload Computation
@Composable
fun HeavyScreen(data: List<RawItem>) {
var result by remember { mutableStateOf<List<ProcessedItem>>(emptyList()) }
LaunchedEffect(data) {
result = withContext(Dispatchers.Default) {
data.map { processItem(it) } // Off main thread
}
}
LazyColumn { items(result, key = { it.id }) { ItemRow(it) } }
}
Dispatchers.Main.immediate vs Dispatchers.Main
Dispatchers.Main.immediatedispatches synchronously if already on main thread (avoids queue round-trip)Dispatchers.Mainalways posts to the message queue (adds latency)- Compose internally uses
Dispatchers.Main.immediatefor snapshotFlow and recomposition triggers
StrictMode for Detection
StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy.Builder()
.detectAll()
.penaltyLog()
.build()
)
// Watch logcat for disk reads, network calls on main thread
16. Overdraw and Layer Management
Debug GPU Overdraw
Enable: Settings -> Developer options -> Debug GPU overdraw
| Color | Overdraw | Severity |
|---|---|---|
| True color | 0x | OK |
| Blue | 1x | Acceptable |
| Green | 2x | Watch |
| Pink | 3x | Problematic |
| Red | 4x+ | Critical |
Reduce Overdraw
// MORE OVERDRAW: nested backgrounds
Box(Modifier.background(Color.White)) {
Column(Modifier.background(Color.White)) { Text("Content") } // Redundant!
}
// LESS OVERDRAW: single background
Column(Modifier.background(Color.White)) { Text("Content") }
// LESS OVERDRAW: opaque > transparent
Box(Modifier.background(Color(0xFF808080))) // Better than Color.Black.copy(alpha = 0.5f)
Layer Optimization
// Every graphicsLayer creates a RenderNode — has overhead
// Use ONLY when:
// - Content changes position/scale during animations
// - Complex layouts need isolation
// - BlendMode operations require offscreen compositing
// - Scrolling items benefit from RenderNode reuse
// DON'T: graphicsLayer on static content that never animates
// DO: graphicsLayer for animated transforms
Box(modifier = Modifier.graphicsLayer { translationY = scrollOffset.value })
zIndex Stacking Order
Modifier.zIndex controls draw order within a parent — lower draws first, higher draws on top. Use it to keep stacking intentional so obscured layers don’t add overdraw, instead of reordering composables:
Box {
Surface(modifier = Modifier.zIndex(0f)) { /* background */ }
Surface(modifier = Modifier.zIndex(1f)) { /* foreground */ }
}
Elevation/Shadow Cost
Shadows require additional drawing passes. Prefer padding/borders/background-color for visual hierarchy over elevation at 240fps.
17. Baseline Profiles and R8
Baseline Profiles
Compose is a library — not pre-compiled on device. Without profiles, JIT must compile hotspots on first use.
Impact: 30%+ overall performance improvement. The Google Play Store reported a 40% reduction in rendering time after adopting baseline profiles.
@RunWith(AndroidJUnit4::class)
class BaselineProfileGenerator {
@get:Rule val rule = BaselineProfileRule()
@Test
fun generateProfile() = rule.collect(
packageName = "com.example.app",
includeInStartupProfile = true,
) {
startActivityAndWait()
device.findObject(By.res("main_list")).apply {
repeat(3) { fling(Direction.DOWN); device.waitForIdle() }
}
device.findObject(By.res("item_0")).click()
device.waitForIdle()
}
}
Compose ships a default baseline profile. Layer your app-specific profile on top.
Startup Profiles
A startup profile is a subset of the baseline profile that optimizes DEX layout: classes used during startup are packed into the first classes.dex, reducing file loads. Mark startup-critical paths with includeInStartupProfile = true — exercising just startActivityAndWait() is enough to capture the startup profile.
R8 Configuration
android {
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), // NOT proguard-android.txt
"proguard-rules.pro"
)
}
}
}
Use proguard-android-optimize.txt — Disney+ reported 30% faster startup and 25% fewer ANRs after switching.
Ensure R8 full mode is on. It is the default; remove any opt-out left in gradle.properties:
# gradle.properties — delete this line if present
# android.enableR8.fullMode=false
Full-mode optimizations relevant to Compose: tree shaking of unreachable code paths, method inlining of small composables, class merging, constant propagation into animation specs, and enum unboxing. On AGP 9.0+, proguard-android.txt (with -dontoptimize) is deprecated and optimized resource shrinking is automatic with isShrinkResources = true.
DO NOT add broad keep rules:
# DON'T — kills optimization
-keep class androidx.compose.** { *; }
Compose libraries ship embedded R8 rules. Trust them.
18. Profiling and Benchmarking
Composition Tracing
dependencies {
implementation("androidx.compose.runtime:runtime-tracing:1.10.5")
}
// System traces now automatically include composable function names
Macrobenchmark
// benchmark/build.gradle.kts
plugins { id("com.android.test") }
dependencies {
implementation("androidx.benchmark:benchmark-macro-junit4:1.5.0")
implementation("androidx.test.ext:junit:1.2.1")
}
android {
targetProjectPath = ":app"
experimentalProperties["android.experimental.self-instrumenting"] = true
}
@Test
fun scrollList() = benchmarkRule.measureRepeated(
packageName = "com.example.app",
metrics = listOf(
FrameTimingMetric(),
TraceSectionMetric("LazyColumn.compose"), // custom trace section
),
compilationMode = CompilationMode.Partial(
baselineProfileMode = BaselineProfileMode.Require
),
iterations = 10,
startupMode = StartupMode.WARM,
) {
startActivityAndWait()
val list = device.findObject(By.res("item_list"))
list.setGestureMargin(device.displayWidth / 5)
repeat(5) { list.fling(Direction.DOWN); device.waitForIdle() }
}
Profile GPU Rendering Bar Colors
Developer Options → Profile GPU rendering → “On screen as bars.” Each vertical bar is one frame; for 240fps target bars under 4.17ms. Color segments (bottom to top):
| Color | Stage | Tall segment means |
|---|---|---|
| Blue | Measure/Draw — building DisplayLists | Many views invalidated or complex onDraw |
| Purple | Sync & Upload — RenderNodes to RenderThread, bitmap upload | Heavy texture uploads |
| Red | Execute/Issue commands | High DisplayList complexity |
| Orange | Process / GPU wait | Too much GPU work (overdraw) |
Perfetto Trace Capture
adb shell perfetto -c - --txt \
-o /data/misc/perfetto-traces/trace.perfetto-trace <<EOF
buffers: { size_kb: 63488, fill_policy: RING_BUFFER }
data_sources: {
config {
name: "linux.ftrace"
ftrace_config {
ftrace_events: "ftrace/print"
atrace_categories: "view"
atrace_categories: "dalvik"
atrace_categories: "graphics"
atrace_apps: "your.package.name"
}
}
}
duration_ms: 10000
EOF
Custom TraceMetric with SQL
TraceMetric runs SQL directly against the Perfetto trace to extract metrics the built-in metrics don’t cover:
class CustomScrollMetric : TraceMetric() {
override fun getResult(
captureInfo: CaptureInfo,
traceSession: PerfettoTraceProcessor.Session
): List<Measurement> {
val query = """
SELECT dur / 1000000.0 as dur_ms
FROM slice
WHERE name LIKE 'Choreographer#doFrame%'
"""
// Process query results into Measurement list
}
}
Key Metrics at 240fps
adb shell dumpsys gfxinfo <package>
# frameDurationCpuMs < 4.0 (target)
# frameOverrunMs < 0 (negative = had time left)
What to Look For in Perfetto
- Main thread slices > 2ms = risk of frame drops
- RenderThread draw > 2ms = complex display lists or heavy overdraw
- Choreographer#doFrame duration > 4ms = dropped frame
- Recomposition trace sections during animation frames = unnecessary work
Tools
| Tool | Purpose |
|---|---|
| Layout Inspector | Live recomposition counts per composable |
| Compose Stability Analyzer (IDE plugin) | Gutter icons for skippability |
| compose-report-to-html (Gradle plugin) | Navigable HTML reports |
| Perfetto | Per-composable timing, GPU analysis |
| Macrobenchmark | End-to-end frame timing, automated |
| Microbenchmark | Hot function cost measurement |
19. Master Checklist
Infrastructure (Do This First)
- Build and ship in Release mode with R8 (
proguard-android-optimize.txt) - Generate and ship Baseline Profiles covering critical user journeys
- Upgrade to Compose BOM 2025.12.00+ for pausable composition
- Request high refresh rate via
Surface.setFrameRate()orpreferredDisplayModeId - Enable Strong Skipping (default in Kotlin 2.0.20+)
Composition Phase (Target: <1ms)
- Defer all fast-changing state reads to latest possible phase (draw > layout > composition)
- Use lambda modifiers everywhere:
Modifier.offset { },Modifier.graphicsLayer { },Modifier.drawBehind { } - Use
@Immutable/@Stableon data classes - Use
ImmutableList/ImmutableSetfrom kotlinx-collections-immutable - Use
derivedStateOffor scroll-dependent / threshold-dependent UI - Offload computation to
Dispatchers.Default - Keep composables small and focused to narrow recomposition scopes
- Use
movableContentOfwhen composables move between layout positions - Avoid
SubcomposeLayoutandBoxWithConstraintsin hot paths
Layout Phase (Target: <1ms)
- Avoid intrinsic measurements in deep trees — use explicit sizes
- Use standard
LayoutoverSubcomposeLayoutwhere possible - Flatten layout hierarchies with custom
Layoutcomposables - Use lambda
offset {}to defer state reads to layout phase - Separate measurement from placement for scroll-dependent positioning
Draw Phase (Target: <1ms)
- Use
graphicsLayer {}lambda for ALL animated alpha, rotation, scale, translation - Use
drawWithCacheto cache Path, Brush, Shader objects - Use
CompositingStrategy.ModulateAlphafor non-overlapping alpha - Pre-compute text layouts with
TextMeasurer - Use
Canvas.drawText()to bypass Compose layout for static text - Set
TextMotion.Animatedfor text with animated transforms
Lazy Lists (Target: 0% jank)
- Always provide stable
keyparameters - Use
contentTypefor heterogeneous lists - Configure
LazyLayoutCacheWindowfor prefetch - Set
nestedPrefetchItemCountfor nested lazy lists - Use
AsyncImage(notSubcomposeAsyncImage) inside lists - Manually
rememberlambdas inLazyListScope(not auto-memoized)
Animation
- Use
Animatable+graphicsLayer{}for gesture-driven animations - Use
animate*AsState+graphicsLayer{}for state transitions - Use
withFrameNanosfor custom physics loops - Consider
SkippingFrameClockfor non-critical animations to save power - Use
ScaleToBounds(notRemeasureToBounds) in shared transitions
Memory
- Use
mutableIntStateOf,mutableFloatStateOfto avoid autoboxing - Use
rememberfor all object creation in composables - Use value classes for wrapper types
- Avoid allocations in draw lambdas — use
drawWithCache - Use
Dispatchers.Main.immediatewhere appropriate
Modifier System
- Migrate
Modifier.composedtoModifier.Node - Order modifiers: Layout -> Appearance -> Interactions
- Hoist static modifier chains outside recomposition scope
- Use
shouldAutoInvalidate = false+ manualinvalidateDraw()/invalidateMeasurement()in custom nodes
Overdraw & Layers
- Remove redundant backgrounds in nested composables
- Minimize transparency and alpha blending
- Use
clip = trueto restrict drawing boundaries - Profile with “Debug GPU Overdraw” developer option
Profiling (Continuous)
- Profile on physical device with profileable build
- Target
frameDurationCpuMs < 4.0,frameOverrunMs < 0 - Use Macrobenchmark
FrameTimingMetricfor automated regression detection - Use Perfetto traces to identify composables > 1ms
- Use Layout Inspector to spot unexpected recomposition counts
- Use Compose compiler reports to audit stability
Priority Order for Maximum Impact
graphicsLayer {}lambda for all animations — eliminates recomposition during animation (single biggest win)- Baseline Profiles + R8 full mode — 30%+ free performance
- Stable keys on all lazy lists — eliminates unnecessary recomposition of list items
- derivedStateOf for scroll-dependent UI — reduces recomposition frequency by orders of magnitude
- ImmutableList/ImmutableSet — makes composables skippable
- Pausable composition (Compose 1.9+) — splits work across frames automatically
- SubcomposeLayout removal — 8%+ P99 improvement
- Modifier.Node migration — 80% improvement per migrated modifier
- drawWithCache for draw-phase objects — eliminates per-frame allocation
- Background thread offloading — keeps main thread under 2ms