Recomposiciones descontroladas: estabilidad, lambdas y lecturas altas
Una pantalla que se recompone entera con cada pulsación de tecla no tiene un problema de Compose: tiene un problema de contratos incumplidos entre el modelo de estado y el compilador. Esta lección ordena las tres causas reales por orden de impacto —la lectura del estado demasiado arriba en el árbol, que invalida todo lo que cuelga de ella; los tipos que el compilador no puede declarar estables y que por tanto le impiden omitir trabajo; y las lambdas que cambian de identidad en cada composición—, explica qué cambió con el modo de omisión fuerte y qué no, describe cómo medir antes de tocar nada con los informes del compilador y el inspector, y cataloga los falsos remedios que empeoran el rendimiento mientras aparentan arreglarlo.
Hay una ironía incómoda en la relación entre MVI y Compose. La arquitectura que mejor encaja con un modelo declarativo —un objeto de estado inmutable que describe la pantalla entera— es también la que más fácilmente produce el peor patrón de recomposición posible, porque concentra en un solo valor toda la información y hace trivial la tentación de leerlo entero en la raíz. Cuando eso ocurre, cada tecla pulsada en un buscador cambia la identidad del objeto completo y con ella la de todo lo que dependía de él, de modo que la pantalla entera se reconstruye para reflejar una letra. El fallo no es de Compose, que se comporta exactamente como prometió, ni de MVI, que no obliga a nada de esto. Es de la traducción entre ambos, y esa traducción es un contrato que se puede cumplir o incumplir de tres maneras distintas y con tres costes muy diferentes.
- Medir antes de intervenir, usando los informes del compilador y los contadores de recomposición.
- Ordenar las tres causas por impacto real y atacar primero la lectura alta del estado.
- Explicar qué garantiza la estabilidad, qué cambió con la omisión fuerte y qué sigue sin resolverse.
- Reconocer los falsos remedios que añaden coste sin reducir recomposiciones.
Medir antes de tocar
La optimización de recomposiciones es el terreno donde la intuición falla con más regularidad, porque el coste real no está donde está el código sospechoso sino donde está el trabajo caro, y ambos rara vez coinciden. Un componente que se recompone cien veces por segundo y solo escribe un texto es irrelevante; otro que se recompone tres veces y decodifica una imagen o reconstruye una lista larga puede costar una animación entera. Por eso la primera acción no es cambiar nada sino obtener dos medidas.
La primera son los informes de estabilidad del compilador, que se activan con una opción de compilación y producen, por cada función componible, si es reiniciable y si es omitible, y por cada clase, si el compilador la considera estable. Ese informe es la única fuente autorizada sobre la estabilidad de tus tipos: sustituye la conjetura por un dato, y suele revelar que la clase que todo el mundo daba por estable no lo es por culpa de un campo anidado que nadie miraba. La segunda medida son los contadores de recomposición del inspector de diseño, que enseñan qué partes del árbol se reconstruyen y cuáles se omitieron, es decir, la consecuencia observable de lo que el informe describe en abstracto.
Hay un tercer dato que ordena los dos anteriores y que se olvida sistemáticamente: en qué fase se paga el coste. Compose ejecuta tres fases por fotograma —componer, medir y colocar, y dibujar— y una invalidación no siempre alcanza a las tres. Un cambio que solo afecta al dibujo es órdenes de magnitud más barato que uno que obliga a recomponer y volver a medir todo un árbol. Por eso la pregunta útil no es cuántas recomposiciones hay, sino cuánta de la cascada se podría haber detenido antes: mover una lectura de la fase de composición a la de dibujo suele ganar más que cualquier cantidad de memorización dentro de la composición.
Un contador alto es una pista, no un veredicto. Antes de intervenir conviene responder a tres preguntas sobre el componente señalado: qué trabajo hace en cada recomposición, si ese trabajo asigna memoria y si el usuario percibe algo. Un componente que se recompone constantemente pero solo lee un valor y ajusta un color no justifica ninguna intervención, y envolverlo en memorización lo empeora, porque la memorización tiene su propio coste de almacenamiento y comparación. En cambio, un componente que se recompone tres veces por gesto y en cada una construye una lista derivada de mil elementos merece atención inmediata aunque su contador sea ridículo comparado con el anterior. Optimizar por el contador y no por el coste es la forma más común de pasar una tarde mejorando un número que no afectaba a nadie.
La lectura demasiado alta
De las tres causas, esta es la más cara y la única específica de la combinación entre MVI y Compose, así que va primero. Ocurre cuando un componente alto del árbol lee el objeto de estado completo y va repartiendo campos hacia abajo. Como leer un valor en Compose establece una dependencia, ese componente queda suscrito a cualquier cambio del estado, y como todo lo demás cuelga de él, la invalidación se propaga hacia abajo por defecto y solo se detiene donde el mecanismo de omisión pueda demostrar que los argumentos no cambiaron.
// Mal: la raiz lee todo el estado y cualquier campo invalida el arbol entero
@Composable
fun PerfilPantalla(vm: PerfilViewModel) {
val estado by vm.container.stateFlow.collectAsStateWithLifecycle()
Cabecera(estado) // recibe el estado completo
Muro(estado) // tambien
Filtros(estado) // tambien
}
Hay dos correcciones y no son equivalentes. La primera y casi siempre suficiente consiste en pasar hacia abajo solo lo que cada hijo necesita, de modo que un cambio en el texto de búsqueda no altere los argumentos de la cabecera y esta pueda omitirse. Esta corrección tiene una consecuencia de diseño que conviene subrayar: es exactamente el mismo trabajo que la partición del estado en subestados cohesivos, visto desde el otro extremo del sistema. Un estado bien partido produce árboles de composición bien aislados casi sin esfuerzo adicional.
La segunda corrección, para el caso en el que un valor cambia con frecuencia altísima —la posición de un desplazamiento, el progreso de una animación, la coordenada de un gesto—, consiste en diferir la lectura pasando una función que devuelve el valor en lugar del valor mismo. Así la dependencia se establece en el punto exacto donde se usa, que suele ser la fase de dibujo, y ni siquiera se recompone: se vuelve a dibujar, que es entre uno y dos órdenes de magnitud más barato.
// Cada hijo recibe lo suyo, y el valor de altisima frecuencia se difiere
@Composable
fun PerfilPantalla(vm: PerfilViewModel) {
val estado by vm.container.stateFlow.collectAsStateWithLifecycle()
val listo = estado as? PerfilState.Listo ?: return
Cabecera(listo.cabecera)
Muro(listo.muro, desplazamiento = { scroll.value })
}
La segunda corrección tiene un requisito que la hace fácil de aplicar mal: solo funciona si la función diferida se consume dentro de un bloque que se ejecuta en una fase posterior. Pasar una función y llamarla inmediatamente en el cuerpo del componible no difiere nada, porque la lectura vuelve a ocurrir durante la composición y la dependencia se establece igual. La diferencia entre las dos versiones no está en la firma sino en dónde se invoca, lo que la convierte en una de las pocas optimizaciones cuyo efecto no se puede verificar leyendo solo la declaración.
flowchart TB E[Cambia un campo del estado] E --> R[La raiz leyo el estado completo] R --> I[Se invalida la raiz entera] I --> H1[Cabecera se recompone sin necesidad] I --> H2[Muro se recompone sin necesidad] E --> P[Cada hijo recibe solo lo suyo] P --> O1[Cabecera se omite] P --> O2[Muro se omite] P --> O3[Filtros se recompone y es correcto] style I fill:#f38ba8,color:#11111b style P fill:#a6e3a1,color:#11111b
Estabilidad y lambdas
La segunda causa es la inestabilidad de tipos. Para omitir la ejecución de un componible, el compilador necesita poder comparar sus argumentos, y solo se atreve a hacerlo si puede demostrar que un valor igual implica un contenido igual que no cambiará por debajo. Los tipos que no ofrecen esa garantía se consideran inestables y bloquean la omisión. El caso que más sorprende es el de las colecciones de la biblioteca estándar: una List es una interfaz de solo lectura, no una garantía de inmutabilidad, y nada impide que la implementación concreta sea mutable, así que el compilador no puede darla por estable.
// El compilador no puede garantizar la estabilidad de una interfaz de solo lectura
data class MuroState(val posts: List<Post>)
// Alternativas: coleccion persistente o promesa explicita del autor
data class MuroOk(val posts: ImmutableList<Post>)
@Immutable
data class MuroPrometido(val posts: List<Post>)
La anotación de inmutabilidad es una promesa del programador, no una comprobación, y por tanto traslada la responsabilidad: si mientes, el compilador omite trabajo que debía hacer y la pantalla deja de reflejar los datos. Las colecciones persistentes, en cambio, garantizan por su tipo lo que la anotación solo declara. La otra fuente clásica de inestabilidad son las clases definidas en módulos que no pasan por el compilador de Compose, típicamente los modelos de dominio de un módulo puro, y ahí la salida limpia es un fichero de configuración de estabilidad en lugar de contaminar el dominio con anotaciones de interfaz.
El modo de omisión fuerte cambió el panorama sin cerrarlo, y conviene tener claro qué hizo exactamente. Hizo dos cosas: memoriza automáticamente las lambdas que se pasan a los componibles, con lo que la tercera causa —lambdas con identidad nueva en cada composición— dejó de ser el problema cotidiano que fue durante años; y permite omitir componibles con parámetros inestables comparándolos por identidad de instancia en lugar de por igualdad. Lo que no hizo fue volver irrelevante la estabilidad: la comparación por identidad solo salva el caso en el que la instancia es literalmente la misma, de modo que un objeto reconstruido con copy y contenido idéntico sigue provocando recomposición. Y sobre todo, no toca en absoluto la primera causa, que es la lectura demasiado alta.
Queda por tanto un residuo de casos donde las lambdas siguen importando, y conviene conocerlo para no dar el tema por cerrado. El más frecuente aparece dentro de listas: una lambda construida en cada elemento capturando el índice o el identificador de ese elemento tiene una identidad distinta por elemento y por composición, y la memorización automática no puede unificarla porque las capturas son genuinamente diferentes. La corrección clásica consiste en subir la lambda un nivel y pasar hacia abajo el identificador, de modo que el elemento reciba una función estable y el dato que la particulariza.
// La lambda se construye una vez y el identificador viaja como argumento
LazyColumn {
items(posts, key = { it.id }) { post ->
PostFila(post = post, onPulsar = alPulsarPost)
}
}
Lectura alta
Coste alto y estructural. Se arregla repartiendo campos o difiriendo la lectura. Ninguna mejora del compilador la resuelve por ti.
Inestabilidad
Coste medio. Se diagnostica con el informe del compilador y se corrige con colecciones persistentes o configuración de estabilidad por módulo.
Lambdas
Coste bajo desde la omisión fuerte. Sigue importando en los casos que la memorización automática no cubre, como las lambdas construidas dentro de bucles con capturas variables.
Los falsos remedios
Hay tres correcciones populares que empeoran lo que pretenden arreglar. La primera es memorizar todo por sistema: cada memorización ocupa espacio en la composición y ejecuta comparaciones de claves, de modo que aplicada a valores baratos añade coste neto. Memorizar tiene sentido cuando el cálculo evitado cuesta más que la comparación, y para una concatenación de dos cadenas no lo cuesta.
La segunda es usar una clave para forzar la reconstrucción de una subárea. Funciona en el sentido de que elimina el estado obsoleto, y por eso parece resolver el problema, pero lo hace descartando y reconstruyendo toda la subcomposición junto con su estado recordado y sus animaciones en curso. Es la operación más cara del sistema, empleada como remedio contra el exceso de trabajo. La tercera es derivar un valor caro en cada recomposición en vez de derivarlo del estado con una derivación memorizada, que es el patrón concreto donde ordenar o filtrar una lista larga se repite decenas de veces por segundo sin que el resultado cambie.
// Se recalcula en cada recomposicion aunque la lista no haya cambiado
val visibles = posts.filter { it.publicado }.sortedByDescending { it.fecha }
// Solo se recalcula cuando cambia aquello de lo que depende
val visibles by remember(posts) {
derivedStateOf { posts.filter { it.publicado }.sortedByDescending { it.fecha } }
}
Hay un cuarto falso remedio, más raro y más dañino que los tres anteriores, que consiste en mover la derivación al reductor para evitar el cálculo en la interfaz. Parece razonable y contradice la primera lección de este nivel: introduce estado derivado almacenado en el objeto de estado, con toda la carga de sincronización que eso implica, para ahorrar un cálculo que se podía memorizar en el sitio donde se usa. La regla que resuelve el conflicto aparente es simple: si la derivación depende solo de datos del estado, memorízala en la vista; si depende además de una regla de negocio que la vista no debería conocer, entonces sí pertenece al reductor, y en ese caso el campo resultante no es un derivado sino un hecho.
Ataca siempre en este orden y detente en cuanto la medida sea aceptable. Primero, reparte el estado hacia abajo para que cada componente reciba solo lo que usa; esta sola medida resuelve la mayoría de los casos reales y además mejora la legibilidad del árbol. Segundo, mira el informe del compilador y corrige la inestabilidad de los tipos que aparecen en las firmas de los componibles más caros, no de todos. Tercero, difiere la lectura de los valores de altísima frecuencia. Y solo entonces, si sigue haciendo falta, memoriza cálculos concretos que hayas medido. Invertir este orden es lo que produce esas jornadas en las que se toca todo, se ensucia el código y el número apenas se mueve.
Lo que hace difícil este tema no es la lista de reglas, que es corta, sino que exige entender qué clase de afirmación es la estabilidad, y la respuesta no es la que se espera. Estable no significa inmutable, ni final, ni sin campos mutables: significa que el sistema puede confiar en que dos valores iguales representan contenidos iguales, y que si el contenido cambiara, alguien avisaría. Es decir, no es una propiedad intrínseca del dato sino un contrato entre quien lo declara y el motor que decide qué trabajo puede omitir, y como todo contrato, puede establecerse de tres formas distintas con tres niveles de garantía. Puede demostrarse, cuando el compilador ve la declaración completa y comprueba que todo es de solo lectura hasta las hojas. Puede garantizarse por construcción, cuando el tipo mismo hace imposible la mutación, que es lo que aportan las colecciones persistentes y por lo que son preferibles aunque exijan una dependencia más. O puede prometerse, cuando el autor firma una anotación y asume la responsabilidad de que la promesa sea cierta, cosa que el compilador nunca comprobará. La jerarquía entre las tres formas es la misma que aparece en cualquier sistema de tipos que negocie con el mundo real, y de ella se sigue la única regla que hace falta recordar: prefiere siempre la garantía a la promesa, y usa la promesa solo cuando puedas defender por qué es cierta. Pero la moraleja más útil de esta lección está por encima de todo eso, y es que el rendimiento en un sistema declarativo no se gana optimizando el motor sino describiéndole con precisión de qué depende cada cosa. Cada dependencia que declaras de más es trabajo que el motor está obligado a hacer, y cada una que declaras de menos es un fallo de corrección. Leer el estado en la raíz es declarar que todo depende de todo, y ninguna cantidad de memorización posterior puede deshacer esa declaración, porque optimizar es aquí, y solo aquí, decir la verdad sobre las dependencias.
- Activa los informes de estabilidad del compilador en un módulo real y localiza los tres componibles no omitibles con el argumento más caro.
- Encuentra un caso de lectura demasiado alta, repártelo hacia abajo y compara los contadores de recomposición antes y después.
- Elige un tipo inestable de tu estado y corrígelo por dos vías distintas: colección persistente y anotación. Argumenta cuál firmarías en una revisión.
- Localiza una derivación cara que se repite en cada recomposición y conviértela en una derivación memorizada, midiendo la diferencia.
- Aplica deliberadamente los tres falsos remedios a una pantalla sana y documenta cuánto coste añade cada uno sin reducir ninguna recomposición.