Performance & Profiling: Recomposition Optimization with Live Metrics | Kotlin Desktop #19
Video: Performance & Profiling: Recomposition Optimization with Live Metrics | Kotlin Desktop #19 by Taught by Celeste AI - AI Coding Coach
Watch full page →Performance & Profiling: Recomposition Optimization with Live Metrics in Kotlin Desktop
In this tutorial, we explore how to optimize Jetpack Compose recompositions in a Kotlin Desktop app by building a Performance Lab with 200 product items, search, sorting, and a live ticker. We compare a Naive implementation containing common anti-patterns against an Optimized version using best practices, while tracking recomposition counts in real time with a MetricsPanel.
Code
import androidx.compose.runtime.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.*
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger
@Immutable
data class Product(val id: Int, val name: String, val price: Double)
@Composable
fun PerformanceLab(products: List<Product>) {
var searchQuery by remember { mutableStateOf("") }
var sortAscending by remember { mutableStateOf(true) }
var optimizedMode by remember { mutableStateOf(false) }
// Track recompositions per composable key
val recompositionCounts = remember { ConcurrentHashMap<String, AtomicInteger>() }
// Increment recomposition count for a given key
fun trackRecomposition(key: String) {
recompositionCounts.computeIfAbsent(key) { AtomicInteger(0) }.incrementAndGet()
}
// Filter and sort products with derivedStateOf to avoid unnecessary recomputations
val filteredSortedProducts by remember(searchQuery, sortAscending, products) {
derivedStateOf {
products.filter { it.name.contains(searchQuery, ignoreCase = true) }
.sortedBy { if (sortAscending) it.price else -it.price }
}
}
Column {
// Search input with remember for stable lambda
TextField(
value = searchQuery,
onValueChange = remember { { query: String -> searchQuery = query } },
label = { Text("Search") }
)
// Sort toggle button
Button(onClick = { sortAscending = !sortAscending }) {
Text(if (sortAscending) "Sort Ascending" else "Sort Descending")
}
// Toggle between Naive and Optimized implementations
SegmentedButtonRow(
options = listOf("Naive", "Optimized"),
selected = if (optimizedMode) 1 else 0,
onSelect = { optimizedMode = it == 1 }
)
// LazyColumn with key parameter for stable item identity
LazyColumn {
items(filteredSortedProducts, key = { it.id }) { product ->
if (optimizedMode) {
OptimizedProductItem(product, trackRecomposition)
} else {
NaiveProductItem(product, trackRecomposition)
}
}
}
// Display live recomposition metrics
MetricsPanel(recompositionCounts)
}
}
@Composable
fun NaiveProductItem(product: Product, trackRecomposition: (String) -> Unit) {
SideEffect { trackRecomposition("NaiveProductItem-${product.id}") }
// Anti-pattern: recomputing formatted price every recomposition
val priceText = "$${product.price}"
Text("${product.name}: $priceText")
}
@Composable
fun OptimizedProductItem(product: Product, trackRecomposition: (String) -> Unit) {
SideEffect { trackRecomposition("OptimizedProductItem-${product.id}") }
// Fix 1: remember formatted price string to avoid recomputing
val priceText = remember(product.price) { "$${product.price}" }
Text("${product.name}: $priceText")
}
@Composable
fun MetricsPanel(recompositionCounts: ConcurrentHashMap<String, AtomicInteger>) {
Column {
Text("Recomposition Counts", style = MaterialTheme.typography.h6)
recompositionCounts.forEach { (key, count) ->
val color = when {
count.get() < 10 -> MaterialTheme.colors.primary
count.get() < 50 -> MaterialTheme.colors.secondary
else -> MaterialTheme.colors.error
}
Text("$key: ${count.get()}", color = color)
}
}
}
@Composable
fun SegmentedButtonRow(options: List<String>, selected: Int, onSelect: (Int) -> Unit) {
Row {
options.forEachIndexed { index, option ->
Button(
onClick = { onSelect(index) },
colors = if (index == selected) ButtonDefaults.buttonColors() else ButtonDefaults.outlinedButtonColors()
) {
Text(option)
}
}
}
}
Key Points
- Use the @Immutable annotation on data classes to help Compose optimize recompositions by treating instances as stable.
- Wrap expensive or derived computations with derivedStateOf to recompute only when inputs change.
- Provide a stable key parameter in LazyColumn to enable identity-based diffing and avoid unnecessary recompositions.
- Use remember to cache lambda callbacks and computed values, preventing recreation on every recomposition.
- Track recompositions with SideEffect and thread-safe counters to visualize performance impact in real time.