AA5.1 Recomposición y rendimiento¶
Qué se ha ejecutado y qué no
El estado de Compose (mutableStateOf, derivedStateOf, snapshotFlow) y la lógica de versiones se han ejecutado de verdad al construir esta web, con el runtime de Compose en la JVM. Los composables, la configuración de Gradle y los pasos de Play Console no se pueden ejecutar aquí (necesitan Android o son procesos manuales): están marcados «sin ejecutar». Las versiones de herramientas cambian con el tiempo: comprueba la documentación oficial al usarlas.
Qué es la recomposición¶
Cuando el estado que lee un composable cambia, Compose vuelve a ejecutar esa función para actualizar la pantalla: eso es recomponer. Compose es listo: solo recompone lo que leyó ese estado, y puede saltarse (skip) las funciones cuyos parámetros no han cambiado. Pero si lees estado en un sitio demasiado alto, o pasas parámetros que Compose no sabe comparar, recompones mucho más de lo necesario y la app va a tirones.
Primero medir, después optimizar
Casi nunca hace falta optimizar a ciegas. La regla de los profesionales: mide, encuentra el cuello de botella, arréglalo y vuelve a medir. Y se mide con la app en modo release (el modo debug es mucho más lento y da conclusiones falsas).
Cómo medir (sin ejecutar)¶
| Herramienta | Qué te dice |
|---|---|
| Layout Inspector (Android Studio) | Cuántas veces se recompone y se salta cada composable. Es la primera parada |
| Profiler | Uso de CPU, memoria y red mientras usas la app |
| Macrobenchmark | Tiempos medidos y repetibles (arranque, desplazamiento) en un dispositivo real |
| Informes del compilador de Compose | Qué funciones y qué clases considera Compose «estables» |
| GPU / «Profile GPU rendering» | Cuántos fotogramas tardan más de 16 ms (la barra roja: se nota el tirón) |
Estabilidad: cuándo Compose se salta una función¶
Para saltarse un composable, Compose necesita saber si un parámetro ha cambiado. Con tipos estables puede compararlos con equals. Un tipo es estable si Compose puede garantizar que sus cambios se le notifican (o que no cambia nunca):
| Tipo | ¿Estable? |
|---|---|
Int, String, Boolean, lambdas |
Sí |
data class con todo val de tipos estables |
Sí |
Una clase con una propiedad var normal |
No: podría cambiar sin avisar |
List, Set, Map (las interfaces de Kotlin) |
No: la lista podría ser una MutableList por debajo |
| Clases de otro módulo/librería que Compose no puede inspeccionar | Normalmente no |
El caso que más sorprende es List<T>: aunque la declares de solo lectura, Compose no puede estar seguro de que nadie la modifique. Hay varias salidas:
// Sin ejecutar: necesita el compilador de Compose
// 1) Marcar la clase para prometer que es inmutable (la promesa es TUYA: si la rompes, la pantalla se desactualiza)
@Immutable
data class TareasUiState(val tareas: List<Tarea>, val filtro: Filtro)
// 2) Usar una colección inmutable de verdad (librería kotlinx-collections-immutable)
data class TareasUiState2(val tareas: ImmutableList<Tarea>, val filtro: Filtro)
// 3) Activar el modo «strong skipping» (viene activado por defecto en las versiones recientes del compilador de Compose)
«Strong skipping»
Las versiones recientes del compilador de Compose (con Kotlin 2.0.20 en adelante) activan por defecto el modo strong skipping, que permite saltarse funciones aunque tengan parámetros inestables (los compara por identidad del objeto en lugar de por equals). Mejora mucho el caso típico, pero conviene seguir creando objetos nuevos solo cuando el contenido cambia de verdad. Comprueba cómo está configurado tu proyecto.
Leer el estado lo más tarde posible¶
Cuanto más arriba lees el estado, más grande es lo que se recompone al cambiar. Un ejemplo clásico es una animación de desplazamiento:
// Sin ejecutar: necesita el compilador de Compose
@Composable
fun Cabecera(scroll: ScrollState) {
// MAL: lee scroll.value al componer. Cada píxel de desplazamiento RECOMPONE toda la función
Box(Modifier.offset(y = (-scroll.value / 2).dp)) { /* ... */ }
// BIEN: la lectura se aplaza a la fase de layout, así no se recompone nada
Box(Modifier.offset { IntOffset(0, -scroll.value / 2) }) { /* ... */ }
}
La regla: si un modificador tiene una versión que acepta una lambda (offset { }, graphicsLayer { }, drawBehind { }), usa esa versión cuando el valor cambie muchas veces por segundo.
derivedStateOf: avisar solo cuando cambia el resultado¶
Mucho estado cambia a menudo pero lo que te importa de él cambia poco. Si quieres mostrar un botón «subir arriba» cuando el primer elemento visible ya no es el primero, el índice cambia con cada elemento que pasa, pero la respuesta («¿hay que mostrar el botón?») solo cambia dos veces. derivedStateOf es para eso: observa un cálculo y avisa únicamente cuando el resultado cambia.
Se puede comprobar con el runtime de Compose real: snapshotFlow convierte una lectura de estado en un Flow que emite cada vez que el valor leído cambia (justo lo que le ocurriría a un composable que lo leyera):
import androidx.compose.runtime.*
import androidx.compose.runtime.snapshots.Snapshot
fun main() = runBlocking {
var indice by mutableStateOf(0) // el índice del primer elemento visible
val mostrarBoton by derivedStateOf { indice > 0 } // ¿hay que mostrar «subir arriba»?
val viendoIndice = mutableListOf<Int>()
val viendoBoton = mutableListOf<Boolean>()
val j1 = launch(Dispatchers.Default) { snapshotFlow { indice }.collect { viendoIndice += it } }
val j2 = launch(Dispatchers.Default) { snapshotFlow { mostrarBoton }.collect { viendoBoton += it } }
delay(200)
for (i in 1..6) { // el usuario se desplaza: el índice sube de 1 en 1
indice = i
Snapshot.sendApplyNotifications() // publica el cambio de estado (Compose lo hace solo)
delay(80)
}
j1.cancel(); j2.cancel()
println("quien lee el índice se entera: $viendoIndice (${viendoIndice.size} veces)")
println("quien lee mostrarBoton se entera: $viendoBoton (${viendoBoton.size} veces)")
}
Salida:
quien lee el índice se entera: [0, 1, 2, 3, 4, 5, 6] (7 veces)
quien lee mostrarBoton se entera: [false, true] (2 veces)
Seis cambios de índice (más el valor inicial) llegan a quien lee indice, pero quien lee mostrarBoton se entera solo dos veces: al empezar y al pasar de false a true. En una pantalla real, eso son cinco recomposiciones evitadas. En una lista larga, desplazándose a toda velocidad, es la diferencia entre fluidez y tirones.
// Sin ejecutar: necesita el compilador de Compose
@Composable
fun ListaConBoton(items: List<String>) {
val estado = rememberLazyListState()
val mostrarBoton by remember { derivedStateOf { estado.firstVisibleItemIndex > 0 } }
Box {
LazyColumn(state = estado) { items(items) { Text(it) } }
if (mostrarBoton) {
FloatingActionButton(onClick = { /* animar hasta el principio */ }) { Icon(Icons.Default.KeyboardArrowUp, "Subir") }
}
}
}
remember { derivedStateOf { ... } } es el patrón completo: el remember conserva el derivedStateOf entre recomposiciones.
No lo uses siempre
derivedStateOf merece la pena cuando la entrada cambia mucho más a menudo que el resultado. Si el resultado cambia cada vez que cambia la entrada (por ejemplo, nombre + apellido), solo añade coste. Un cálculo sencillo se hace directamente en la composición.
Listas: key y contentType¶
En una LazyColumn, Compose necesita saber qué fila es cuál. Sin pistas, identifica las filas por su posición: si insertas una al principio, cree que todas han cambiado de contenido y las recompone todas.
// Sin ejecutar: necesita el compilador de Compose
LazyColumn {
items(
items = tareas,
key = { it.id }, // identidad estable: al insertar o reordenar solo cambia lo necesario
contentType = { it.tipo } // las filas del mismo tipo reutilizan su estructura
) { tarea ->
FilaTarea(tarea)
}
}
keydebe ser único y estable (un identificador, no la posición). Además hace que el estado de cada fila (remember) siga a su elemento al reordenar.contentTypesirve cuando la lista mezcla filas de aspecto muy distinto (cabeceras, anuncios, tarjetas): permite reutilizar filas del mismo tipo al desplazarse.
Trabajo pesado: fuera de la composición¶
Una función @Composable puede ejecutarse muchas veces por segundo. Todo lo que pongas dentro se repite:
| Malo (en la composición) | Mejor |
|---|---|
val ordenada = lista.sortedBy { ... } |
remember(lista) { lista.sortedBy { ... } }, o mejor, calcularlo en el ViewModel |
| Llamar a la red o leer del disco | LaunchedEffect o el ViewModel |
Crear objetos pesados (formateadores, Regex) |
remember { ... } |
Operaciones con efectos (Log, guardar) |
Efectos (LaunchedEffect, SideEffect) |
Errores frecuentes¶
| Error | Cómo evitarlo |
|---|---|
| Optimizar sin medir | Layout Inspector y modo release primero |
| Medir en modo debug | Siempre con una compilación release |
Pasar List y asumir que se salta la recomposición |
@Immutable, colecciones inmutables o strong skipping |
| Leer el scroll o una animación en la composición | Versión con lambda del modificador |
LazyColumn sin key |
key = { it.id } con un identificador estable |
derivedStateOf para todo |
Solo si el resultado cambia menos que la entrada |
| Ordenar o filtrar listas dentro del composable | En el ViewModel, o dentro de remember(clave) |
Para practicar¶
Los ejercicios AA5.1 y AA5.4 de AA5 · Ejercicios practican derivedStateOf y las listas con key.