Cómo decide Compose qué recomponer
Compose recompone un composable cuando alguno de sus parámetros cambia. La pregunta clave es: ¿cómo determina Compose si un parámetro cambió? La respuesta es la igualdad estructural (equals()). Si equals() retorna true, Compose asume que no hubo cambio y se saltea la recomposición.
Data classes de Kotlin implementan equals() automáticamente por valor. Classes normales usan identidad de objeto por default. Esta diferencia es la raíz de la mayoría de los problemas de performance en Compose.
// ✓ Data class — Compose puede detectar si cambió por valor
data class Usuario(val nombre: String, val edad: Int)
@Composable
fun TarjetaUsuario(usuario: Usuario) { /* ... */ }
// Si el ViewModel emite Usuario("Carlos", 30) dos veces iguales,
// Compose no recompone TarjetaUsuario
// ✗ Class normal — Compose siempre ve una instancia diferente
class UsuarioWrapper(val usuario: Usuario)
@Composable
fun TarjetaUsuarioWrapper(wrapper: UsuarioWrapper) { /* ... */ }
// Cada vez que el ViewModel emite un nuevo UsuarioWrapper (aunque con los mismos datos),
// Compose lo recompone porque la identidad de objeto es diferente
Estabilidad — el concepto central
Compose clasifica cada tipo como estable o inestable. Los tipos estables garantizan que equals() refleja correctamente si el valor cambió. Compose puede saltear la recomposición de composables que solo reciben tipos estables cuando sus parámetros son iguales.
// Tipos estables automáticamente:
// - Primitivos (Int, Boolean, Float, etc.)
// - String
// - Todas las data classes cuyos campos son también estables
// - Lambdas (Compose las trata como estables)
// - @Immutable y @Stable (anotaciones manuales)
// Tipos INESTABLES — causan recomposición aunque el valor sea el mismo:
// - List, Map, Set (las implementaciones mutables de Kotlin)
// - Clases con var campos
// - Clases no data con equals() de identidad
// Solución para listas: usar ImmutableList de kotlinx-collections-immutable
// o anotar la clase contenedora como @Stable/@Immutable
// build.gradle:
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.7")
// Uso:
@Composable
fun ListaProductos(productos: ImmutableList<Producto>) {
// Compose sabe que ImmutableList es estable
// Si la referencia no cambió, no recompone
}
List<T> hace que todo se recomponga siempreSi pasás un List<T> a un composable, Compose lo marca como inestable y recompone aunque el contenido sea idéntico. Convertir a ImmutableList o a persistentListOf() de kotlinx-collections-immutable resuelve esto en la mayoría de los casos.
remember — qué cachear y qué no
// remember sin clave — persiste mientras el composable está en la composición
val estado = remember { mutableStateOf(0) }
// remember con clave — se recalcula cuando la clave cambia
val formatoFecha = remember(locale) {
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
}
// Si locale no cambia entre recomposiciones, no recrea el DateTimeFormatter
// ❌ MAL — crear objetos costosos en la composición sin remember
@Composable
fun MiComposable(datos: List<Dato>) {
val procesados = datos.sortedBy { it.fecha } // se ejecuta en CADA recomposición
// ...
}
// ✓ BIEN — cachear el resultado con remember
@Composable
fun MiComposable(datos: List<Dato>) {
val procesados = remember(datos) {
datos.sortedBy { it.fecha } // solo se recalcula cuando datos cambia
}
// ...
}
// ❌ MAL — remember sin clave cuando debería tenerla
@Composable
fun Greeting(nombre: String) {
val mensaje = remember { "Hola, $nombre!" }
// Si nombre cambia, mensaje NO se actualiza — sigue con el valor inicial
}
// ✓ BIEN — clave correcta
@Composable
fun Greeting(nombre: String) {
val mensaje = remember(nombre) { "Hola, $nombre!" }
}
derivedStateOf — cuando el estado deriva de otro estado
derivedStateOf es para cuando tenés un estado que se calcula a partir de otro estado observable. La diferencia con remember(clave): derivedStateOf solo dispara la recomposición cuando el resultado cambia, no cuando cambia el estado origen.
// Caso clásico: mostrar un botón "Ir arriba" cuando el scroll supera un umbral
@Composable
fun ListaConBotonArriba() {
val listState = rememberLazyListState()
// ❌ MAL — con remember(listState.firstVisibleItemIndex)
// Se recompone en CADA pixel de scroll porque el índice cambia constantemente
val mostrarBoton = remember(listState.firstVisibleItemIndex) {
listState.firstVisibleItemIndex > 0
}
// ✓ BIEN — con derivedStateOf
// Solo se recompone cuando el RESULTADO cambia (de false a true o viceversa)
val mostrarBoton by remember {
derivedStateOf { listState.firstVisibleItemIndex > 0 }
}
Box {
LazyColumn(state = listState) { /* items */ }
if (mostrarBoton) {
BotonIrArriba()
}
}
}
// Otro caso: validación de formulario
@Composable
fun Formulario() {
var email by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
// Solo recompone el botón cuando cambia el resultado de la validación,
// no en cada keystroke
val formularioValido by remember {
derivedStateOf {
email.contains("@") && password.length >= 8
}
}
Button(enabled = formularioValido, onClick = { }) { Text("Enviar") }
}
Lambdas y recomposición innecesaria
// ❌ MAL — lambda nueva en cada recomposición del padre
@Composable
fun Padre(items: List<Item>) {
items.forEach { item ->
ItemCard(
item = item,
onClick = { viewModel.seleccionar(item.id) } // lambda nueva siempre
)
}
}
// ✓ BIEN — lambda estable con referencia al ViewModel
@Composable
fun Padre(items: List<Item>, onSeleccionar: (Int) -> Unit) {
items.forEach { item ->
ItemCard(item = item, onClick = { onSeleccionar(item.id) })
}
}
// La lambda que llama a viewModel.seleccionar se crea en el ViewModel/caller,
// no en la composición del Padre
// Para lambdas que capturan estado cambiante: rememberUpdatedState
@Composable
fun Timer(onExpiracion: () -> Unit) {
// Si onExpiracion cambia, el efecto no necesita reiniciarse
val onExpiracionActual by rememberUpdatedState(onExpiracion)
LaunchedEffect(Unit) {
delay(5000)
onExpiracionActual() // siempre llama a la versión más reciente
}
}
key() — identidad en listas
// En LazyColumn, key() permite a Compose rastrear cada item
// Si la lista se reordena, los items mantienen su estado en lugar de resetearse
LazyColumn {
items(
items = productos,
key = { producto -> producto.id } // identificador único y estable
) { producto ->
ProductoCard(producto)
}
}
// En composición normal (no lazy), key() fuerza a Compose a tratar
// el contenido como perteneciente a una identidad diferente
Column {
for (item in items) {
key(item.id) {
// Si item.id cambia entre recomposiciones,
// Compose descarta y recrea este subtree en lugar de reusar
ItemRow(item)
}
}
}
Medir recomposiciones con el Layout Inspector
# Android Studio → View → Tool Windows → Layout Inspector
# Con la app corriendo en debug, activar "Show recomposition counts"
# El inspector muestra en cada composable:
# - Cuántas veces se recompuso (número verde)
# - Cuántas veces se salteó la recomposición (número azul)
# Un composable que se recompone 50 veces mientras el usuario solo hizo
# una acción es una señal de recomposición excesiva
# Para profiling más detallado: Android Studio Profiler → CPU → System Trace
# Los composables aparecen como "Compose Recompose" en el trace