El esqueleto mínimo: estado privado, estado público e intents
MVI no es una librería sino un contrato, y ese contrato cabe en tres piezas que ya vienen con Kotlin: un flujo mutable privado que nadie fuera puede tocar, una vista pública de solo lectura que la interfaz observa, y una única función de entrada que traduce intenciones en transiciones. Esta lección construye ese esqueleto desde cero, justifica por qué la asimetría entre escritura y lectura es la que sostiene la unidireccionalidad, y separa con precisión el reducer puro del trabajo asíncrono que lo rodea.
Después de doce niveles apoyados en Orbit conviene hacerse una pregunta incómoda: ¿cuánto de lo aprendido era la librería y cuánto era la arquitectura? La respuesta, que sorprende la primera vez, es que la arquitectura completa cabe en unas pocas docenas de líneas de Kotlin sin una sola dependencia externa. MVI no es un artefacto que se instala: es un contrato sobre quién puede escribir el estado, quién puede leerlo y por dónde entran los cambios. Ese contrato se puede expresar con tres construcciones del lenguaje que ya conoces —un flujo mutable confinado, su proyección de solo lectura y una función de entrada— y el resultado no es una versión degradada de Orbit sino el mismo modelo mental sin azúcar. Construirlo a mano una vez es la forma más rápida de dejar de tratar la librería como magia y empezar a tratarla como una decisión.
- Construir un ciclo MVI completo con
MutableStateFlow,StateFlowy una función de entrada única. - Justificar por qué la asimetría entre el flujo privado y el público es lo que hace cumplible la unidireccionalidad.
- Separar el reducer puro del trabajo asíncrono sin que la separación degenere en ceremonia.
- Elegir entre
updatey asignación directa entendiendo qué carrera evita cada opción.
La asimetría entre escribir y leer
El corazón del esqueleto es una asimetría deliberada. Dentro del componente vive un MutableStateFlow con visibilidad privada; hacia fuera se expone el mismo objeto proyectado como StateFlow, que carece de métodos de escritura. La interfaz recibe un valor que puede observar y no puede alterar, y esa imposibilidad no es una convención documentada sino una propiedad del tipo, verificada por el compilador en cada punto de uso.
class CarritoViewModel(
private val repo: CarritoRepo,
) : ViewModel() {
private val _estado = MutableStateFlow(CarritoState())
val estado: StateFlow<CarritoState> = _estado.asStateFlow()
}
Merece la pena detenerse en asStateFlow. Una alternativa frecuente es declarar la propiedad pública con el tipo StateFlow y asignarle directamente el flujo mutable, confiando en que el tipo declarado baste. Bastaría en Kotlin puro, pero no protege frente a una conversión descendente en tiempo de ejecución ni frente a consumidores escritos en otro lenguaje de la JVM. La llamada a asStateFlow devuelve un envoltorio distinto que no es el mismo objeto y que, por tanto, no puede recuperarse con un as oportunista. El coste es una asignación por instancia de pantalla; la garantía es que la única ruta de escritura del estado es la que tú has escrito.
flowchart LR U[Interfaz] -->|envia intent| P[Funcion procesar] P --> R[Reducer puro] R --> M[MutableStateFlow privado] M --> S[StateFlow publico de solo lectura] S --> U style M fill:#f9e2af,color:#11111b style S fill:#a6e3a1,color:#11111b
El diagrama muestra un ciclo, no una línea, y esa circularidad es el sello de MVI: la interfaz nunca escribe estado, solo emite intenciones; el estado nunca invoca a la interfaz, solo cambia de valor y deja que la observación haga el resto. Un StateFlow encaja aquí porque cumple tres propiedades a la vez que la pantalla necesita: siempre tiene un valor actual, conflada los cambios rápidos y entrega el último valor a cada nuevo suscriptor. Esa última es la que hace que una rotación de pantalla no pierda nada: el observador nuevo recibe inmediatamente el estado vigente.
El constructor de MutableStateFlow exige un valor. Esa exigencia parece una molestia y es una defensa: obliga a que exista un estado válido desde el instante cero, antes de cualquier carga de datos, y elimina de raíz la pregunta de qué renderiza la interfaz mientras no hay nada. El estado inicial correcto casi nunca es un valor vacío disfrazado de dato real, sino uno que declare explícitamente que aún no se ha cargado nada, ya sea con una bandera de carga o con una lista vacía cuyo significado esté documentado en el propio tipo.
Una sola puerta de entrada
La segunda pieza es una función que recibe intenciones. Su firma importa más que su cuerpo: acepta un tipo sellado y no devuelve nada útil, con lo que declara que la única forma de influir en el componente es enviarle un valor de ese vocabulario cerrado.
sealed interface CarritoIntent {
data object Cargar : CarritoIntent
data class CambiarCantidad(val id: String, val cantidad: Int) : CarritoIntent
data class Borrar(val id: String) : CarritoIntent
}
fun procesar(intent: CarritoIntent) {
when (intent) {
CarritoIntent.Cargar -> cargar()
is CarritoIntent.CambiarCantidad -> cambiarCantidad(intent.id, intent.cantidad)
is CarritoIntent.Borrar -> borrar(intent.id)
}
}
Que la función sea única no es una cuestión estética. Una clase con quince métodos públicos ofrece quince superficies de entrada, y nada impide que un compañero añada la decimosexta sin que nadie lo note. Una clase con un solo método público cuyo parámetro es un tipo sellado ofrece exactamente tantas entradas como casos tenga ese tipo, y ampliarlas obliga a tocar un fichero que la revisión de código mira siempre. La unidireccionalidad deja de depender de la disciplina y pasa a ser legible en la firma.
El despacho con when exhaustivo añade la segunda garantía: añadir un intent nuevo rompe la compilación aquí hasta que alguien decida qué hacer con él. Es el mismo mecanismo que sostiene el consumo de efectos, aplicado a la entrada en vez de a la salida.
Conviene además que la función no devuelva nada aprovechable. La tentación de que retorne el estado resultante, o un valor que indique si la intención fue aceptada, parece una comodidad y rompe el modelo: si el emisor puede leer una respuesta directa, ya existe un segundo canal de información fuera del estado, y la interfaz empezará a tomar decisiones con ese valor en vez de con lo que observa. Todo lo que el mundo exterior debe saber tiene que llegarle por el estado o por un efecto, sin excepciones.
Una función pública
Toda la superficie de escritura cabe en una firma. Auditar el componente es leer un tipo sellado, no rastrear métodos por el fichero.
Un flujo privado
La escritura queda confinada al interior de la clase. Ningún consumidor puede saltarse el reducer aunque quiera.
Un reducer puro
La transición es una función de estado más intent a estado. Se prueba con una igualdad y sin infraestructura.
El reducer puro y el trabajo que lo rodea
El error más común al escribir MVI a mano es meter la llamada de red dentro de la misma función que calcula el estado nuevo. Conviene mantener dos capas bien distintas: una función pura que dado un estado y un hecho produce el estado siguiente, y unas corrutinas que producen esos hechos hablando con el mundo.
private fun reduce(estado: CarritoState, intent: CarritoIntent): CarritoState =
when (intent) {
is CarritoIntent.CambiarCantidad ->
estado.copy(lineas = estado.lineas.map {
if (it.id == intent.id) it.copy(cantidad = intent.cantidad) else it
})
else -> estado
}
private fun cargar() = viewModelScope.launch {
_estado.update { it.copy(cargando = true) }
val lineas = repo.lineas()
_estado.update { it.copy(cargando = false, lineas = lineas) }
}
La llamada a update merece explicación, porque la alternativa evidente es asignar directamente a la propiedad de valor. La asignación directa lee y escribe en dos pasos, y entre ambos puede colarse otra corrutina que también escriba: el clásico problema de lectura, modificación y escritura no atómica. La función update aplica la transformación mediante una comparación e intercambio en bucle, de modo que si otro escritor se adelantó, el bloque se reejecuta sobre el valor nuevo. El precio es que ese bloque debe ser puro y barato, porque puede ejecutarse varias veces; si dentro haces una llamada suspendida o registras una métrica, tendrás efectos duplicados sin saber por qué.
Hay un segundo detalle del StateFlow que conviene interiorizar pronto porque explica muchas sorpresas: la emisión está condicionada por la igualdad. Si el valor nuevo es igual al anterior según su comparación estructural, el flujo no emite nada y los observadores no se enteran. Con estados modelados como clases de datos inmutables eso es justo lo que quieres, porque convierte una reducción idempotente en una recomposición evitada. Pero explica también un fallo desconcertante: si tu estado contiene una lista mutable y la modificas en el sitio antes de asignarla, la comparación dará igualdad y la interfaz no se actualizará jamás, sin ningún error visible.
El modo de fallo más silencioso de este esqueleto no está en el flujo sino en el contenido. Una clase de datos que aloja una lista mutable parece inmutable y no lo es: basta con que alguien añada un elemento en el sitio para que el valor viejo y el nuevo sean el mismo objeto, la comparación dé igualdad y la emisión no ocurra. El síntoma es una pantalla que no se actualiza pese a que el registro muestra que la reducción se ejecutó. La defensa es doble: exponer siempre colecciones de solo lectura en el tipo de estado y construir cada valor nuevo con una copia en vez de mutar el anterior.
Si tu función de reducción necesita saber la hora actual, generar un identificador aleatorio o consultar un repositorio, deja de ser pura y con ello pierdes su mejor propiedad: que un test la ejecute mil veces con los mismos argumentos y obtenga siempre el mismo resultado. La solución es que esos valores lleguen ya calculados dentro del intent o del hecho que produce la corrutina. Un intent que transporta la marca de tiempo es feo la primera vez que se ve y bendito la primera vez que hay que reproducir un fallo.
Merece señalarse una asimetría entre este esqueleto y lo que enseñan los tratamientos canónicos de MVI. En la formulación estricta, cada acción del usuario produce un intent, cada respuesta del mundo produce un hecho, y solo los hechos alimentan el reducer. Aquí hemos colapsado ambos vocabularios en uno solo por brevedad, lo cual funciona perfectamente en pantallas medianas y empieza a doler cuando una operación tiene varias etapas observables: en ese momento el tipo de intents acumula casos que el usuario nunca puede provocar, como la llegada de una respuesta o el vencimiento de un temporizador. Cuando eso ocurra, separar los dos vocabularios no es purismo sino higiene, y el coste es un tipo sellado adicional.
Queda una decisión de estilo con consecuencias reales: si el trabajo asíncrono se lanza desde el despacho o desde el reducer. La respuesta correcta es siempre desde el despacho, nunca desde el reducer, y la razón es que el reducer debe poder ejecutarse en un test sin ningún alcance de corrutinas. Un reducer que lanza trabajo obliga a que cada prueba suya monte infraestructura, y en cuanto eso ocurre las pruebas de transiciones dejan de escribirse porque cuestan demasiado.
Este esqueleto tiene además un silencio importante que conviene nombrar antes de cerrarlo, porque será el hilo de las lecciones siguientes: no dice absolutamente nada sobre qué ocurre si dos intents se procesan a la vez. Cada llamada lanza su propia corrutina y ninguna espera a las demás, de modo que dos operaciones lentas pueden intercalar sus reducciones en cualquier orden. En el camino feliz eso no se nota; con una red lenta y un usuario impaciente, sí. Que treinta líneas basten para el patrón no significa que basten para la concurrencia, y confundir ambas cosas es el error más caro que se comete al abandonar una librería.
Lo que acabas de construir tiene quizá treinta líneas y ya cumple todas las promesas que se le atribuyen a MVI: fuente única de verdad, flujo unidireccional verificable por el compilador, estado inmutable observable y transiciones deterministas. Esto debería reordenar tu forma de mirar el ecosistema. Orbit, y cualquier librería equivalente, no aporta la arquitectura: la arquitectura vive en las restricciones que tú aceptas —no escribir el estado desde fuera, no entrar por más de una puerta, no mezclar cálculo con efecto— y esas restricciones son gratuitas porque el lenguaje ya las sabe expresar. Lo que aporta la librería es lo que viene después: ergonomía cuando el número de pantallas crece, un modelo de concurrencia probado cuando dos intents compiten, herramientas de test que afirman sobre secuencias, y sobre todo un vocabulario compartido que hace que un desarrollador nuevo reconozca el patrón en cinco minutos en vez de leer tu clase base. Confundir ambas cosas tiene un coste doble y simétrico: quien cree que la librería es la arquitectura escribe código acoplado bajo una fachada correcta y se sorprende de que los bugs sigan apareciendo; quien cree que la arquitectura es gratuita reescribe cada seis meses una versión peor de un contenedor que ya existía. Saber dibujar el esqueleto de memoria es lo que te permite hacer la única pregunta que importa cuando llegue la decisión: no si la librería es buena, sino qué parte del trabajo que hace la necesitas de verdad.
- Implementa desde cero un
ViewModelcon estado privado, estado público y una función de intents para una pantalla de búsqueda con carga, resultados y error. - Intenta escribir el estado desde un test sin usar la función pública y documenta exactamente qué error te da el compilador y por qué.
- Sustituye
asStateFlowpor una asignación directa y explica qué garantía has perdido y en qué escenario concreto se manifestaría. - Extrae el reducer a una función pura de nivel superior y escribe tres tests que no necesiten corrutinas ni dependencias simuladas.
- Provoca a propósito una carrera con dos corrutinas que escriban a la vez usando asignación directa, y comprueba que
updatela elimina.