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)