Back to Blog

Performance & Profiling: Recomposition Optimization with Live Metrics | Kotlin Desktop #19

Sandy LaneSandy Lane

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.