wandres.dev
EL LEGADO DE ELM · lo que sembró

MVI en Android: la misma idea con otro nombre

El acrónimo MVI llegó a Android por una ruta indirecta y algo azarosa: André Staltz lo acuñó para Cycle.js rebautizando las tres funciones del bucle unidireccional como intención, modelo y vista, y desde ahí Hannes Dorfmann lo introdujo en el mundo Android en una serie de artículos que hoy se citan como fundacionales. El nombre nuevo escondía un contenido viejo. Intent es Msg, el modelo único es Model, render es view y el operador scan de las bibliotecas reactivas es, literalmente, un pliegue con la misma forma que update. Esta lección reconstruye ese linaje hasta el presente, pasando por Mobius, que es el puerto más fiel de la Arquitectura Elm que existe fuera de Elm, por Orbit y MVIKotlin, y por la guía oficial de flujo unidireccional que hoy acompaña a Compose. Y se detiene en la única costura donde la copia no fue completa: los eventos de una sola vez, esos avisos de navegación y esas notificaciones efímeras que no son estado y que Android resolvió con un canal aparte donde Elm habría devuelto un comando. Esa costura sigue produciendo bugs y sigue produciendo debate, lo cual la convierte en el mejor sitio para observar qué se pierde cuando una arquitectura se importa al noventa por ciento.

⏱ 17 min

Que la misma arquitectura aparezca tres veces en tres plataformas distintas, con tres nombres distintos y con genealogías que solo se cruzan en parte, dice algo que ninguna de las tres apariciones dice por separado. En Android, la idea llegó envuelta en el vocabulario de la programación reactiva y con un acrónimo que confunde a quien lo lee por primera vez: MVI, modelo, vista e intención, que suena a variación de MVC y no lo es en absoluto. La intención no es una capa; es el mensaje. El modelo no es un objeto de dominio con métodos; es el estado completo e inmutable de la pantalla. Y la vista no es una clase que se actualiza a trozos; es una función que recibe ese estado y lo pinta entero. Puestos en fila, los tres elementos son el bucle de Elm sin una sola pieza de sobra. Lo notable es que en Android esa disciplina no llegó como refinamiento estético sino como respuesta a una presión del sistema operativo: los procesos mueren, las pantallas se recrean al rotar, el estado hay que poder guardarlo y restaurarlo, y todo eso resulta trivial si el estado es un único valor serializable y prácticamente imposible si está repartido en campos mutables de una jerarquía de vistas. La plataforma que peor trataba a sus programadores fue, por eso mismo, la que más rápido entendió para qué servía la disciplina.

🎯 Al terminar esta lección sabrás
  • Traducir el vocabulario de MVI al de la Arquitectura Elm y detectar los falsos amigos del acrónimo.
  • Reconocer el pliegue sobre un flujo de eventos como la misma operación que la función de transición.
  • Situar a Mobius, Orbit y MVIKotlin en el linaje y distinguir cuál conserva el canal de efectos en el tipo de retorno.
  • Analizar el problema de los eventos de una sola vez como la costura donde la importación quedó incompleta.

El mismo bucle con otro vocabulario

La traducción es directa y conviene fijarla antes de seguir, porque buena parte de la confusión que rodea a este acrónimo se disuelve al hacerla. La intención es el mensaje que produce la interfaz cuando el usuario hace algo, y en Kotlin se modela con una interfaz sellada cuyas implementaciones son los casos posibles. El modelo es una clase de datos inmutable que describe la pantalla entera, incluidos los estados de carga y de error, y se transforma con el método de copia que el lenguaje genera. El reductor es una función de estado e intención a estado. La vista observa el estado y lo pinta. Kotlin aporta a esta arquitectura dos herramientas que Elm tenía desde el principio y Java no tenía: las jerarquías selladas, que permiten al compilador comprobar que una expresión de selección cubre todos los casos, y las clases de datos con copia, que dan a la actualización inmutable una sintaxis soportable.

sealed interface Intencion {
    data object Incrementar : Intencion
    data object PedirDato : Intencion
    data class DatoRecibido(val texto: String) : Intencion
    data class Fallo(val causa: String) : Intencion
}

data class Estado(
    val cuenta: Int = 0,
    val cargando: Boolean = false,
    val hecho: String? = null,
    val error: String? = null,
)

fun reducir(estado: Estado, intencion: Intencion): Estado = when (intencion) {
    is Intencion.Incrementar -> estado.copy(cuenta = estado.cuenta + 1)
    is Intencion.PedirDato -> estado.copy(cargando = true, error = null)
    is Intencion.DatoRecibido -> estado.copy(cargando = false, hecho = intencion.texto)
    is Intencion.Fallo -> estado.copy(cargando = false, error = intencion.causa)
}

