La UI como función del estado
Compose no dibuja pantallas: las describe. Esta lección desmonta el salto conceptual que separa el sistema de vistas imperativo del modelo declarativo, y muestra por qué ese salto convierte a MVI en el compañero natural de Compose y no en un patrón añadido a la fuerza. Se analiza el problema combinatorio del widget que guarda su propia copia de la verdad, la ecuación fundacional que dice que la interfaz es una función del estado, la mecánica de la recomposición como reejecución de esa función y las obligaciones que impone: idempotencia, ausencia de efectos en el cuerpo del componible y ningún orden garantizado. Al final se enfrentan las dos funciones puras que forman el bucle completo: la reductora que va del estado y la intención al estado nuevo, y la componible que va del estado a los píxeles.
Durante quince años programar una interfaz en Android consistió en dar órdenes: busca esta vista, cámbiale el texto, ocúltale aquella, pon el spinner, quítalo. La pantalla era un objeto mutable al que se le iban aplicando parches, y el estado real de la app vivía disperso entre los campos privados del Fragment y los atributos internos de cada widget. Compose no mejora ese modelo: lo abandona. En Compose no le dices a la pantalla qué debe cambiar, le describes cómo debe verse dado un estado, y el runtime se encarga de averiguar la diferencia. Ese cambio de imperativo a declarativo no es una moda estética: es exactamente la misma renuncia que MVI había hecho antes por su cuenta, y por eso ambas piezas encajan sin adaptador.
- Entender por qué el widget que guarda su propia copia del estado produce una explosión combinatoria de casos.
- Leer la interfaz como una función pura del estado y no como un objeto al que se le aplican parches.
- Comprender la recomposición como reejecución selectiva y las obligaciones que impone al cuerpo de un componible.
- Ver el bucle completo como la composición de dos funciones: la reductora y la que renderiza.
Del parche imperativo a la descripción declarativa
El sistema de vistas obligaba a razonar sobre transiciones, no sobre estados. Si una pantalla tiene un indicador de carga, una lista, un mensaje de error y un botón, cada uno de esos widgets almacena su propia versión de la verdad, y tu código debe garantizar que las cuatro versiones sean coherentes tras cada evento. El problema no es escribir el cambio; es no olvidar ninguno. Con cuatro elementos hay dieciséis combinaciones posibles de visibilidad, y solo unas pocas son legales: el bug clásico del spinner que se queda girando sobre la lista ya cargada es una de esas combinaciones ilegales a la que nadie llegó a escribirle su parche.
// Imperativo: cada evento debe parchear TODOS los widgets afectados
fun mostrarCargando() {
progressBar.visibility = View.VISIBLE
recyclerView.visibility = View.GONE
errorText.visibility = View.GONE
// si olvidas una linea, la pantalla queda en un estado ilegal
}
// Declarativo: describes la pantalla entera para cada estado posible
@Composable
fun Pantalla(estado: UiState) {
when (estado) {
is UiState.Cargando -> Spinner()
is UiState.Contenido -> Lista(estado.items)
is UiState.Error -> Mensaje(estado.texto)
}
}
La segunda versión no puede producir el bug de la primera, y la razón es estructural, no de disciplina: no existe ninguna ruta en el código que muestre el spinner y la lista a la vez, porque la pantalla no se parchea sino que se redescribe entera a partir del estado. Los estados ilegales no se evitan con cuidado; se vuelven inexpresables. Esta es la misma jugada que viste cuando el modelo de MVI pasó de campos sueltos a una data class inmutable con una sealed interface para los casos mutuamente excluyentes: reducir el espacio de lo representable hasta que coincida con el espacio de lo válido.
La objeción evidente es de coste: si describimos la pantalla entera cada vez, ¿no estamos redibujándolo todo continuamente? No, y la distinción es central. Un componible no dibuja: emite una descripción del árbol que debería existir. El runtime compara esa descripción con la anterior y traduce la diferencia en las operaciones mínimas sobre lo que ya está en pantalla. Es decir, el cálculo de la diferencia —lo tedioso, lo que producía todos los bugs cuando lo hacía un humano a base de parches— sigue existiendo, pero lo hace una máquina que no se olvida de ningún caso. Lo que has cambiado no es la cantidad de trabajo, es quién lo hace y con qué fiabilidad.
Recomposición: la función que se vuelve a llamar
La ecuación que gobierna Compose se escribe en una línea: la interfaz es una función del estado. Cuando el estado cambia, esa función se vuelve a llamar, y el runtime compara el árbol resultante con el anterior para tocar solo lo que difiere. A ese reejecutar se le llama recomposición, y es selectiva: Compose recompone únicamente los ámbitos que leyeron el valor que cambió, saltándose el resto siempre que los parámetros sean estables. Un componible que recibe una data class inmutable con val puede compararse por igualdad y omitirse; uno que recibe una lista mutable no, y se recompone siempre.
Que la función se llame muchas veces impone obligaciones que conviene enunciar sin rodeos, porque casi todos los errores de un principiante en Compose son la violación de una de ellas.
Idempotente
Llamar al componible diez veces con el mismo estado produce exactamente la misma pantalla. Si el resultado depende de cuántas veces se ejecutó, ya hay un error latente.
Sin efectos en el cuerpo
Nada de peticiones, escrituras, analítica o navegación en el cuerpo: no controlas el número de ejecuciones, así que tampoco el número de veces que ocurrirán.
Sin orden garantizado
Los hermanos pueden ejecutarse en cualquier secuencia, e incluso en paralelo. Cualquier lógica que dependa de quién se compuso antes es una suposición sin respaldo.
Abandonable
Una recomposición puede descartarse a medias si llega un estado más nuevo. El trabajo iniciado en el cuerpo quedaría huérfano y sin nadie que lo cancele.
Estas cuatro propiedades no son recomendaciones de estilo: son las condiciones bajo las cuales el runtime puede permitirse las optimizaciones que hacen viable el modelo. Precisamente porque puede asumir que tu función es pura, se atreve a saltársela, a reordenarla, a ejecutarla en otro hilo o a tirar su resultado a la basura. Un componible que rompe el contrato no falla de inmediato; falla más tarde, de forma intermitente, cuando el runtime ejerce una libertad que tú habías supuesto que no ejercería.
Si escribes una llamada al repositorio, un registro de analítica o una navegación directamente en el cuerpo de una función @Composable, ese código se ejecutará un número de veces que no puedes predecir ni acotar: una por recomposición, y las recomposiciones dependen de decisiones internas del runtime. El síntoma típico es la petición de red que se dispara en bucle o el evento de analítica contado tres veces. Todo lo que actúa sobre el mundo tiene que salir del cuerpo y entrar en un efecto controlado, que es justo lo que estudiarás al cerrar este nivel.
Que un componible se salte la recomposición depende de que sus parámetros sean estables y comparables, no de que el usuario perciba que nada cambió. Un tipo se considera estable si el compilador puede demostrar que sus lecturas notifican cuando cambian y que su igualdad es fiable; una lista mutable, una interfaz cuya implementación no se conoce o una función creada al vuelo rompen esa demostración. Por eso los estados de MVI se modelan con data class de campos inmutables y colecciones inmutables: no es purismo, es la condición para que el runtime pueda ahorrarte trabajo.
Componer, medir, dibujar: tres fases y no una
Conviene añadir una precisión que evita malentendidos caros. Compose no ejecuta una fase sino tres, y no todas se disparan por las mismas razones. La composición produce el árbol de nodos a partir de tus funciones; la disposición mide y coloca esos nodos; el dibujo los pinta. Cuando un estado cambia, lo que se invalida depende de en qué fase se leyó ese estado: leerlo en el cuerpo de un componible invalida la composición entera de ese ámbito, mientras que leerlo dentro de una lambda que solo se evalúa al medir o al pintar invalida únicamente esa fase.
Esta es la razón técnica de un consejo que parece esotérico: para valores que cambian a alta frecuencia —el desplazamiento de una lista, el progreso de una animación, la posición de un dedo— pasa una función que produce el valor en lugar del valor ya leído. Así la lectura ocurre en la fase de disposición o de dibujo y te ahorras recomponer nada. Un fotograma con recomposición cuesta órdenes de magnitud más que un fotograma que solo se vuelve a pintar, y a sesenta fotogramas por segundo esa diferencia es exactamente la que separa una animación fluida de una a tirones.
Dos funciones puras: reducir y renderizar
Aquí es donde las piezas se juntan. MVI define la lógica como una función reductora que toma el estado actual y una intención y devuelve el estado siguiente. Compose define la pantalla como una función que toma el estado y devuelve una descripción visual. Puestas una detrás de otra, forman un bucle cerrado en el que ninguna de las dos mantiene estado propio: el estado vive en un solo sitio, viaja en una sola dirección y ambas funciones se limitan a transformarlo.
flowchart LR I[Intent del usuario] --> R[reduce estado mas intent devuelve estado nuevo] R --> S[StateFlow con el estado unico] S --> C[Composable estado devuelve pantalla] C --> I style S fill:#a6e3a1,color:#11111b style R fill:#89b4fa,color:#11111b style C fill:#cba6f7,color:#11111b
Ese diagrama tiene una propiedad que conviene subrayar: es un ciclo, pero ninguna de sus flechas es de escritura remota. La reductora no toca la pantalla, la pantalla no toca el estado y el usuario no toca ninguna de las dos: cada eslabón produce un valor que el siguiente consume. Un ciclo hecho de transformaciones y no de mutaciones se puede detener en cualquier punto, inspeccionar, grabar y reproducir, y eso es lo que hace posible el registro de estados para depurar o la reproducción de una sesión a partir de la secuencia de intenciones.
La simetría es exacta y merece detenerse en ella. La reductora es (State, Intent) -> State; la componible es State -> UI. Las dos son puras, las dos son determinísticas, las dos se pueden llamar tantas veces como haga falta con el mismo resultado. El sistema entero es la composición de esas dos funciones cerrada por el usuario, que aporta las intenciones. No hay una tercera pieza que guarde estado a escondidas, y por eso no hay dos verdades que sincronizar.
// La pantalla no sabe de dónde viene el estado ni a dónde va el evento
@Composable
fun ContadorScreen(estado: ContadorState, onIntent: (ContadorIntent) -> Unit) {
Column {
Text("Valor: ${estado.valor}")
Button(onClick = { onIntent(ContadorIntent.Incrementar) }) {
Text("Sumar")
}
}
}
Fíjate en lo que esta firma prohíbe. La pantalla no puede modificar estado.valor, porque es inmutable; no puede decidir cómo se incrementa, porque solo emite una intención; y no puede saber si detrás hay un ViewModel con Orbit, un test que le pasa estados a mano o una vista previa. Esa ignorancia es la propiedad valiosa: la función es probable, previsualizable y reutilizable precisamente porque no sabe nada de su entorno.
Hay una consecuencia metodológica que suele descubrirse tarde. Si la pantalla es una función total sobre el tipo del estado, entonces enumerar los casos del estado es enumerar las pantallas posibles, y eso convierte la revisión visual en un ejercicio finito: para cada valor legal del modelo existe una y solo una imagen, y todas se pueden generar sin ejecutar la app. Las suites de captura visual que comparan capturas de pantalla contra referencias no son viables en un modelo imperativo, donde para llegar a un estado hay que recorrer la secuencia de eventos que conduce a él; en un modelo declarativo son triviales, porque el estado se construye directamente. La testabilidad no mejora un poco: cambia de naturaleza.
Vale la pena señalar también qué no promete este modelo. Que la interfaz sea una función del estado no dice nada sobre cuánto tarda en calcularse, ni impide escribir componibles lentos, ni sustituye a medir el rendimiento. Lo que garantiza es más modesto y más valioso: que la pantalla que ves es siempre derivable del estado que hay, y por tanto que cualquier discrepancia entre lo que el usuario ve y lo que la app cree es un error localizable en un solo sitio. En el mundo imperativo esa clase de bug obligaba a reconstruir mentalmente la historia entera de mutaciones para averiguar dónde se desincronizó la pantalla; aquí basta con imprimir el estado.
// Enumerar los estados es enumerar las pantallas: cada uno es una vista previa
@Preview @Composable
private fun ContadorEnCero() = ContadorScreen(ContadorState(valor = 0), onIntent = {})
@Preview @Composable
private fun ContadorEnLimite() = ContadorScreen(ContadorState(valor = 99), onIntent = {})
Nada de lo anterior obliga a que el estado venga de Orbit, y esa independencia es justamente lo que hace la combinación robusta: la pantalla exige un valor de un tipo y una función que acepta intenciones, y le da igual quién los provea. Puedes cambiar la librería de la lógica, sustituir el ViewModel por un servidor de pruebas o renderizar la pantalla dentro de una galería de componentes sin tocar una línea de la descripción visual.
Si puedes escribir una @Preview de una pantalla pasándole un estado construido a mano, esa pantalla es una función del estado de verdad. Si para previsualizarla necesitas montar un ViewModel, inyectar un repositorio o simular una base de datos, alguien metió lógica donde solo debía haber descripción. La facilidad de la vista previa no es una comodidad de herramienta: es un indicador directo de si la separación está bien hecha.
Aquí está la idea que hay que llevarse del nivel entero, y conviene formularla despacio porque parece obvia y no lo es. Un programa imperativo de interfaz habla en imperativos porque su unidad de razonamiento es la transición: dado que el usuario pulsó esto, cambia aquello. El problema de razonar en transiciones es que su número crece con el cuadrado del número de estados, no con el número de estados: para n situaciones posibles hay del orden de n al cuadrado transiciones entre ellas, y tu código debe tener escrita cada una. Por eso las pantallas imperativas envejecen mal: cada requisito nuevo no añade un caso, añade una fila y una columna entera a una matriz que nadie tiene entera en la cabeza. El modelo declarativo hace una renuncia radical: deja de hablar de transiciones y habla solo de verdades. Para cada estado posible, esta es la pantalla. Punto. El número de cosas que debes escribir vuelve a crecer linealmente con los estados, y la diferencia entre la pantalla vieja y la nueva —que es lo verdaderamente difícil y lo que producía los bugs— la calcula el runtime, que no se olvida nunca de ocultar el spinner. MVI había hecho exactamente esa misma renuncia en la capa de la lógica: en lugar de métodos que mutan campos, una función que dado un estado y una intención devuelve el estado entero nuevo. Que Compose y MVI encajen tan bien no es afortunado, es tautológico: son la misma idea aplicada a dos capas distintas del mismo problema. Y de ahí sale la vara para medir cualquier arquitectura de interfaz que te encuentres: pregunta si te obliga a describir cambios o te permite describir verdades. Si te obliga a lo primero, estás firmando un contrato de mantenimiento cuadrático sin haberlo leído.
- Toma una pantalla del sistema de vistas con al menos cuatro widgets cuya visibilidad se controle a mano y enumera cuántas combinaciones de visibilidad existen y cuántas son legales.
- Modela esos estados legales con una
sealed interfacede forma que las combinaciones ilegales no se puedan escribir, y escribe el componible que los renderiza con unwhenexhaustivo. - Explica por qué un componible que recibe una
data classinmutable puede saltarse la recomposición y uno que recibe una lista mutable no. - Escribe la firma de una pantalla que reciba estado y emita intenciones, y argumenta qué tres cosas le quedan prohibidas por construcción.
- Justifica por qué la facilidad de escribir una vista previa sin dependencias es un indicador fiable de que la pantalla es una función pura del estado.