MVVM clásico: el estado disperso y sus grietas
El MVVM que Android popularizó reparte el estado de una pantalla en varios observables mutables e independientes, y esa dispersión es cómoda mientras la pantalla es pequeña. Esta lección analiza con precisión por qué deja de serlo: la combinatoria de estados que los tipos permiten crece de forma multiplicativa, las actualizaciones ocurren en instantes distintos y abren ventanas de incoherencia observables por la interfaz, y la responsabilidad de combinar las piezas acaba desplazada a la vista, que es justo el sitio donde menos se puede verificar. El objetivo no es descalificar MVVM sino localizar con exactitud el umbral a partir del cual su libertad se convierte en un pasivo de mantenimiento.
El MVVM que se enseñó en Android durante casi una década tiene una virtud que explica su éxito y un defecto que explica por qué existe MVI: son la misma propiedad. La virtud es que puedes exponer exactamente los trozos de estado que la pantalla necesita, cada uno con su propio tipo, cada uno actualizable por separado, sin ceremonia y sin pensar en el conjunto. El defecto es que puedes exponer exactamente los trozos de estado que la pantalla necesita, cada uno actualizable por separado, sin pensar en el conjunto. Mientras hay dos o tres piezas y una sola corrutina que las toca, esa libertad no cuesta nada y ahorra código real. Cuando hay ocho piezas, tres corrutinas concurrentes y un flujo de errores que puede llegar en cualquier momento, esa misma libertad se convierte en un espacio de estados que nadie ha enumerado y que el compilador no vigila. Esta lección mide el coste con precisión, porque el argumento a favor de MVI solo vale si el problema que resuelve se puede cuantificar.
- Analizar el contrato real del
MVVMclásico: varios observables mutables y varios métodos de entrada. - Cuantificar la explosión combinatoria de estados que permiten los tipos cuando el estado está disperso.
- Identificar la ventana de incoherencia que abre toda actualización no atómica de varios observables.
- Localizar el umbral concreto a partir del cual la dispersión deja de ser cómoda y pasa a ser un pasivo.
El contrato y su libertad
El MVVM clásico define un contrato de dos caras, ambas anchas. Del lado de la salida, un observable por cada trozo de pantalla. Del lado de la entrada, un método por cada cosa que el usuario puede hacer. Ninguna de las dos caras está acotada por nada: crecen a medida que la pantalla crece.
class BusquedaViewModel(private val repo: Repo) : ViewModel() {
val consulta = MutableStateFlow("")
val cargando = MutableStateFlow(false)
val resultados = MutableStateFlow<List<Item>>(emptyList())
val error = MutableStateFlow<String?>(null)
val vacio = MutableStateFlow(false)
fun onConsultaCambiada(texto: String) { consulta.value = texto }
fun onBuscar() { /* toca cargando, luego resultados o error, luego vacio */ }
fun onReintentar() { /* vuelve a tocar los mismos, en otro orden */ }
fun onLimpiar() { /* y alguno se olvida de limpiar */ }
}
La comodidad es evidente y no conviene minimizarla: añadir un dato a la pantalla cuesta una línea, y no obliga a tocar ningún tipo compartido ni a revisar ningún reductor. Es una arquitectura de coste marginal casi nulo por cada adición individual. El problema es que el coste no vive en la adición: vive en la interacción entre lo añadido y todo lo demás, y esa interacción no aparece en ningún sitio del código.
Del lado de la vista, la consecuencia inmediata es que la interfaz recolecta varias fuentes y tiene que decidir qué pinta cuando se contradicen.
@Composable
fun BusquedaScreen(vm: BusquedaViewModel) {
val cargando by vm.cargando.collectAsStateWithLifecycle()
val resultados by vm.resultados.collectAsStateWithLifecycle()
val error by vm.error.collectAsStateWithLifecycle()
val vacio by vm.vacio.collectAsStateWithLifecycle()
// Aqui vive una decision de negocio disfrazada de detalle de presentacion
when {
cargando -> Indicador()
error != null -> MensajeError(error!!)
vacio -> SinResultados()
else -> Lista(resultados)
}
}
Ese bloque de condiciones tiene más importancia de la que aparenta: es el sitio donde el equipo decide, sin documentarlo en ninguna parte, que la carga tiene prioridad sobre el error y el error sobre el vacío. Esa política no está en el ViewModel, no está en ningún tipo, no está en ningún test, y cambia si alguien reordena las ramas del when en una revisión apresurada. Es lógica de aplicación que ha terminado alojada en la capa que peor se verifica.
El código de arriba no es un ejemplo mal escrito para ganar un argumento: es el estilo que la documentación oficial de Android enseñó durante años y que sigue apareciendo en bases de código en producción. Un MVVM moderno bien hecho puede exponer un solo StateFlow de un tipo compuesto y quedar muy cerca de MVI, y esa variante se analiza en la lección siguiente. Lo que aquí se examina es el MVVM disperso, que es el que la mayoría de los equipos tiene realmente escrito.
La combinatoria que nadie enumeró
El espacio de estados que un tipo permite es el producto de los espacios de sus partes, no la suma. Con el ViewModel de arriba, los tipos declarados permiten un cargando verdadero con resultados llenos y error presente y vacio verdadero, todo a la vez. Es un estado sin sentido que ninguna persona diseñó, que ningún test cubre y que nada en el código impide.
La cuenta es incómoda de hacer y por eso casi nadie la hace. Dos valores booleanos independientes permiten cuatro combinaciones; tres permiten ocho; cuatro permiten dieciséis. Si además hay una lista que puede estar vacía o llena y un error que puede ser nulo o no, el producto se dispara. De ese espacio, el número de combinaciones que corresponden a una pantalla que alguien diseñó suele ser cuatro o cinco: cargando, contenido, vacío, error. Todo lo demás es superficie de fallo.
// El mismo estado, modelado como tipo suma: el espacio se reduce a lo diseñado.
sealed interface Contenido {
data object Cargando : Contenido
data class Exito(val items: List<Item>) : Contenido
data object Vacio : Contenido
data class Error(val mensaje: String) : Contenido
}
La diferencia entre las dos formulaciones no es de gusto ni de elegancia. En la primera, los estados imposibles son representables y hay que evitarlos con disciplina; en la segunda, no son representables y el compilador lo garantiza. La disciplina se olvida bajo presión de entrega; el compilador no.
Hay un detalle de la aritmética que merece énfasis porque invierte la intuición habitual. Añadir una propiedad a una pantalla se percibe como una adición, y por eso se aprueba en revisión sin discusión: es una línea, no rompe nada, no toca a nadie. Pero en términos del espacio de estados no es una adición sino una multiplicación, y multiplicar un espacio de dieciséis combinaciones por dos deja treinta y dos, de las cuales las nuevas dieciséis no han sido examinadas por nadie. El coste real de esa línea no es la línea: es la mitad de un espacio de comportamientos que acaba de aparecer sin dueño.
La ventana de incoherencia
El segundo problema es temporal y más difícil de ver leyendo el código, porque solo existe entre dos instrucciones. Cuando una corrutina termina y tiene que reflejar el resultado, no puede escribir varios observables a la vez: los escribe uno detrás de otro, y entre una asignación y la siguiente hay un intervalo en el que la interfaz puede leer.
fun onBuscar() {
cargando.value = true
viewModelScope.launch {
val r = repo.buscar(consulta.value)
cargando.value = false // instante A: cargando ya es falso
resultados.value = r.items // instante B: resultados aun vacios en A
vacio.value = r.items.isEmpty() // instante C: vacio aun falso en A y B
}
}
Entre el instante A y el instante C existe al menos una composición en la que la pantalla muestra ni cargando, ni resultados, ni vacío: la nada. En una pantalla rápida se percibe como un parpadeo; en una lenta, como un fallo reportado por un usuario que jura haber visto la lista vacía. Y el orden de las tres asignaciones cambia qué se ve en el intervalo, lo que significa que un detalle sin importancia aparente —el orden de tres líneas— es en realidad una decisión de interfaz.
sequenceDiagram participant L as Logica participant O as Observables participant V as Vista L->>O: cargando pasa a falso O-->>V: recomposicion con pantalla vacia L->>O: resultados recibe items O-->>V: recomposicion con lista L->>O: vacio pasa a falso O-->>V: recomposicion final
Con varios observables el problema no se elimina combinándolos en la vista, porque cada recolección es independiente y el planificador decide cuándo entrega cada una. Se puede mitigar con operadores que unen flujos, pero eso es precisamente empezar a construir a mano el estado único que MVI propone de entrada.
// La mitigacion habitual: combinar los flujos en uno solo.
val ui: StateFlow<BusquedaUi> = combine(cargando, resultados, error) { c, r, e ->
BusquedaUi(c, r, e) // el estado unico, reconstruido a mano y a destiempo
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), BusquedaUi())
Ese combine es una confesión interesante: el equipo que lo escribe ha reconocido que la pantalla necesita un valor único y coherente, y lo está fabricando aguas abajo en lugar de mantenerlo aguas arriba. Funciona, pero conserva las fuentes mutables independientes y por tanto sigue permitiendo que la lógica las deje en combinaciones sin sentido; lo único que ha cambiado es que ahora la incoherencia llega empaquetada en un solo objeto.
La segunda razón por la que la ventana es difícil de ver es que suele estar cerrada en el entorno de desarrollo. Con red rápida o con datos falsos, las tres asignaciones ocurren en el mismo fotograma y el intervalo no llega a pintarse nunca. El fallo aparece en dispositivos lentos, con red degradada y con listas grandes, es decir, exactamente en el perfil de usuario que menos se parece al del programador que escribió el código.
Dónde falla a escala
Los dos problemas anteriores son inocuos en pequeño y multiplicativos en grande, y conviene nombrar los tres puntos exactos donde una base de código empieza a pagar.
La vista hereda la lógica
Cuando hay cinco observables, alguien tiene que decidir qué se pinta si dos se contradicen. Esa decisión acaba en la función componible, que es el sitio donde peor se prueba.
Los olvidos son silenciosos
Cada método nuevo debe acordarse de tocar todos los observables afectados. Olvidar limpiar el error al reintentar no falla en compilación: falla en producción, ante un usuario.
Los tests dejan de ser suficientes
Probar cada observable por separado no cubre la coherencia del conjunto. Los fallos reales aparecen en combinaciones que ningún test individual visita.
Hay además un síntoma organizativo que suele preceder al técnico. En cuanto la pantalla tiene más de cuatro o cinco observables, la discusión en las revisiones de código deja de ser sobre lo que el cambio hace y pasa a ser sobre lo que el cambio olvida. Cuando un equipo empieza a escribir comentarios del tipo acuérdate de poner el error a nulo aquí, ese equipo está sosteniendo con protocolo humano una garantía que un tipo podría sostener sola.
Lo que hace peligroso al MVVM disperso no es ninguno de sus defectos individuales, sino la forma en que su coste escala frente a la forma en que su comodidad escala. La comodidad es lineal: añadir la novena propiedad cuesta exactamente lo mismo que costó añadir la primera, una línea. El coste es multiplicativo: la novena propiedad no añade un estado posible más, sino que duplica —o algo peor— el número total de combinaciones que los tipos permiten, y por tanto el número de configuraciones que nadie ha mirado nunca. Un sistema donde el beneficio crece sumando y el riesgo crece multiplicando tiene siempre un punto de cruce, y el problema es que ese punto no se anuncia: no hay un día en que el equipo note que lo ha pasado. Se percibe indirectamente, meses después, en la forma de fallos que solo aparecen bajo red lenta, en tiempos de revisión que se alargan, en la sensación difusa de que tocar esa pantalla da miedo. Nótese que esto es exactamente la anatomía de una deuda técnica bien definida: se contrae con decisiones individualmente razonables, cada una defendible en su revisión, y se cobra de golpe en un momento que no guarda relación causal visible con ninguna de ellas. De ahí la lección que trasciende a Android y a estos patrones: cuando una estructura permite representar estados que el dominio no admite, no has escrito un programa incorrecto, has escrito un programa cuyo espacio de comportamientos es mayor que el espacio de comportamientos que entiendes. La diferencia entre esos dos espacios es tu superficie de fallo, y la única forma honesta de reducirla no es probar más, porque no se puede probar un espacio que no se ha enumerado, sino estrechar el tipo hasta que ambos espacios coincidan. MVI no es más que la aplicación sistemática de esa idea a la capa de presentación, y quien la entiende deja de ver MVVM como un patrón rival y empieza a verlo como el mismo diseño con el tipo sin apretar.
- Toma una pantalla real escrita en
MVVMdisperso y lista todos sus observables con sus tipos exactos. - Calcula cuántas combinaciones permiten esos tipos y cuántas corresponden a pantallas que alguien diseñó de verdad; la diferencia es tu superficie de fallo.
- Localiza un método que actualice tres o más observables en secuencia y describe qué se ve en pantalla entre la primera y la última asignación.
- Reordena esas asignaciones y comprueba si el parpadeo intermedio cambia; documenta que el orden es una decisión de interfaz, no un detalle.
- Reescribe el conjunto como un solo tipo compuesto con el contenido modelado como tipo suma y vuelve a calcular las combinaciones representables.