Quien haya leído las lecciones anteriores reconocerá la forma sin necesidad de que se la señalen. Y quien venga de Elm notará también la ausencia: reducir devuelve estado y solo estado, igual que el reducer de Redux, con lo cual la petición al servidor tendrá que ocurrir en otro sitio. La diferencia respecto al caso de JavaScript es que aquí ese otro sitio estaba ya construido y era razonablemente bueno, porque el ecosistema Android había adoptado la programación reactiva años antes y disponía de operadores de composición asíncrona bien probados.

El operador que ya era un reducer

El hallazgo de los primeros artículos de MVI en Android fue notar que la biblioteca reactiva ya contenía la mitad del patrón sin saberlo. El operador de pliegue acumulativo toma un valor inicial y una función de acumulación y produce un flujo de estados sucesivos a partir de un flujo de eventos; puesto de otro modo, es la Arquitectura Elm expresada como una sola llamada. Sustituyendo el flujo antiguo por los flujos de la biblioteca de corrutinas, la formulación es prácticamente idéntica y el estado observable se expone como un flujo con valor actual, que la interfaz recoge respetando el ciclo de vida.

class ContadorViewModel(
    private val api: Api,
    private val scope: CoroutineScope,
) {
    private val intenciones = MutableSharedFlow<Intencion>()

    val estado: StateFlow<Estado> =
        intenciones
            .scan(Estado()) { acc, intencion -> reducir(acc, intencion) }
            .stateIn(scope, SharingStarted.WhileSubscribed(5_000), Estado())

    fun enviar(intencion: Intencion) {
        scope.launch { intenciones.emit(intencion) }
        if (intencion is Intencion.PedirDato) {
            scope.launch {
                val resultado = runCatching { api.hecho() }
                intenciones.emit(
                    resultado.fold(
                        onSuccess = { Intencion.DatoRecibido(it) },
                        onFailure = { Intencion.Fallo(it.message ?: "error") },
                    )
                )
            }
        }
    }
}

Ese if dentro de enviar es la cicatriz. Alguien tiene que decidir que la intención de pedir datos provoca una llamada a la red, y como el reductor no puede decirlo, lo dice el objeto que lo rodea, fuera de la función pura y fuera del alcance de un test que solo pruebe el reductor. Las bibliotecas del ecosistema difieren precisamente en cómo resuelven este punto, y esa diferencia ordena el linaje mejor que ninguna otra.

// La variante fiel: la transicion devuelve modelo y efectos, como la tupla de Elm
sealed interface Efecto {
    data object PedirHecho : Efecto
}

fun actualizar(modelo: Estado, evento: Intencion): Next<Estado, Efecto> = when (evento) {
    is Intencion.Incrementar ->
        Next.next(modelo.copy(cuenta = modelo.cuenta + 1))

    is Intencion.PedirDato ->
        Next.next(modelo.copy(cargando = true), setOf(Efecto.PedirHecho))

    is Intencion.DatoRecibido ->
        Next.next(modelo.copy(cargando = false, hecho = evento.texto))

    is Intencion.Fallo ->
        Next.next(modelo.copy(cargando = false, error = evento.causa))
}

La diferencia con el ejemplo anterior es de una línea y de una categoría entera. Aquí el efecto forma parte de lo que la función devuelve, con lo cual un test del reductor puede afirmar no solo qué estado resulta sino qué se pidió al mundo, y el manejador que ejecuta esos efectos es una pieza sustituible que en las pruebas no existe. Es la Arquitectura Elm sin traducción y en un lenguaje que en su día ni siquiera tenía tipos suma cómodos.

🎯

Mobius

De Spotify, y el puerto más fiel: la función de actualización devuelve un valor que contiene modelo y conjunto de efectos, con manejadores externos que los ejecutan. Es la tupla de Elm escrita en Java.

🛰️

Orbit

Organiza el trabajo en un contenedor donde se reduce el estado y se publican efectos laterales por un canal aparte. Ergonómico y muy adoptado, con la costura declarada.

🧱

MVIKotlin

Multiplataforma y explícito sobre el ciclo de vida y la restauración del estado, con ejecutores separados del reductor.

🧭

Compose y la guía oficial

Estado de interfaz inmutable expuesto como flujo, eventos que suben como llamadas a función y recomposición como diferencia. El patrón dejó de necesitar biblioteca.

flowchart LR
U[Usuario] -->|Intent| VM[ViewModel]
VM --> R[reducir estado intencion]
R --> S[StateFlow de estado unico]
S -->|render| C[Compose]
C --> U
VM -.->|efecto fuera del tipo| N[Red o base de datos]
N -.->|nueva intencion| VM
style R fill:#a6e3a1,color:#11111b
style S fill:#89b4fa,color:#11111b
style N fill:#f38ba8,color:#11111b

La costura: los eventos de una sola vez

