La capa de UI: UiState, eventos hacia arriba y el ViewModel como frontera
La capa de presentación de la arquitectura recomendada se sostiene sobre una idea que parece trivial hasta que se aplica con rigor: la interfaz es una función del estado, y por tanto el estado tiene que existir como un objeto con nombre antes de que exista la pantalla que lo dibuja. Esta lección construye esa capa pieza a pieza: cómo se modela el estado de una pantalla y qué se gana o se pierde al elegir entre un objeto único con banderas o una jerarquía cerrada de casos, dónde vive el estado y por qué el ViewModel es una frontera y no un cajón de sastre, cómo suben los eventos sin que la pantalla conozca lo que hay debajo, y por qué los avisos de una sola vez son el punto donde casi todos los equipos rompen el modelo sin darse cuenta.
La capa de UI de la arquitectura recomendada descansa sobre una afirmación que suena a perogrullada y que casi ninguna base de código respeta: lo que se ve en la pantalla debe ser una función pura del estado de la aplicación en ese instante. Si eso es cierto, entonces el estado tiene que existir como un valor con nombre, con tipo y con historia propia, y la pantalla se convierte en una proyección de ese valor. Si no lo es, el aspecto de la interfaz depende de la secuencia de sucesos que la llevó hasta ahí, y esa dependencia es exactamente el material del que están hechos los errores que solo ocurren cuando alguien gira el dispositivo dos veces mientras carga. La diferencia entre las dos situaciones no es de estilo: es la diferencia entre un sistema que se puede razonar y uno que se depura por acumulación de casos.
- Modelar el estado de una pantalla como un valor explícito y elegir entre representación plana o cerrada con criterio.
- Situar cada pieza de estado en su lugar correcto entre la composición, el titular de estado y el
ViewModel. - Diseñar la superficie de eventos de una pantalla para que suban sin acoplarla a las capas inferiores.
- Tratar los avisos de una sola vez sin romper la propiedad de que la interfaz es una función del estado.
El estado primero, la pantalla después
Conviene precisar qué significa aquí estado, porque el término se usa para tres cosas distintas y mezclarlas garantiza confusión. Está el estado de la aplicación, que vive en la capa de datos y es lo que la aplicación sabe del mundo. Está el estado de la interfaz, que es la proyección de lo anterior lista para dibujarse. Y está el estado de un componente concreto, que es memoria local sin ningún significado fuera de él. Esta lección trata del segundo, y buena parte de los errores de diseño en esta capa consisten en poner algo del primero o del tercero donde va el segundo.
Escribir una pantalla en el orden habitual, primero los componentes y después las variables que hacen falta, produce estados imposibles con una fiabilidad notable. Aparecen dos banderas que no deberían ser ciertas a la vez, una lista vacía que significa dos cosas distintas, un mensaje de error que sobrevive a la recarga que debía borrarlo. Ninguno de esos defectos es un fallo de programación: son el resultado de haber dejado que la forma del estado emergiera de las necesidades de dibujo en lugar de diseñarla.
El orden correcto es el inverso, y su virtud principal es que obliga a hablar del producto antes de hablar de píxeles. Antes de escribir un solo Composable, se enumera qué puede estar ocurriendo en esa pantalla y se le da nombre. Ese ejercicio suele revelar que la pantalla tenía menos situaciones de las que su código sugería, o que tenía una que nadie había contemplado.
data class DetalleUiState(
val articulo: Articulo? = null,
val cargando: Boolean = false,
val error: MensajeError? = null,
val enFavoritos: Boolean = false,
)
Esa forma plana, un objeto de datos con campos opcionales, es la que recomienda por defecto la guía oficial y funciona bien mientras las combinaciones sean genuinamente independientes. Su ventaja es que admite estados mixtos con naturalidad: hay artículo antiguo visible y a la vez está cargando el nuevo, que es justamente lo que quiere ver el usuario durante un refresco.
La alternativa es una jerarquía cerrada donde cada situación es un tipo distinto, y su ventaja es que hace imposible representar lo imposible: no existe forma de construir un estado que sea a la vez error y contenido.
sealed interface DetalleUiState {
data object Cargando : DetalleUiState
data class Contenido(val articulo: Articulo, val refrescando: Boolean) : DetalleUiState
data class Fallo(val mensaje: MensajeError) : DetalleUiState
}
El criterio para elegir no es de gusto. Si las combinaciones de tu pantalla son casi todas legítimas, la forma plana cuesta menos y se lee mejor. Si la mayoría de combinaciones son absurdas y solo unas pocas tienen sentido, la forma cerrada convierte esa restricción en algo que el compilador vigila por ti. Y hay una tercera opción infravalorada: un campo cerrado dentro de un objeto plano, que separa lo excluyente de lo ortogonal sin obligar a duplicar todo lo demás en cada rama.
Sea cual sea la forma elegida, hay una regla que las atraviesa a las dos: el estado contiene lo que la pantalla necesita mostrar, ya traducido, y no las materias primas con las que habría que calcularlo. Si un Composable recibe un importe en céntimos y una configuración regional para formatearlo él mismo, esa lógica está fuera de todo alcance de prueba razonable y se duplicará en cuanto el mismo importe aparezca en otro sitio. El estado debería llevar el texto ya listo, o al menos un tipo que solo admita una representación.
La contrapartida es no pasarse: un estado que contiene cadenas ya traducidas obliga a que la traducción ocurra en el ViewModel, y eso reintroduce el conocimiento de recursos en una clase que no debería tenerlo. El punto de equilibrio habitual es un tipo intermedio que identifica el mensaje y sus argumentos sin resolverlo, dejando la resolución a la pantalla, que es la única que sabe en qué idioma está el usuario en este instante.
Multiplica las combinaciones que permite tu modelo actual y marca cuántas tienen sentido. Cuatro banderas booleanas describen dieciséis situaciones; si solo cinco son legítimas, tu modelo autoriza once bugs que ninguna prueba cubrirá porque nadie sabe que existen. Ese cálculo, que ocupa dos minutos en una servilleta, decide la representación mejor que cualquier preferencia estética.
Dónde vive cada cosa
El estado no vive todo en el mismo sitio, y la guía oficial distingue con cuidado dos categorías que conviene no mezclar. El estado de la interfaz es el que describe qué se muestra, y su dueño natural es el ViewModel cuando depende de la lógica de negocio. El estado del elemento es el que pertenece a un componente concreto y a nadie más: si un desplegable está abierto, la posición del desplazamiento, el contenido de un campo de texto antes de validarse.
Esa distinción no es un detalle organizativo: determina qué sobrevive a qué. El estado del elemento se pierde cuando el componente sale de la composición, y casi siempre eso es lo correcto. El estado de la interfaz sobrevive a los cambios de configuración porque vive donde vive el ViewModel. Colocar mal una pieza produce el fallo característico de un filtro que se reinicia al girar o de un panel que se recuerda cuando nadie quería que lo hiciera.
Subir todo al ViewModel porque suena más arquitectónico produce clases que sabían demasiado de la pantalla y que se rompían cada vez que el diseño cambiaba. Un ViewModel que guarda si un panel está expandido ha adquirido conocimiento sobre la disposición visual, y esa es exactamente la dependencia que las capas querían evitar, solo que orientada en la dirección menos vigilada.
flowchart TD A[Composable] -->|evento del usuario| B[ViewModel] B -->|llamada| C[Capa de datos] C -->|flujo de datos| B B -->|UiState observable| A D[Estado del elemento] --- A style B fill:#f9e2af,color:#11111b style A fill:#89b4fa,color:#11111b
La prueba para decidir es preguntar si alguien fuera de la pantalla necesita conocer ese valor o reaccionar a él. Si el filtro seleccionado determina qué se pide al repositorio, es estado de interfaz y sube. Si el panel plegado no afecta a nada más que a los píxeles, es estado del elemento y se queda abajo, donde además sobrevive a los cambios de configuración por sus propios medios sin que nadie escriba código de guardado.
Entre ambos extremos hay una pieza que la guía nombra explícitamente y que muchos proyectos ignoran: el titular de estado de interfaz, una clase normal, sin ciclo de vida propio, que agrupa la lógica de presentación de una pantalla compleja. Sirve para lo que no es lógica de negocio pero tampoco cabe en un componente: coordinar varios estados de desplazamiento, decidir si mostrar una barra según el ancho disponible, recordar qué pestaña estaba activa. Cuando una pantalla tiene esa clase de complejidad, meterla en el ViewModel lo ensucia y dejarla suelta en la composición la vuelve imposible de probar.
El ViewModel es una frontera
La descripción más útil del ViewModel no es la que aparece en su documentación de API sino esta: es el punto donde el lenguaje del dominio se traduce al lenguaje de la pantalla, y el único punto donde ambos se tocan. Todo lo que entra son modelos de dominio; todo lo que sale es UiState. Esa traducción es su razón de ser, y evaluar un ViewModel consiste en preguntarse si está traduciendo o si está haciendo otra cosa.
De esa definición se sigue también quién debe observar y cómo. La pantalla se suscribe al estado respetando el ciclo de vida, de modo que deja de recoger cuando no está visible y el trabajo aguas abajo se detiene solo. Ese detalle, que parece de higiene, es lo que evita que una aplicación con diez pantallas en la pila mantenga diez suscripciones activas consumiendo batería en segundo plano.
La consecuencia práctica es una lista corta de lo que no le corresponde. No le corresponde saber cómo se obtiene un dato, porque eso es de la capa de datos. No le corresponde formatear textos con recursos ni conocer dimensiones, porque eso es de la pantalla. No le corresponde acumular la lógica de tres pantallas porque comparten repositorio. Y muy en particular no le corresponde exponer los modelos del dominio tal cual, delegando en cada Composable la decisión de cómo se convierte una fecha o un importe en algo legible.
class DetalleViewModel(
private val repo: ArticuloRepository,
handle: SavedStateHandle,
) : ViewModel() {
private val id: ArticuloId = checkNotNull(handle["articuloId"])
val estado: StateFlow<DetalleUiState> = repo.observar(id)
.map { articulo -> DetalleUiState(articulo = articulo) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DetalleUiState(cargando = true))
fun refrescar() = viewModelScope.launch { repo.refrescar(id) }
}
Repara en que la pantalla observa un único valor y llama a un único método. Esa asimetría, estado hacia abajo y eventos hacia arriba, es lo que la guía llama flujo unidireccional de datos, y su virtud es que el ciclo tiene un solo sentido: no hay dos caminos por los que un valor pueda cambiar, así que no hay carreras entre ellos.
Los eventos que suben merecen tanto diseño como el estado que baja, y suelen recibir mucho menos. Una pantalla que expone diez funciones sueltas hacia arriba obliga a cualquier componente intermedio a transportarlas todas, y basta con añadir la undécima para tocar cinco firmas. Nombrar los eventos en términos de lo que el usuario hizo, y no de lo que el sistema debe hacer, evita además que la pantalla acabe dando órdenes: el ViewModel debe recibir que se pulsó el botón de guardar, no la orden de escribir en la base de datos.
El síntoma de un ViewModel que dejó de ser frontera es que su nombre ya no dice de qué pantalla es, o que dos pantallas comparten uno porque comparten datos. Compartir datos es trabajo del repositorio, que ya garantiza la coherencia; compartir un ViewModel para eso acopla dos pantallas a través de su presentación y hace que cambiar una rompa la otra. La excepción legítima es un ámbito de navegación, donde varias pantallas de un mismo flujo comparten deliberadamente un estado que solo existe mientras dura ese flujo.
Los avisos de una sola vez, donde todo el mundo tropieza
Un beneficio colateral de esa disciplina, y que rara vez se anticipa, es que las vistas previas y los catálogos de componentes se vuelven triviales. Si la pantalla es una función de un UiState, entonces cada situación interesante se previsualiza construyendo el valor correspondiente, sin ejecutar lógica ni simular repositorios. Un equipo que puede ver de un vistazo sus doce estados posibles diseña mejor los tres que nadie había mirado nunca.
Hay una categoría de cosas que la pantalla necesita y que encaja mal en el modelo: mostrar un aviso efímero, navegar a otro destino, disparar una vibración. No son estado porque no describen una situación persistente, y sin embargo alguien tiene que decidirlas. La solución ingenua, un campo del estado que se pone y se limpia, produce el defecto clásico del mensaje que reaparece al rotar, porque el estado sobrevive a la recreación y el efecto ya se había consumido.
La solución que la propia guía recomienda hoy es incómoda y correcta: convertir el aviso en estado con una marca de consumo explícita, de modo que la pantalla informe de que ya lo mostró. El aviso deja de ser un suceso y pasa a ser un hecho del estado que tiene un ciclo de vida completo, con nacimiento y con muerte declarada.
data class DetalleUiState(
val articulo: Articulo? = null,
val avisos: List<Aviso> = emptyList(), // se muestran y se confirman
)
fun avisoMostrado(id: AvisoId) = _estado.update { actual ->
actual.copy(avisos = actual.avisos.filterNot { it.id == id })
}
La incomodidad de esa solución es real y conviene no disimularla: obliga a que la pantalla informe de vuelta, añade un método por cada tipo de aviso y hace que el estado crezca. Lo que se compra a cambio es que un aviso jamás se pierda por una recreación ni se muestre dos veces, y que el comportamiento sea reproducible en un test escribiendo un estado concreto en lugar de coreografiando una secuencia de emisiones.
La navegación merece una nota aparte porque es la tentación más fuerte. Un ViewModel que emite órdenes de navegación ha adquirido conocimiento del grafo de destinos, es decir, de la estructura de la interfaz, y esa dependencia impide reutilizar la pantalla en otro contexto. La alternativa que sostiene el modelo es que el ViewModel exponga el hecho, por ejemplo que el pedido se completó, y que sea la capa de navegación quien decida qué se hace con esa información.
Merece la pena entender qué se compra realmente al pagar el precio de modelar el estado con esta disciplina, porque el precio es visible desde el primer día y el beneficio tarda meses en aparecer. Lo que se compra es la capacidad de responder a la pregunta por qué la pantalla está así mirando un único valor, en lugar de reconstruyendo mentalmente la secuencia de sucesos que llevó hasta ese punto. En una interfaz sin estado explícito, el aspecto actual es el resultado acumulado de una historia: se mostró un diálogo, se ocultó, llegó una respuesta tardía, el usuario giró el dispositivo, una animación quedó a medias. Depurar en ese régimen consiste en reproducir la historia, y las historias no siempre se dejan reproducir, sobre todo cuando dependen de latencias de red o de decisiones del sistema sobre cuándo destruir una actividad. En una interfaz que es función de un estado explícito, en cambio, el aspecto actual es la imagen de un valor que puedes imprimir, guardar en un informe de fallo, reconstruir en un test o enviar desde el dispositivo del usuario que reportó el problema. La historia deja de importar porque el presente es completo. Esa es también la razón profunda de que los avisos de una sola vez sean tan incómodos y de que la incomodidad sea informativa en lugar de accidental. Un suceso que se dispara y desaparece es, por construcción, un fragmento de historia que no está representado en el presente, y por tanto un agujero exacto en la propiedad que hace útil a todo el modelo. Cada vez que un equipo introduce un canal de sucesos para esquivar la molestia, no está simplificando su código: está reintroduciendo la dependencia de la secuencia justo en el punto donde el sistema se comunica con el usuario, que es donde los fallos se ven. La regla que se sigue de aquí es tan simple como impopular: si algo tiene que ocurrir en la pantalla, tiene que existir en el estado el tiempo suficiente para que alguien pueda observarlo, contarlo y confirmarlo. Todo lo que ocurre sin dejar rastro en el estado es, literalmente, imposible de razonar.
- Toma una pantalla existente, enumera todas sus variables de estado y calcula cuántas combinaciones permite. Marca las imposibles y decide si la forma plana o la cerrada las elimina mejor.
- Clasifica cada pieza de estado en estado de interfaz o estado del elemento, y baja al componente todo lo que el
ViewModelno necesitaba saber. - Localiza cualquier modelo de dominio que llegue crudo a un
Composabley muévelo detrás de una traducción explícita en elViewModel. - Busca un canal de sucesos de una sola vez en tu proyecto, reprodúcelo perdiendo un mensaje al rotar y conviértelo en estado con confirmación de consumo.
- Escribe un test que fije un
UiStateconcreto y verifique la pantalla resultante sin ejecutar ninguna lógica de negocio. Si necesitas simular una secuencia, el estado todavía no es completo.