A practical, production-ready guide to auditing Jetpack Compose performance — from checklists, automated tests for excessive recompositions, real screen analysis, to a clear comparison with View-based performance traps.
🎯 Why this article?
Jetpack Compose is declarative, but performance problems don’t disappear — they just change shape.
In real production apps, most Compose performance issues come from:
- Excessive recompositions
- Wrong state boundaries
- Unstable objects & lambdas
- Animations running in the wrong phase
This article gives you:
- ✅ A Compose performance audit checklist
- ✅ A real automated test to catch excessive recompositions
- ✅ A step-by-step analysis of a Compose screen
- ✅ A Compose vs View performance comparison with concrete examples & tests
1️⃣ Performance Audit Checklist for Jetpack Compose
Use this checklist whenever you review a Compose screen or PR.
🧠 A. Composition & Recomposition
✔ Are states read at the lowest possible composable?
❌ Anti-pattern:
@Composable
fun Screen(vm: VM) {
val state by vm.state.collectAsState()
List(state.items)
}
✔ Better:
items(state.items, key = { it.id }) {
Item(it)
}
✔ Checklist:
- No frequently-changing state read at screen root
- Use
derivedStateOffor computed values - Composables recompose only when their inputs change
🧠 B. Object Allocation
✔ Are objects created inside composition?
❌ Anti-pattern:
Brush.linearGradient(colors)
✔ Fix:
val brush = remember { Brush.linearGradient(colors) }
✔ Checklist:
-
Brush,Shape,TextStyle,Path,Outlineare remembered - Prefer
drawBehindfor purely visual effects
🧠 C. Stability
✔ Are your models stable?
@Immutable
data class UiItem(
val id: String,
val title: String
)
✔ Checklist:
-
@Immutable/@Stableused where applicable - Avoid
MutableList,MutableMapin UI models
🧠 D. Lazy Layouts
✔ Are keys provided?
items(items, key = { it.id }) { ... }
✔ Checklist:
- No index-based keys
- Item-level state isolated
🧠 E. Animations
✔ Where does the animation run?
| Animation type | Correct phase |
|---|---|
| Layout change | Composition |
| Visual only | Draw / graphicsLayer |
✔ Checklist:
- No
animate*AsStatedriving layout unintentionally - Use
graphicsLayerfor translations, alpha, scale
2️⃣ Demo: Automated Test to Catch Excessive Recompositions
🎯 Goal
Turn recomposition behavior into something testable & enforceable.
Step 1: Create a recomposition counter modifier
val RecomposeCountKey = SemanticsPropertyKey<Int>("RecomposeCount")
var SemanticsPropertyReceiver.recomposeCount by RecomposeCountKey
fun Modifier.trackRecompositions(): Modifier = composed {
var count by remember { mutableStateOf(0) }
SideEffect { count++ }
semantics {
recomposeCount = count
}
}
Step 2: Use it in your composable
@Composable
fun ProfileCard(user: User) {
Box(
modifier = Modifier.trackRecompositions()
) {
Text(user.name)
}
}
Step 3: Write a Compose UI test
@Test
fun profileCard_shouldNotRecomposeExcessively() {
composeRule.setContent {
ProfileCard(User("1", "Vio"))
}
composeRule.waitForIdle()
val count = composeRule
.onNode(hasAnyAncestor(isRoot()))
.fetchSemanticsNode()
.config[RecomposeCountKey]
assertThat(count).isLessThan(3)
}
✔ This test fails automatically if someone introduces excessive recompositions later.
3️⃣ Real-world Analysis: A Compose Screen
📱 Example: Recording List Screen
@Composable
fun RecordingScreen(vm: RecordingVM) {
val state by vm.state.collectAsState()
LazyColumn {
items(state.records) {
RecordingItem(it)
}
}
}
❌ Problems
- Entire list recomposes on any state change
- No item keys
- RecordingItem recreated unnecessarily
✅ Refactored Version
@Composable
fun RecordingScreen(vm: RecordingVM) {
val records by vm.records.collectAsState()
LazyColumn {
items(
items = records,
key = { it.id }
) {
RecordingItem(it)
}
}
}
🎯 Result
- Only changed items recompose
- Scroll remains smooth
- Predictable performance
4️⃣ Compose vs View Performance Traps
🔴 Trap #1: "Compose is slower than Views"
❌ Wrong comparison:
- Unoptimized Compose vs optimized RecyclerView
✔ Fair comparison:
- Stable models
- Keys
- Proper state scoping
🔴 Trap #2: Frequent invalidation
View system
view.invalidate() // redraw only
Compose equivalent
Modifier.drawBehind { }
❌ Bad Compose equivalent:
mutableStateOf(x++) // triggers recomposition
🔴 Trap #3: Measuring everything again
| System | Cost |
|---|---|
| View |
requestLayout() expensive |
| Compose | Layout skipped if inputs stable |
✔ Compose wins if stability is respected.
5️⃣ How to Test Performance (Compose & View)
🧪 Tools
- Layout Inspector (Recomposition Count)
- Recomposition Highlighter
- Macrobenchmark
📊 Macrobenchmark example
measureRepeated(
packageName = "com.vio.app",
metrics = listOf(FrameTimingMetric()),
iterations = 5
) {
startActivityAndWait()
}
🧠 Final Mental Model
Compose performance = state boundaries + stability + correct phase (composition vs draw)
If you control those three, Compose is:
- Faster than Views
- More predictable
- Easier to test
✅ TL;DR Checklist
- 🔲 No unnecessary recompositions
- 🔲 Stable models & lambdas
- 🔲 Keys everywhere in Lazy layouts
- 🔲 Animations in draw phase when possible
- 🔲 Performance covered by tests
If you want:
- 🔍 A performance audit template for your app
- 🧪 A ready-to-use testing module
- 🧠 A deep dive into Compose internals (slot table, skipping, groups)
👉 Let me know — happy to go deeper 🚀
Top comments (0)