Queda un residuo que no encaja y que ha generado más discusión que ninguna otra parte del patrón. Hay cosas que la aplicación debe hacer una sola vez y que no son estado: navegar a otra pantalla, mostrar un aviso efímero, cerrar el teclado, lanzar una vibración. Si se modelan como campos del estado, aparece el problema de consumirlos, porque tras rotar la pantalla el estado se vuelve a entregar y el aviso se muestra dos veces; y si se modelan como un canal aparte de sucesos, se ha abierto una segunda vía que escapa al modelo y que puede perder mensajes cuando no hay nadie escuchando. La solución habitual, un canal de efectos laterales separado del estado, funciona en la práctica y es lo que ofrecen casi todas las bibliotecas, pero tiene un precio conceptual que conviene nombrar: a partir de ese momento, el estado ya no describe la pantalla entera y la reproducción a partir del estado deja de ser fiel.

⚠️
Dónde exactamente diverge de Elm

Elm no tiene este problema porque no tiene esta categoría. Navegar es un comando, y un comando se devuelve desde la transición como un dato junto al modelo nuevo; se ejecuta una vez porque se produce una vez, y no se vuelve a producir al reconstruir la vista porque la vista no produce comandos. Android partió ese canal en dos, estado por un lado y sucesos por otro, y a cambio de comodidad heredó una familia entera de fallos: avisos duplicados al rotar, navegaciones perdidas cuando la aplicación estaba en segundo plano y ordenaciones sorprendentes entre lo que se muestra y lo que se navega. La guía oficial reciente empuja a modelar como estado todo lo que se pueda y a reservar el canal para lo que realmente no lo sea, que es un movimiento en la dirección de la solución original.

💡
Un criterio operativo para decidir

Pregúntate si, al restaurar la aplicación después de que el sistema mate el proceso, ese suceso debería volver a ocurrir. Si la respuesta es sí, era estado y estaba mal modelado como suceso. Si la respuesta es no, es un efecto genuino y lo que necesitas es un mecanismo que lo emita una vez y garantice su entrega, no un campo booleano con una bandera de consumido que alguien tendrá que acordarse de limpiar.

Que la misma forma reaparezca en plataformas que no se hablan es la prueba de que describe el problema y no el lenguaje

Lo que hace intelectualmente valioso el caso de Android no es que copiara bien o mal, sino que su genealogía es en parte independiente. Cycle.js llegó al bucle unidireccional desde los flujos y el diálogo entre programa y entorno; Redux llegó desde Flux y la necesidad de un depurador; Mobius llegó desde Elm de forma declarada y directa; la guía oficial de Compose llegó desde el problema del ciclo de vida y la muerte de procesos, un asunto que a Evan Czaplicki no le preocupaba en absoluto cuando diseñó su lenguaje. Cuatro caminos, cuatro motivaciones y una sola figura resultante: un estado, un vocabulario cerrado de mensajes, una transición pura, una vista que es una función de ese estado y un lugar aparte donde ocurren las cosas que ensucian. Cuando una solución aparece repetidamente en contextos que no se comunican, la explicación más económica no es la moda ni la imitación, sino que la solución está determinada por la estructura del problema. Y el problema, aquí, es que una interfaz de usuario es un sistema concurrente cuyo estado es observado por un humano en tiempo real, de modo que cualquier arquitectura que permita que dos fuentes distintas modifiquen lo observable acabará produciendo discrepancias que ningún test encuentra y todo usuario ve. Esta observación tiene una consecuencia práctica que va más allá de la nostalgia: el sitio donde una plataforma se aparta de la figura común es exactamente el sitio donde hay que esperar los bugs difíciles. En Android ese sitio está señalado con luces de neón y se llama eventos de una sola vez. No es casualidad que sea el único punto del patrón sobre el que la comunidad lleva años sin ponerse de acuerdo, ni que las soluciones propuestas oscilen entre dos extremos que ya conocemos de sobra: modelarlo como estado, que es lo que hace Elm con la vista, o modelarlo como comando, que es lo que hace Elm con los efectos. La discusión sigue abierta porque a la plataforma le falta la pieza que haría innecesaria la elección.

⚔️ Medir la fidelidad de tu copia
  1. Toma una pantalla real de tu aplicación y escribe su estado como una única clase de datos que incluya carga, error y contenido; anota cuántos campos mutables desaparecen.
  2. Convierte su lógica en una función pura de estado e intención a estado y comprueba si el compilador te obliga a cubrir todos los casos de la interfaz sellada.
  3. Inventaria todos los sucesos de una sola vez de esa pantalla y clasifícalos con el criterio del recuadro anterior: cuáles eran estado mal modelado y cuáles son efectos genuinos.
  4. Reescribe la función de transición para que devuelva un par de estado y lista de efectos, al modo de Mobius, y construye el ejecutor mínimo que los consuma.
  5. Simula la muerte del proceso y la restauración, y comprueba cuántos de tus sucesos se duplican o se pierden.
  6. Argumenta en contra de esta lección: defiende que el canal separado de efectos laterales es superior al comando de Elm para una plataforma con ciclo de vida hostil, y di qué garantías estás dispuesto a ceder a cambio.