LazyColumn con keys — el impacto real
La diferencia entre una lista con y sin key no es solo performance de recomposición — también afecta la correctitud. Sin key, cuando la lista se reordena o se insertan items al principio, Compose reutiliza los composables en el orden de la pantalla, no en el orden de los datos. El estado local de cada item (expansión, selección, animaciones) se mezcla con el item incorrecto.
// ❌ Sin key — Compose asigna identidades por posición
LazyColumn {
items(productos) { producto ->
ProductoCard(producto) // el item en posición 0 siempre es "el primero"
}
}
// ✓ Con key — Compose rastreva cada item por su identidad real
LazyColumn {
items(
items = productos,
key = { it.id } // identificador único, estable, no cambia con el orden
) { producto ->
ProductoCard(producto)
}
}
// El key también permite animaciones de inserción/eliminación automáticas:
LazyColumn {
items(
items = productos,
key = { it.id }
) { producto ->
ProductoCard(
producto = producto,
modifier = Modifier.animateItem() // animación automática al insertar/eliminar
)
}
}
contentType — reusar ViewHolders entre tipos
Si tu lista tiene items de diferentes tipos (headers, cards, banners), contentType le dice a Compose qué composables pueden reutilizarse entre sí. Sin esto, Compose puede intentar reusar un header como una card con resultados incorrectos:
sealed class ItemLista {
data class Header(val titulo: String) : ItemLista()
data class Producto(val producto: Producto) : ItemLista()
data class Banner(val url: String) : ItemLista()
}
LazyColumn {
items(
items = listaConVariostipos,
key = { item ->
when (item) {
is ItemLista.Header -> "header-${item.titulo}"
is ItemLista.Producto -> "producto-${item.producto.id}"
is ItemLista.Banner -> "banner-${item.url}"
}
},
contentType = { item ->
// Compose solo reutiliza composables del mismo contentType
when (item) {
is ItemLista.Header -> "header"
is ItemLista.Producto -> "producto"
is ItemLista.Banner -> "banner"
}
}
) { item ->
when (item) {
is ItemLista.Header -> HeaderItem(item.titulo)
is ItemLista.Producto -> ProductoCard(item.producto)
is ItemLista.Banner -> BannerItem(item.url)
}
}
}
Evitar allocations en recomposición
Cada vez que un composable se recompone, el código dentro de su cuerpo corre de nuevo. Crear objetos dentro de la composición sin remember genera allocations en cada recomposición — presión al garbage collector, posible jank.
// ❌ Allocation en cada recomposición
@Composable
fun ProductoCard(producto: Producto) {
val formatoMoneda = NumberFormat.getCurrencyInstance(Locale("es", "AR")) // nuevo objeto cada vez
val precioFormateado = formatoMoneda.format(producto.precio)
Text(precioFormateado)
}
// ✓ Cachear objetos costosos con remember
@Composable
fun ProductoCard(producto: Producto) {
val formatoMoneda = remember { NumberFormat.getCurrencyInstance(Locale("es", "AR")) }
val precioFormateado = remember(producto.precio) { formatoMoneda.format(producto.precio) }
Text(precioFormateado)
}
// ❌ Lambda nueva en cada recomposición (menos crítico pero vale la pena)
@Composable
fun Lista(items: List<Item>, viewModel: ViewModel) {
LazyColumn {
items(items) { item ->
Card(onClick = { viewModel.seleccionar(item) }) // lambda nueva por item por recomposición
}
}
}
// ✓ Pasar el callback estable desde fuera
@Composable
fun Lista(
items: List<Item>,
onSeleccionar: (Item) -> Unit // lambda estable que viene del caller
) {
LazyColumn {
items(items, key = { it.id }) { item ->
Card(onClick = { onSeleccionar(item) })
}
}
}
Baseline Profiles para Compose
Compose tiene mucho bytecode — si no pre-compilás los métodos críticos, el startup y la primera interacción son lentos porque ART los compila JIT. Los Baseline Profiles solucionan esto:
// El generador de Baseline Profile incluye las pantallas de Compose
@OptIn(ExperimentalBaselineProfilesApi::class)
class ComposeBaselineProfileGenerator {
@get:Rule
val rule = BaselineProfileRule()
@Test
fun generate() = rule.collect(packageName = "ar.pensa.miapp") {
pressHome()
startActivityAndWait()
// Navegar por las pantallas principales
device.findObject(By.text("Productos")).click()
device.waitForIdle()
// Scrollear la lista principal (Compose necesita esto para compilar LazyColumn)
val lista = device.findObject(By.res("ar.pensa.miapp:id/lazy_column"))
lista?.fling(Direction.DOWN)
device.waitForIdle()
// Navegar al detalle
device.findObject(By.text("Mouse Logitech"))?.click()
device.waitForIdle()
}
}
// Con Jetpack Macrobenchmark también podés medir el impacto:
@Test
fun startupConProfile() = benchmarkRule.measureRepeated(
packageName = "ar.pensa.miapp",
metrics = listOf(StartupTimingMetric(), FrameTimingMetric()),
compilationMode = CompilationMode.Partial( // con Baseline Profile
baselineProfileMode = BaselineProfileMode.Require
),
startupMode = StartupMode.COLD,
iterations = 5
) {
pressHome()
startActivityAndWait()
}
El impacto de Baseline Profiles en apps con Compose es mayorCompose genera mucho más bytecode que el View System para las mismas pantallas. Esto significa que el JIT tiene más trabajo en el primer arranque. Los Baseline Profiles pueden reducir el tiempo de primera renderización de Compose en un 20-40%.
Profiling con Layout Inspector de recomposición
# Android Studio → View → Tool Windows → Layout Inspector
# Con la app en debug, activar "Show recomposition counts" en la barra superior
# Números en verde: cuántas veces se recompuso un composable
# Números en azul: cuántas veces Compose se salteó la recomposición (skipped)
# Flujo de diagnóstico:
# 1. Realizar una acción simple (ej: escribir un caracter en un TextField)
# 2. Ver qué composables tienen números altos de recomposición
# 3. Para cada composable con muchas recomposiciones:
# a. ¿Recibe tipos inestables (List, classes con var)?
# b. ¿Recibe lambdas que se crean en la recomposición del padre?
# c. ¿Lee state que cambia con más frecuencia que el composable necesita?
# 4. Aplicar: ImmutableList, derivedStateOf, remember, @Stable/@Immutable
# Verificar estabilidad con el Compose Compiler Report:
# build.gradle:
# kotlinOptions {
# freeCompilerArgs += listOf(
# "-P", "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=/tmp/compose-reports",
# "-P", "plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=/tmp/compose-reports"
# )
# }
# ./gradlew assembleRelease
# Los archivos en /tmp/compose-reports muestran qué tipos son stable/unstable
Checklist de performance en Compose
# ── ESTABILIDAD ──────────────────────────────────────────────
# ✓ Las data classes que van como parámetros son estables (campos val, tipos estables)
# ✓ Las listas usan ImmutableList en lugar de List<T>
# ✓ Las clases con comportamiento complejo tienen @Stable o @Immutable si corresponde
# ── remember ─────────────────────────────────────────────────
# ✓ Objetos costosos están en remember (formatters, DateTimeFormatter, etc.)
# ✓ Los cálculos derivados de estado tienen clave correcta en remember(clave)
# ✓ derivedStateOf se usa cuando el resultado cambia menos que el estado origen
# ── LAZY LISTS ───────────────────────────────────────────────
# ✓ Todos los LazyColumn/LazyRow tienen key = { item.id }
# ✓ Listas con múltiples tipos tienen contentType configurado
# ✓ Los items usan Modifier.animateItem() si necesitás animación de inserción
# ── LAMBDAS ──────────────────────────────────────────────────
# ✓ Las lambdas de click no capturan estado que cambia frecuentemente
# ✓ Los callbacks vienen de fuera (del ViewModel) en lugar de crearse en la composición
# ── CANVAS ───────────────────────────────────────────────────
# ✓ Los objetos Paint, Path y Stroke están en remember (son costosos de crear)
# ✓ Las animaciones de Canvas usan animateFloatAsState o Animatable según el caso
# ── PROFILING ────────────────────────────────────────────────
# ✓ Baseline Profile generado y commiteado en el repo
# ✓ Layout Inspector usado para identificar composables con recomposición excesiva
# ✓ Compose Compiler Report revisado para tipos inestables críticos