Por qué MVI hace que el skipping funcione solo
Las técnicas de estabilidad se presentan a menudo como una lista de trucos que hay que aplicar sobre una interfaz ya escrita, y ese enfoque produce un mantenimiento eterno. Esta lección defiende la tesis contraria: si el estado se modela como un único valor inmutable, con clases de datos de campos val, colecciones inmutables y jerarquías selladas para los casos excluyentes, la estabilidad deja de ser algo que se persigue y pasa a ser una propiedad estructural que el compilador deduce sin ayuda. Se examina cómo el reductor de MVI produce instancias nuevas cuya igualdad significa algo, cómo la granularidad del estado determina cuánto se recompone, y por qué el patrón que se adoptó por predictibilidad acaba regalando rendimiento como efecto lateral.
Llegados aquí surge una sospecha legítima: si mantener la estabilidad exige tanta vigilancia —auditar tipos, revisar informes, cuidar cada colección—, ¿no será que el sistema está mal diseñado? La respuesta es que sí lo estaría, si la estabilidad hubiera que perseguirla pieza a pieza sobre una interfaz ya escrita. Pero existe una forma de organizar una pantalla en la que la estabilidad no se persigue: se hereda. Esa forma es exactamente la que llevas cinco niveles construyendo. MVI no fue diseñado pensando en el compilador de Compose —es anterior, y viene de un linaje funcional que nada tenía que ver con Android—, y sin embargo cumple sin proponérselo las tres cláusulas de la definición de estabilidad, porque ambas ideas descienden del mismo principio. Esta lección explica por qué esa coincidencia no es una casualidad afortunada sino una consecuencia necesaria, y qué hay que hacer para no estropearla.
- Ver cómo un estado modelado como valor inmutable satisface las cláusulas de estabilidad por construcción.
- Entender por qué las instancias nuevas que produce el reductor hacen que la comparación signifique algo.
- Elegir la granularidad del estado para que el salto ocurra donde interesa y no se pierda en la raíz.
- Reconocer las tres formas habituales de romper una estabilidad que ya se tenía gratis.
El estado como un único valor inmutable
Recupera la definición de estado de MVI: una sola clase de datos, con todos sus campos val, que describe por completo lo que la pantalla debe mostrar en un instante. Ahora colócala al lado de las cláusulas de estabilidad. Todos los campos son val, así que ninguna propiedad muta nunca y la segunda cláusula —la de notificación— se cumple de forma trivial: no hay nada que notificar. La clase de datos genera un equals estructural determinista, así que la primera cláusula se cumple. Y si los tipos de los campos son primitivos, String, otras clases de datos igualmente modeladas o colecciones inmutables, la tercera se cumple por inducción.
data class BandejaState(
val cargando: Boolean = false,
val mensajes: ImmutableList<Mensaje> = persistentListOf(),
val seleccion: Seleccion = Seleccion.Ninguna,
)
sealed interface Seleccion {
data object Ninguna : Seleccion
data class Uno(val id: Long) : Seleccion
}
Obsérvese que la jerarquía sellada aporta estabilidad además de exhaustividad. Seleccion.Ninguna es un objeto único y sin campos, así que su referencia nunca cambia; Seleccion.Uno es una clase de datos de un solo Long. El compilador conoce el conjunto cerrado de subtipos y puede razonar sobre todos ellos, cosa que con una interfaz abierta le resultaría imposible. La misma construcción que usabas para hacer irrepresentables los estados imposibles resulta ser, sin cambiar una coma, la que permite demostrar la estabilidad.
No hay una sola anotación en ese fragmento y todo él es estable, demostrablemente, sin que nadie tenga que prometer nada. El compilador lo deduce leyendo la forma del tipo. Compáralo con la alternativa que MVI evita —un puñado de campos observables sueltos, algunos mutables, algunos compartidos con el dominio— y verás que allí cada campo es una negociación de estabilidad distinta, mientras que aquí hay una sola decisión tomada una vez.
Una igualdad que significa algo
La segunda mitad del regalo está en el reductor. En MVI, el estado nunca se modifica: se reemplaza. Cada paso produce una instancia nueva a partir de la anterior mediante copy, y esa mecánica tiene una consecuencia precisa sobre la comparación de parámetros.
fun marcarLeido(id: Long) = intent {
reduce {
state.copy(
mensajes = state.mensajes.mutate { l ->
val i = l.indexOfFirst { it.id == id }
if (i >= 0) l[i] = l[i].copy(leido = true)
}.toImmutableList(),
)
}
}
Fíjate en lo que ocurre a nivel de referencias: copy construye un BandejaState nuevo, pero reutiliza sin copiar todos los campos que no se tocaron. cargando sigue siendo el mismo Boolean, seleccion sigue siendo la misma instancia. Cuando el composable raíz se recomponga y evalúe a sus hijos, el hijo que recibe state.seleccion comparará una referencia contra sí misma, dará igual y se saltará. Solo el subárbol que depende de mensajes verá un valor distinto y se reejecutará.
Merece la pena señalar el papel de la palabra casi. La comparación por referencia funciona sola para los campos que no se tocaron, pero un reductor descuidado puede reconstruir campos que no cambiaron —volviendo a mapear una lista entera cuando solo cambió el filtro, por ejemplo— y producir referencias nuevas para datos idénticos. Cuando eso pasa, la clase de datos sigue siendo estable y su equals estructural salva la situación en la mayoría de los casos, pero el trabajo de reconstrucción ya se ha pagado. La disciplina consiste en que el reductor toque exactamente lo que cambia y nada más.
Esto es lo que significa “el skipping funciona casi solo”: no hay que hacer nada para conseguirlo más que producir estados nuevos por copia estructural. La inmutabilidad, que se adoptó porque hacía el estado predecible y auditable, resulta ser también el mecanismo que convierte cada copy en un diff implícito que el runtime puede leer comparando referencias.
flowchart TD R[Reduce produce un estado nuevo con copy] --> N[Instancia raiz distinta] N --> C1[Campo mensajes: referencia nueva] N --> C2[Campo seleccion: misma referencia] N --> C3[Campo cargando: mismo valor] C1 --> RE[Ese subarbol se recompone] C2 --> SK1[Ese subarbol se salta] C3 --> SK2[Ese subarbol se salta] style RE fill:#f9e2af,color:#11111b style SK1 fill:#a6e3a1,color:#11111b style SK2 fill:#a6e3a1,color:#11111b
Granularidad: pasar campos, no el estado entero
Queda un detalle de oficio que decide si todo lo anterior se aprovecha o se desperdicia. Si cada composable de la pantalla recibe el objeto de estado completo, entonces cualquier cambio en cualquier campo produce una raíz distinta y todos los hijos ven un parámetro nuevo: nada se salta, pese a que todo es perfectamente estable. La estabilidad habilita el salto; la granularidad decide dónde ocurre.
// Desperdicia el salto: cualquier cambio del estado invalida a los dos hijos.
@Composable
fun Bandeja(state: BandejaState, onAbrir: (Long) -> Unit) {
Cabecera(state = state)
Lista(state = state, onAbrir = onAbrir)
}
// Aprovecha el salto: cada hijo depende solo de lo que usa.
@Composable
fun Bandeja(state: BandejaState, onAbrir: (Long) -> Unit) {
Cabecera(titulo = state.titulo, cargando = state.cargando)
Lista(mensajes = state.mensajes, onAbrir = onAbrir)
}
La regla es pasar a cada composable el mínimo que necesita, no el contenedor donde vive. Es la misma disciplina que ya aplicas al diseñar funciones puras, y aquí tiene un premio medible: Cabecera no se recompondrá cuando llegue un mensaje nuevo, porque su título y su indicador de carga no cambiaron.
Existe una variante para los casos en que un composable necesita muchos campos y desglosarlos produciría una firma absurda: pasar el estado completo pero envuelto en una función de acceso diferida, de modo que la lectura del valor ocurra dentro del hijo y no en el sitio de la llamada. Así el parámetro es una lambda estable que no cambia nunca, y la invalidación se acota al scope que realmente lee el dato.
// La lectura ocurre dentro del hijo: el parametro en si no cambia nunca.
@Composable
fun Pie(totalProveedor: () -> Int) {
Text("Total: " + totalProveedor())
}
Esta técnica se llama a veces aplazamiento de la lectura y conviene usarla con moderación: complica las firmas y solo compensa en nodos que se recomponen a mucha frecuencia, como los que siguen a un gesto de arrastre o a una animación. En el resto, desglosar los campos es más simple y más legible.
Inmutabilidad
Campos val y colecciones inmutables. Cumple las cláusulas de estabilidad sin anotar nada.
Copia estructural
copy reutiliza las referencias intactas, y esa reutilización es el diff que el runtime aprovecha para saltar.
Granularidad
Pasar campos y no el estado entero. Sin esto, la estabilidad existe pero no se traduce en saltos.
Conviene añadir que la granularidad tiene también un efecto sobre la legibilidad y las pruebas que es independiente del rendimiento: un composable que declara en su firma exactamente los tres datos que usa es un componente reutilizable y trivial de previsualizar, mientras que uno que recibe el estado completo de la pantalla solo sirve para esa pantalla y necesita construir un estado entero para probarse. Otra vez, la presión del rendimiento y la del diseño empujan en la misma dirección.
Las tres formas de estropearlo
Como todo lo que se recibe gratis, esta estabilidad se pierde por descuido y casi siempre de una de tres maneras. La primera es dejar entrar un tipo de dominio o de red directamente en el estado: un modelo generado por una biblioteca de serialización, con campos mutables o listas corrientes, contamina la clase entera. La segunda es usar List en lugar de ImmutableList, el caso ya estudiado y el más frecuente con diferencia. La tercera es pasar el estado completo a todos los hijos, que no rompe la estabilidad pero anula su beneficio.
Ninguna de las tres se detecta leyendo el código con atención, porque las tres parecen razonables. Las tres se detectan en treinta segundos con los informes del compilador, que es la razón por la que la lección anterior venía antes que esta.
Cuando revises un cambio que toca el estado de una pantalla, haz una sola pregunta: ¿todos los campos nuevos son val de tipos primitivos, clases de datos propias, jerarquías selladas o colecciones inmutables? Si la respuesta es sí, no hace falta mirar nada más. Si es no, el autor debe justificar por qué. Esa pregunta única evita la práctica totalidad de las regresiones de estabilidad y no requiere que quien revisa entienda el compilador.
La conclusión de este nivel es también la tesis que ordena todo el recorrido, y merece enunciarse sin adornos. Adoptaste MVI por razones que no tenían nada que ver con la velocidad: querías un flujo unidireccional para poder razonar sobre él, un estado que fuera una fotografía completa y auditable, un reductor puro que se pudiera probar sin dispositivo, y estados imposibles que el sistema de tipos hiciera irrepresentables. Todas ellas son razones de corrección. Y sin embargo, al final del camino, resulta que el mismo modelo que hace tu app comprensible es el que hace que el compilador de Compose pueda demostrar que puede saltarse la mitad del trabajo. Esa convergencia no es una casualidad afortunada: es que ambas cosas descienden del mismo principio, el de que un valor que no cambia es un valor sobre el que se puede razonar. Tú razonas sobre él para depurar, escribir pruebas y entender qué pasó; el compilador razona sobre él para demostrar que dos instancias iguales producen la misma interfaz y ahorrarse la segunda. Es literalmente la misma propiedad explotada por dos lectores distintos. De ahí se sigue la inversión de prioridades que define a un ingeniero maduro en esta plataforma: no persigas el rendimiento, persigue la claridad del modelo de datos, y el rendimiento aparecerá como corolario. El código de Compose más rápido que verás en tu vida no estará lleno de anotaciones, memos y trucos de recomposición; estará escrito con clases de datos aburridas, campos val, jerarquías selladas y colecciones inmutables, y será rápido precisamente porque no intentaba serlo.
- Toma el estado de una pantalla tuya y comprueba, cláusula por cláusula, si es estable por construcción o si depende de alguna anotación.
- Dibuja qué campos cambian de referencia en el
copyde tu reductor más frecuente y deduce qué subárboles deberían saltarse. - Busca un composable que reciba el estado completo y refactorízalo para que reciba solo los campos que usa; mide antes y después.
- Identifica en tu proyecto un modelo de dominio o de red que se haya colado en el estado y propón el modelo de presentación que lo sustituye.
- Argumenta, con lo aprendido en los cinco niveles, por qué la frase “MVI es más lento porque copia el estado entero” es falsa.