Recomposición en Jetpack Compose: por qué una lista entera se redibuja en vez de un solo ítem
Jetpack Compose promete que solo se recompone lo que cambió. Esa promesa se cumple casi siempre en pantallas simples y se rompe con facilidad en cuanto aparece una LazyColumn con un par de cientos de ítems y un estado que cambia con frecuencia, como un contador de no leídos o un botón de favorito que el usuario toca mientras sigue haciendo scroll. El síntoma es siempre el mismo: se toca un ítem, y en el perfilador aparece la lista completa recomponiendo, no solo la fila afectada. Entender por qué pasa esto exige mirar cómo Compose decide, en realidad, qué volver a ejecutar.
Cómo decide Compose qué recomponer
El compilador de Compose instrumenta cada función @Composable para registrar qué valores de estado lee durante su ejecución. Cuando uno de esos valores cambia, el runtime no vuelve a ejecutar toda la jerarquía desde la raíz: busca el conjunto de scopes de recomposición que efectivamente leyeron ese valor y solo vuelve a ejecutar esos scopes, saltándose todo lo que no depende de él. Esto es lo que permite que, en teoría, tocar un MutableState<Boolean> que vive dentro de un único ítem de una lista de doscientos solo recomponga ese ítem.
El problema aparece cuando el scope de recomposición termina siendo más grande de lo que parece. Si el contenido de cada fila se define como una lambda que recibe el ítem completo desde una lista que vive en un ViewModel y ese ViewModel expone un único StateFlow<List<Item>>, cualquier cambio en cualquier ítem produce una lista nueva completa -porque así se construyen los flujos inmutables- y el collectAsState() que observa ese flujo desde el composable padre dispara la recomposición de ese padre entero. La LazyColumn vuelve a ejecutar su bloque de contenido con la lista nueva, y aunque cada fila individual pueda seguir siendo skippable si sus parámetros no cambiaron, el trabajo de recorrer y comparar cada fila ya se pagó igual.
Claves estables en LazyColumn
El primer punto de fuga concreto está en cómo se declaran los ítems. Sin una clave explícita, LazyColumn identifica cada fila por su posición:
LazyColumn {
items(pedidos) { pedido ->
PedidoRow(pedido)
}
}
Mientras la lista no cambie de orden ni de tamaño, esto no causa problemas visibles. Pero en cuanto se inserta un elemento al principio, se elimina uno del medio, o se reordena la lista después de un filtro, Compose ya no tiene forma de saber que la fila que antes estaba en la posición 5 es la misma entidad que ahora está en la posición 6: solo ve que la posición 5 tiene datos distintos a los de la ejecución anterior y recompone esa fila como si fuera contenido nuevo, perdiendo además cualquier estado local que esa fila tuviera con remember -el scroll de un carrusel interno, un estado de expandido/colapsado- porque ese estado está atado a la posición, no a la identidad.
La corrección es declarar una clave basada en la identidad real del dato:
LazyColumn {
items(pedidos, key = { it.id }) { pedido ->
PedidoRow(pedido)
}
}
Con una clave estable, Compose puede reconocer que el pedido con id = 42 sigue siendo el mismo aunque haya cambiado de posición, reutilizar su slot de composición y su estado local, y sobre todo, limitar la recomposición a los ítems cuyo contenido efectivamente cambió en lugar de tratar cualquier desplazamiento de posición como una fila completamente nueva. Esto no evita por sí solo el problema del StateFlow<List<Item>> completo descrito arriba, pero es la condición necesaria para que cualquier otra optimización sobre la lista tenga efecto.
remember y derivedStateOf para acotar qué se recalcula
remember cachea el resultado de un cálculo entre recomposiciones del mismo scope, siempre que sus claves no cambien. Es útil para evitar que un LazyColumn recalcule un formateo costoso en cada recomposición del padre, pero no resuelve el problema de fondo cuando lo que cambia con frecuencia es un valor derivado de un estado que cambia todavía más seguido, como la posición de scroll.
Un caso típico es mostrar un botón de "volver arriba" cuando el usuario baja más de cierta cantidad de ítems:
val listState = rememberLazyListState()
val mostrarBoton by remember {
derivedStateOf { listState.firstVisibleItemIndex > 5 }
}
Sin derivedStateOf, leer listState.firstVisibleItemIndex directamente en el composable que dibuja el botón lo suscribe a ese valor, que cambia en cada pixel de scroll, y ese composable se recompone decenas de veces por segundo aunque el botón nunca cambie de estado visible. derivedStateOf envuelve la lectura en un cálculo que Compose sí observa en cada cambio de firstVisibleItemIndex, pero que solo notifica a los lectores de mostrarBoton cuando el resultado booleano efectivamente cambia de valor. El scope que dibuja el botón sigue suscrito a una fuente de cambios frecuentes por debajo, pero solo recompone cuando el valor que realmente le importa cambia.
Lo que no funcionó: envolver todo en remember sin revisar la estabilidad de los parámetros
La primera vez que intentamos atacar este problema, envolvimos el contenido de cada fila en un bloque remember que memorizaba el Modifier calculado y el color de fondo según el estado de favorito, asumiendo que eso bastaría para que Compose saltara filas sin cambios reales. No ayudó en nada, y el perfilador seguía mostrando recomposición completa de la lista en cada toggle.
La causa era otra: PedidoRow recibía como parámetro una lambda onFavoritoClick: (Pedido) -> Unit definida inline en el sitio de la llamada, dentro del bloque items. Cada recomposición del padre generaba una instancia nueva de esa lambda -sin remember explícito, el compilador de Compose solo puede evitar la recreación cuando puede inferir que las variables capturadas son estables, y en este caso la lambda capturaba una referencia al ViewModel completo, tratada como parámetro inestable-, y una lambda nueva en cada recomposición hace que Compose no pueda considerar estables los parámetros de PedidoRow, así que cualquier fila se trataba como si sus parámetros hubieran cambiado incluso cuando el Pedido en sí era idéntico al de la ejecución anterior. remember sobre valores internos de la fila no podía compensar eso, porque la fila entera ya se consideraba no-skippable antes de llegar a evaluar su contenido.
La corrección fue subir la lambda fuera del bloque items y envolverla en remember a nivel del composable padre, capturando solo la referencia a una función del ViewModel -que sí es estable entre recomposiciones- en vez del objeto ViewModel completo:
val onFavoritoClick = remember(viewModel) {
{ pedido: Pedido -> viewModel.toggleFavorito(pedido.id) }
}
LazyColumn {
items(pedidos, key = { it.id }) { pedido ->
PedidoRow(pedido, onFavoritoClick = onFavoritoClick)
}
}
Con la lambda estabilizada y la clave por id ya en su lugar, el perfilador mostró lo que se esperaba desde el principio: tocar el favorito de un pedido recompone esa fila y nada más.
Cuándo vale la pena perseguir esto
Nada de esto importa en una lista de diez ítems que rara vez cambia; el costo de una recomposición completa ahí es indistinguible en un frame. Vale la pena revisar claves, estabilidad de lambdas y derivedStateOf cuando se combinan dos condiciones: listas de tamaño considerable y estado que cambia con una frecuencia notablemente mayor que la frecuencia con la que cambia el contenido visible de cada fila individual. Fuera de esa combinación, es optimización prematura que complica el código sin que el usuario note ninguna diferencia.