Saltar al contenido
Todas las entradas

Recomposición en Jetpack Compose: qué la dispara realmente y por qué dejé de adivinar

Una guía práctica sobre la recomposición en Jetpack Compose en 2026 — estabilidad de tipos, lambdas inestables, claves de LazyColumn y cómo leer el informe de métricas del compilador.

MFKAPPS 6 min de lectura

Una pantalla de Compose que se atasca casi nunca tiene una causa obvia. No hay stack trace, ni un crash, ni una advertencia del linter — solo una lista que se siente un poco más lenta de lo que debería, o un campo de texto que va medio frame por detrás de cada pulsación. El sospechoso habitual es la recomposición ejecutándose sobre más árbol de UI del necesario, y lo frustrante es que Compose nunca te avisa cuando esto pasa. Simplemente hace más trabajo del necesario, en silencio, en cada frame, hasta que vas a buscarlo. Esto es lo que realmente decide si un composable se salta o se vuelve a ejecutar, y las dos herramientas que sustituyen adivinar por leer.

Qué es realmente la recomposición

Compose rastrea lecturas de estado, no variables. Cuando una función composable lee un State<T> — directamente o a través de remember { mutableStateOf(...) } — el runtime registra esa lectura contra el scope de composición actual. Cuando el valor del estado cambia, solo los scopes que realmente lo leyeron se programan para volver a ejecutarse. Todo lo demás en el árbol se deja intacto, en teoría. La palabra que hace el trabajo en esa frase es “scopes que realmente lo leyeron”, y averiguar cuáles son esos scopes es donde las cosas salen mal.

La pregunta real del compilador: ¿este tipo es estable?

Cada parámetro de una función composable se clasifica por el compilador de Compose como estable o inestable. Un tipo estable es aquel que el compilador puede demostrar que no cambiará sin notificar a la composición — así que si el valor es igual (equals()) al de la última vez, Compose se salta por completo volver a ejecutar la función. No se puede confiar así en un tipo inestable, así que el composable que lo usa se vuelve a ejecutar en cada recomposición de su padre, haya cambiado o no algo que realmente lee.

// Estable: cada propiedad es val, y String/Int son estables por definición.
data class PantryItem(val id: Long, val name: String, val quantity: Int)

// Inestable: una propiedad var significa que el compilador no puede probar
// que esta instancia no mutará mientras un composable mantiene una referencia.
data class PantryItemDraft(var name: String, var quantity: Int)

La versión con var no es una manía de estilo — es la diferencia entre un composable que Compose puede saltarse y uno que no. El mismo problema aparece con parámetros simples de List<T> y Map<K, V>: el compilador trata la interfaz List como inestable, porque nada impide que quien llama le pase una MutableList y luego la mute. Una data class llena de campos val estables, envuelta en una List simple, sigue siendo un parámetro inestable — y es el punto único más común donde la estabilidad se rompe en silencio en una base de código real.

Dónde se cuela sin que nadie lo note

Dos patrones explican la mayoría de la recomposición innecesaria que he rastreado en mis propias pantallas:

Lambdas sin “hoisting”. Una lambda definida en línea dentro del cuerpo de un composable es un objeto nuevo en cada recomposición del padre, aunque los valores que captura no hayan cambiado. Pasada a un composable hijo, esa nueva instancia falla la comprobación de igualdad y fuerza al hijo a recomponerse, haya cambiado o no algo que realmente lee.

// Se recrea cada vez que PantryScreen recompone — anula el skip en ItemRow.
PantryList(items = items, onDelete = { id -> viewModel.delete(id) })

// Hoisted una sola vez, estable a través de las recomposiciones de PantryScreen.
val onDelete = remember(viewModel) { { id: Long -> viewModel.delete(id) } }
PantryList(items = items, onDelete = onDelete)

Colecciones pasadas directamente desde un repositorio. Un Flow<List<PantryItem>> recolectado con collectAsStateWithLifecycle() te da una instancia nueva de List en cada emisión, aunque el contenido sea idéntico — algo común justo después de que una consulta de Room se vuelve a ejecutar por una escritura en una tabla sin relación. Si esa lista se pasa a varios composables hijos, todos recomponen en cada emisión. Envolver el lado de lectura en derivedStateOf corrige esto cuando lo que realmente te importa es un valor calculado, no la lista cruda en sí:

val isEmpty by remember {
    derivedStateOf { items.isEmpty() }
}

derivedStateOf solo dispara recomposición cuando su salida calculada cambia, no cuando cambian sus entradas — así que una lista que pasa de 40 a 41 elementos no vuelve a disparar un composable al que solo le importa si la lista está vacía.

Listas: la clave que limita la recomposición fila a fila

LazyColumn usa por defecto la posición del elemento como clave de identidad. Reordena, inserta o elimina un elemento en cualquier parte que no sea el final, y cada fila después de ese índice se trata como “cambiada” — porque lo que Compose está rastreando es la posición, no el contenido. Proporcionar una clave explícita y estable corrige esto limitando la recomposición a las filas cuyo contenido realmente cambió:

LazyColumn {
    items(items = pantryItems, key = { it.id }) { item ->
        PantryItemRow(item)
    }
}

Este es exactamente el patrón detrás de la lista de despensa de Stocky, que rutinariamente contiene unos cientos de filas provenientes de escaneos de código de barras e importaciones de recibos — sin una clave estable, editar la cantidad de un artículo recompondría toda la lista visible en lugar de la única fila que cambió.

Leer el propio informe del compilador

Adivinar qué composables son inestables es innecesario — el compilador de Compose te lo puede decir directamente. Añadir un flag de métricas al build de Gradle produce un informe que lista cada función composable, si se puede saltar (“skippable”) y exactamente qué parámetro la hizo inestable:

// build.gradle.kts
composeCompiler {
    metricsDestination = layout.buildDirectory.dir("compose_metrics")
    reportsDestination = layout.buildDirectory.dir("compose_metrics")
}

El informe *-composables.txt generado nombra el parámetro inestable en cada función que marca, lo que convierte “por qué esta pantalla va lenta” de una sesión de profiling en un grep. Lo ejecuto antes de lanzar cualquier pantalla con una lista o un valor que se actualiza con frecuencia — es barato, es exacto, y detecta el error del var en una data class en más o menos el tiempo que toma abrir el archivo.

La conclusión

La recomposición no es un problema de rendimiento por naturaleza — Compose está diseñado para volver a ejecutar funciones de forma barata y frecuente. El problema es la recomposición sin acotar: una pantalla entera volviendo a ejecutarse porque un parámetro inestable, una lambda sin hoisting o una clave de lista faltante le dijeron al compilador que no podía probar que algo fuera seguro de saltarse. Arregla esas tres cosas — tipos estables con campos val, lambdas con hoisting y claves explícitas en cada elemento de LazyColumn — y la mayor parte de lo que parece un problema de rendimiento en Compose resulta que ya está resuelto.