Lo que MVI añade y lo que cobra
MVI no aporta capacidades nuevas sobre MVVM: aporta dos prohibiciones. Una sola salida, un estado inmutable que se reemplaza entero, y una sola entrada, un canal por el que la interfaz envía datos en lugar de llamar a métodos variados. Esta lección examina qué compra cada una de esas restricciones —atomicidad de la publicación, imposibilidad de estados incoherentes, trazabilidad y reproducibilidad de la interacción— y a continuación pasa la factura sin adornos: ceremonia por pantalla, indirección entre el gesto y la lógica, y un coste de cambio que crece cuando el estado es grande. El objetivo es que la elección se haga con las dos columnas del balance a la vista, no con una sola.
Hay una manera perezosa de defender MVI que consiste en enumerar sus ventajas y callar el precio, y hay una manera perezosa de atacarlo que consiste en llamarlo ceremonia y callar lo que esa ceremonia compra. Ninguna de las dos sirve para decidir nada. Lo honesto es reconocer primero un hecho estructural que suele pasarse por alto: MVI no permite hacer nada que MVVM no permitiera. No hay una sola pantalla expresable en MVI que no fuese expresable antes. Todo lo que MVI aporta son caminos cerrados, y todo lo que cobra es la incomodidad de no poder tomarlos cuando tomarlos habría sido más corto. Esta lección separa con cuidado las dos columnas del balance. Primero qué compran exactamente las dos prohibiciones —la salida única y la entrada única—, formuladas como propiedades verificables y no como eslóganes. Después qué cuesta esa compra, medido en líneas, en indirección y en fricción diaria, porque un argumento arquitectónico que no incluye su propia factura no es un argumento.
- Formular las dos prohibiciones de
MVIy verificar que ninguna añade capacidad expresiva. - Determinar qué propiedad concreta compra la salida única y por qué la atomicidad no se puede simular con varios observables.
- Determinar qué propiedad concreta compra la entrada única y qué habilita convertir la interacción en dato.
- Cuantificar la factura de ceremonia, indirección y coste de cambio que
MVIimpone por pantalla.
Las dos prohibiciones
El contrato completo de MVI cabe en dos líneas de una clase, y todo lo demás son consecuencias.
class BusquedaViewModel(private val repo: Repo) : ViewModel() {
private val _state = MutableStateFlow(BusquedaState())
val state: StateFlow<BusquedaState> = _state.asStateFlow() // unica salida
fun onIntent(intent: BusquedaIntent) { /* unica entrada */ }
}
Conviene comprobar la afirmación de que no hay capacidad nueva, porque es la base de todo el análisis. Cualquier pantalla escrita en MVI se puede reescribir en MVVM disperso sin perder nada: basta con descomponer el estado en propiedades separadas y sustituir cada rama del reductor por un método. La traducción inversa también existe y también es mecánica. Dos formulaciones intertraducibles tienen exactamente el mismo poder expresivo, y por tanto la diferencia entre ellas no puede estar en lo que permiten hacer, solo en lo que permiten hacer mal.
La primera prohibición dice que la interfaz solo puede leer un valor, y que ese valor se reemplaza entero o no se reemplaza. La segunda dice que la interfaz solo puede escribir enviando un dato por un único método. Nótese que ambas son negativas: no hay ninguna capacidad nueva aquí, solo dos puertas cerradas. La superficie de contacto entre la vista y la lógica pasa de ser proporcional al tamaño de la pantalla a ser constante.
La propiedad de la que se derivan casi todas las ventajas de MVI se puede enunciar en una frase: en cualquier instante observable, el valor que la interfaz lee es un estado que la lógica publicó a propósito. Nunca es una mezcla de dos publicaciones, nunca es un intermedio accidental. Con varios observables ese invariante no se puede garantizar por construcción, solo aproximar con cuidado. Con un solo valor inmutable se cumple sin esfuerzo, porque publicar es una sola asignación de referencia.
Lo que compra la salida única
La primera compra es la atomicidad. Como el estado es un valor inmutable y publicarlo es sustituir una referencia por otra, no existe ningún instante en el que la mitad del cambio esté visible. Todo el problema de la ventana de incoherencia de la lección anterior desaparece, no porque se gestione mejor, sino porque deja de poder ocurrir.
// Un solo cambio, atomico: o se ve el estado viejo entero, o el nuevo entero.
_state.update { actual ->
actual.copy(contenido = if (items.isEmpty()) Contenido.Vacio else Contenido.Exito(items))
}
Es importante ver por qué esa atomicidad no se puede reproducir con varios observables por mucho cuidado que se ponga. El motivo no es de disciplina sino de mecanismo: cada observable notifica a sus recolectores en el momento en que se le escribe, y no existe forma de pedirle a dos observables que notifiquen a la vez. Se puede acortar el intervalo, se pueden reordenar las escrituras para que el estado intermedio sea menos feo, pero el intervalo sigue ahí. Con un solo valor no hay intervalo porque no hay dos escrituras.
La segunda compra es la reducción del espacio de estados. Al haber un único tipo que describe la pantalla completa, ese tipo se puede modelar con precisión —tipos suma para lo que se excluye mutuamente, campos obligatorios donde el dominio los exige— y el compilador pasa a rechazar las configuraciones que el dominio no admite. Es la diferencia entre confiar en que nadie ponga cargando y error a la vez y hacer que esa frase ni siquiera se pueda escribir.
La tercera compra es la comparabilidad. Un estado que es un valor con igualdad estructural se puede comparar con el anterior, y de ahí salen tres cosas prácticas: recomposiciones que se saltan cuando nada relevante cambió, tests que afirman sobre un valor completo en lugar de sobre cinco propiedades sueltas, y registros de depuración donde cada línea es la fotografía entera de la pantalla.
// La comprobacion afirma sobre el estado entero, no sobre propiedades sueltas.
@Test fun buscar_pasa_a_cargando_sin_tocar_la_consulta() {
val inicial = BusquedaState(consulta = "kotlin", contenido = Contenido.Vacio)
val siguiente = reduce(inicial, BusquedaIntent.Buscar)
assertEquals(inicial.copy(contenido = Contenido.Cargando), siguiente)
}
Nótese la diferencia de fuerza de esa afirmación frente a comprobar propiedad por propiedad. Al comparar el estado completo contra un valor esperado, el test no solo verifica lo que debía cambiar: verifica también que nada más cambió. Con observables sueltos esa segunda garantía exige recordar comprobar explícitamente cada uno de los demás, y precisamente los que nadie recuerda comprobar son los que se rompen.
Hay una cuarta compra menos citada: la persistencia se vuelve trivial. Un estado que es un valor serializable se guarda entero en SavedStateHandle con una sola operación y se restaura igual. Con el estado repartido en cinco observables hay que guardar y restaurar cinco veces, acordarse de todos al añadir el sexto, y aceptar que la restauración tampoco es atómica.
Lo que compra la entrada única
Conviene tener presente que estas cuatro compras las hace ya el MVVM con estado unificado, sin necesidad de cerrar la entrada. Todo lo que sigue es lo que aporta el segundo paso, y es lo que de verdad separa a MVI de su vecino más cercano.
La entrada única suele defenderse peor que la salida, y sin embargo es la que habilita las propiedades más difíciles de conseguir por otros medios. Su efecto real es convertir la interacción en dato: en lugar de una llamada a un método, que es un evento que ocurre y desaparece, cada gesto del usuario se materializa en un valor con nombre y con tipo.
sealed interface BusquedaIntent {
data class ConsultaCambiada(val texto: String) : BusquedaIntent
data object Buscar : BusquedaIntent
data object Reintentar : BusquedaIntent
}
La diferencia entre un método y un dato parece filosófica hasta que se intenta hacer algo con la interacción. Un método invocado deja de existir en cuanto retorna: no hay ningún objeto que represente el hecho de que el usuario pulsó buscar, solo hubo un salto en la pila de llamadas. Un intent, en cambio, es un valor que existe antes de aplicarse, que se puede inspeccionar mientras viaja y que sigue existiendo después si alguien lo guardó.
Un dato se puede guardar, registrar, serializar, encolar, filtrar, contar y reproducir; una llamada a un método no. De ahí salen cuatro capacidades que en MVVM exigen infraestructura ad hoc y aquí son consecuencia del tipo. Un registro de interacción completo se obtiene interceptando un solo punto. Un fallo se reproduce plegando la lista de intents guardada sobre el estado inicial. Las políticas transversales —limitar frecuencia, descartar duplicados, exigir autenticación— se aplican en un sitio y valen para toda la pantalla. Y la exhaustividad del when obliga a decidir explícitamente qué pasa con cada interacción cuando se añade una nueva.
flowchart LR V[Vista] -->|un intent que es dato| E[onIntent] E --> R[reduce puro] R --> S[un estado inmutable] S -->|se lee entero| V E -.->|se puede registrar| L[Registro reproducible] style S fill:#a6e3a1,color:#11111b style E fill:#f9e2af,color:#11111b
// Una politica transversal escrita una vez y valida para toda la pantalla.
fun onIntent(intent: BusquedaIntent) {
registro.anotar(intent) // trazabilidad completa
if (intent is BusquedaIntent.Buscar && yaEnVuelo) return // una sola puerta que cerrar
_state.update { reduce(it, intent) }
}
Ese guardado contra la doble pulsación es un buen ejemplo de lo que la entrada única regala. Con métodos separados, la protección hay que repetirla en cada método que la necesite y acordarse de añadirla a los que vengan después; con un único punto de entrada, se escribe una vez y cubre por construcción todo lo que llegue en el futuro. Lo mismo vale para el registro, la limitación de frecuencia y la comprobación de permisos.
Hay una consecuencia menos citada y bastante valiosa: la entrada única hace que el conjunto de cosas que el usuario puede hacer en una pantalla sea legible en un solo sitio, de forma exhaustiva. En MVVM esa lista está implícita en los métodos públicos del ViewModel, mezclada con métodos internos y sin garantía de completitud. En MVI es un tipo sellado que se lee en diez segundos.
La factura, sin adornos
Ahora la otra columna, que es la que suele faltar en los artículos entusiastas. MVI cobra, y cobra en tres monedas distintas. Ninguna de las tres es un defecto corregible con mejor diseño: las tres son la sombra directa de las prohibiciones, y quien promete MVI sin ellas está vendiendo el patrón, no explicándolo.
Ceremonia por pantalla
Cada pantalla exige un tipo de estado, un tipo sellado de intents, un reductor y a menudo un tipo de efectos. Son entre cuarenta y ochenta líneas de andamiaje antes de escribir la primera regla de negocio.
Indirección
Del gesto a la lógica hay tres saltos: la vista emite un intent, el despachador lo enruta, el reductor lo aplica. Seguir un flujo exige abrir tres sitios en lugar de leer un método.
Coste de cambio
Añadir un dato obliga a tocar el estado, el intent, el reductor y la vista. Lo que en MVVM era una línea aquí son cuatro puntos de edición coordinados.
Hay una cuarta partida que casi nunca se factura y que sin embargo se paga a diario: la latencia de lectura. Cuando alguien investiga un fallo en MVVM, abre el método que corresponde al gesto y lee la lógica seguida, de arriba abajo, en un solo archivo. En MVI tiene que localizar el intent, buscar dónde se despacha, encontrar la rama del reductor y comprobar si además hay un efecto asociado. Cada uno de esos saltos es pequeño; la suma, repetida decenas de veces por semana, es una fricción real que los defensores del patrón tienden a minimizar porque ya la tienen automatizada en la cabeza.
Hay además un coste que no se mide en líneas y que conviene decir en voz alta: el coste de aprendizaje del equipo. Un ViewModel con observables lo entiende cualquiera que sepa Android; un contenedor con estado, intents, reductor y efectos exige vocabulario nuevo, y en un equipo con rotación alta ese vocabulario hay que enseñarlo una y otra vez. La ceremonia se puede automatizar con plantillas; la comprensión, no.
Y queda una factura que solo aparece en pantallas grandes: copiar un estado extenso en cada transición tiene un coste, pequeño pero no nulo, y sobre todo obliga a pensar la granularidad. Un estado con listas largas que se reemplaza entero en cada pulsación de tecla es una invitación a recomposiciones caras si los tipos no son estables. No es un argumento contra el patrón, pero sí una obligación de diseño que MVVM disperso no impone porque allí cada observable se actualiza por su cuenta.
Parte de la ceremonia de MVI es esencial y parte es accidental, y confundirlas lleva a rechazar el patrón por los motivos equivocados. Es esencial declarar el estado como un tipo y las interacciones como otro: eso es el patrón. Es accidental escribir a mano el enrutado, los constructores de estado repetidos o el pegamento entre el contenedor y la vista: eso lo resuelven librerías como Orbit y unas cuantas plantillas. Antes de descartar MVI por verboso conviene comprobar cuánta de esa verbosidad es el patrón y cuánta es no haber extraído todavía la abstracción obvia.
El error de encuadre más frecuente al evaluar MVI es medirlo como si compitiera en capacidad, cuando compite en otra cosa completamente distinta: en el número de preguntas que un programador tiene que contestar de nuevo cada vez que toca la pantalla. En MVVM disperso, cada modificación reabre un cuestionario silencioso —qué otros observables debo tocar, en qué orden, qué se ve en el intervalo, quién más escribe esto, qué pasa si dos corrutinas llegan a la vez—. Ninguna de esas preguntas aparece en el código; todas viven en la cabeza de quien edita, y todas se contestan de memoria, con prisa y con distinto criterio según el día y la persona. MVI no contesta esas preguntas mejor: hace que dejen de formularse. No hay que decidir el orden de las escrituras porque solo hay una escritura. No hay que preguntarse quién más escribe porque solo escribe el reductor. No hay que razonar sobre el intervalo porque no hay intervalo. Ese es el producto real, y explica por qué la comparación en líneas de código es engañosa hasta el punto de invertir la conclusión: MVI escribe más líneas y elimina más decisiones, y las decisiones repetidas son mucho más caras que las líneas, porque escalan con el número de personas y con el tiempo, mientras que las líneas se escriben una vez. De ahí la formulación que conviene llevarse: la ceremonia de MVI es un pago por adelantado, único y visible, a cambio de retirar un impuesto recurrente, distribuido e invisible. Y de ahí también su condición de aplicabilidad, que es honesta y limitante: si la pantalla es tan simple que ese cuestionario silencioso nunca llega a formularse, entonces no hay impuesto que retirar y el pago por adelantado es dinero tirado. MVI compensa exactamente cuando el número de veces que esas preguntas se harían, multiplicado por el coste de equivocarse en una, supera la ceremonia. Quien entiende ese producto deja de discutir verbosidad y empieza a estimar frecuencia y gravedad, que es lo único que la decisión requiere de verdad.
- Toma una pantalla tuya en
MVVMy escribe la lista completa de preguntas silenciosas que hay que contestar para añadirle un campo nuevo. - Reescríbela en
MVIy cuenta exactamente las líneas de andamiaje: estado, intents, reductor y efectos, antes de cualquier lógica de negocio. - Repite el ejercicio de añadir el mismo campo en la versión
MVIy anota cuántas de aquellas preguntas han desaparecido y cuántos puntos de edición has necesitado. - Separa la verbosidad esencial de la accidental y propón qué parte de la accidental eliminarías con una plantilla o una librería.
- Estima, para esa pantalla concreta, cuántas veces al año se hará ese cambio y qué costaría un fallo de estado incoherente; concluye con un número, no con una preferencia.