Sobre coroutines y Flow: Orbit es Kotlin asíncrono
La promesa que Orbit hizo en la primera lección se cobra aquí: que no es un runtime sino una convención sobre la maquinaria asíncrona que Kotlin ya trae. Esta lección cierra el nivel mostrando esa integración de cerca: por qué intent es literalmente una corrutina donde puedes suspender, paralelizar con async y await y coleccionar un Flow sin adaptadores; por qué exponer el estado como StateFlow y los side effects como Flow significa reutilizar el sistema de suscripción, el planificador y la cancelación de la biblioteca de corrutinas en lugar de reinventarlos; y cómo la concurrencia estructurada atada a viewModelScope elimina las fugas por construcción. Sitúa además a Orbit en el mapa de alternativas —el MVI artesanal, Mavericks y Ballast— para ver qué añade cada una, y explica por qué depender solo de coroutines y Kotlin puro es la condición que permite compartir la lógica entre Android, iOS, escritorio y web con Kotlin Multiplatform.
Cuatro lecciones atrás quedó una promesa pendiente. Cuando presentamos Orbit dijimos que su grandeza no estaba en lo que añade sino en lo que se atreve a no añadir: que era una convención sobre coroutines, no un motor por encima de ellas. Y en la lección de intent prometimos volver sobre esa integración con corrutinas al final del nivel. Este es el final. Aquí verás que la frase no era retórica: intent es literalmente una corrutina, el estado es literalmente un StateFlow de la biblioteca estándar, los side effects viajan por un Flow respaldado por un Channel, y la cancelación es la concurrencia estructurada de Kotlin sin un solo adaptador de por medio. Orbit no habla un idioma asíncrono propio que haya que traducir al de tu app: habla el mismo, porque no hay otro.
- Ver
intentcomo Kotlin asíncrono puro: suspender, paralelizar conasyncy coleccionar unFlow. - Entender por qué exponer
StateFlowyFlowes reutilizar la maquinaria de corrutinas, no reinventarla. - Situar a Orbit frente al MVI artesanal, Mavericks y Ballast, y saber qué añade cada uno.
- Comprender por qué apoyarse solo en coroutines y Kotlin puro habilita Kotlin Multiplatform.
intent es Kotlin asíncrono, sin adaptadores
Ya lo insinuamos al presentar intent: no es un método que ejecutas, es un bloque que lanza una corrutina sobre el scope del container. Lo que no habíamos desplegado es hasta dónde llega esa afirmación. Dentro de un intent estás en tierra suspend sin restricción alguna: puedes esperar un repositorio, paralelizar con async y await, abrir un coroutineScope hijo, cambiar de dispatcher con withContext o coleccionar un Flow que emite para siempre. No hay una API asíncrona de Orbit que aprender por encima; hay corrutinas, y punto.
// 1. Suspender y paralelizar: async/await dentro del intent
fun cargar(id: String) = intent {
reduce { state.copy(cargando = true) }
coroutineScope {
val perfil = async { repo.perfil(id) } // suspende, no bloquea
val posts = async { repo.posts(id) }
reduce { state.copy(perfil = perfil.await(), posts = posts.await(), cargando = false) }
}
}
// 2. Coleccionar un Flow vivo: el intent no termina, reacciona
fun observar(id: String) = intent {
repo.mensajesFlow(id).collect { lista ->
reduce { state.copy(mensajes = lista) }
}
}
Lee el primero como Kotlin corriente: coroutineScope abre un ámbito hijo, los dos async disparan trabajo en paralelo y el reduce no ocurre hasta que ambos await han resuelto. Si cualquiera de las dos llamadas falla, la concurrencia estructurada cancela a su hermana y propaga el error hacia arriba sin dejar corrutinas huérfanas. Nada de eso es API de Orbit; es el comportamiento por defecto de un ámbito de corrutinas.
El segundo intent merece una pausa: no retorna nunca, se queda coleccionando el Flow de la base de datos y reduciendo en cada emisión. En una arquitectura con suscripciones manuales tendrías que recordar cancelarla —y olvidarlo es la fuente clásica de fugas de memoria—. Aquí no cancelas nada: como la corrutina cuelga de viewModelScope, la concurrencia estructurada la termina en cuanto el ViewModel se destruye, junto con toda su descendencia. La cancelación no es una función de Orbit; es la de Kotlin, heredada gratis.
Esa corrutina viva se puede afinar aún más. En la lección del container viste que Orbit ofrece variantes de sus flujos con conteo de suscriptores; repeatOnSubscription es el envés de esa moneda dentro del intent: envuelve una colección para que solo se ejecute mientras alguien mira la pantalla y se pause cuando el último coleccionista se va, reanudándose al volver. Así, observar la base de datos no consume recursos con la app en segundo plano, y sigues sin escribir una sola línea de gestión de ciclo de vida.
Un intent que colecciona un Flow infinito no debe lanzarse en cada recomposición ni en cada entrada a la pantalla: cada llamada abre una corrutina nueva y acabarías con varios coleccionadores duplicados observando lo mismo. Lanza esas suscripciones una sola vez —en el onCreate del container— o átalas al conteo de suscriptores con repeatOnSubscription. La regla que evita el bug: los intent que reaccionan a un Flow se arrancan una vez; los que responden a un gesto del usuario se disparan por gesto.
Si un intent hace entrada/salida bloqueante o cálculo pesado, envuélvelo en withContext(Dispatchers.IO) o Dispatchers.Default igual que en cualquier código de corrutinas: Orbit no te impone un modelo de hilos, hereda el de coroutines. Lo único que serializa por ti es el reduce, así que da igual desde qué hilo lo invoques. Regla sana: el trabajo caro vive en el cuerpo del intent, nunca dentro de reduce, que debe ser una transformación pura y rápida.
StateFlow y Flow: reutilizar la maquinaria, no reinventarla
La misma economía gobierna la salida. Viste en la lección del container que el estado se expone como StateFlow<STATE> y los side effects como Flow<SIDE_EFFECT> sobre un Channel. Lo que importa entender ahora es que esos son tipos de la biblioteca de corrutinas, no envoltorios propios de Orbit. La consecuencia es enorme y silenciosa: Orbit no inventa su sistema de suscripción, ni su planificador, ni su modelo de contrapresión, ni su forma de cancelar. Reutiliza el de coroutines, que ya es idiomático, está probado y lo conoce cualquiera que escriba Kotlin.
El contraste con la generación anterior es instructivo. Las arquitecturas MVI construidas sobre RxJava sí traían mundo propio: un Observable que no era el Flow de nadie más, un Disposable que había que acordarse de liberar, un Scheduler que aprender aparte de los dispatchers. Cada una de esas piezas era una segunda verdad sobre la asincronía que convivía, a menudo mal, con la del resto del proyecto. Al plantarse sobre Flow, Orbit borra la duplicidad: no hay un modelo asíncrono de la librería y otro de la app, hay uno solo.
Exponer un tipo estándar es lo que hace que el estado componga con el resto del mundo sin adaptadores. Un StateFlow es un Flow, así que aguas abajo puedes transformarlo con los operadores de siempre:
// El estado es un StateFlow normal: compone con la biblioteca estandar
val nombresVisibles: Flow<List<String>> =
viewModel.container.stateFlow
.map { estado -> estado.usuarios.filter { it.visible }.map { it.nombre } }
.distinctUntilChanged()
Si el estado fuera un tipo cerrado de Orbit, cada una de esas operaciones exigiría un puente. Al ser Flow, no exige ninguno: map, combine, flatMapLatest, coleccionarlo en Compose con collectAsStateWithLifecycle o entregarlo a otra capa son gestos que ya sabes hacer. La superficie que Orbit no ocupa la ocupa Kotlin.
Como el estado es un StateFlow conflado, un coleccionista lento no frena a nadie: simplemente se salta los estados intermedios y siempre ve el último, que es justo lo que una interfaz necesita. El canal de side effects, en cambio, tiene búfer para no perder eventos cuando todavía no hay quien los reciba. Esas dos semánticas —conflar el estado, bufferizar el evento— no las inventa Orbit: son las de StateFlow y Channel. Al elegir esos tipos, Orbit adopta también su comportamiento ante la contrapresión, y tú razonas sobre ella con lo que ya sabes de corrutinas.
flowchart LR KT[Biblioteca de corrutinas de Kotlin] --> I[intent es una corrutina] KT --> S[StateFlow expone el estado] KT --> C[Channel respalda los side effects] KT --> V[viewModelScope cancela en cascada] I --> O[Orbit solo pone los nombres] S --> O C --> O V --> O style KT fill:#89b4fa,color:#11111b style O fill:#cba6f7,color:#11111b
Orbit en el mapa: a mano, Mavericks, Ballast
Conviene situar a Orbit entre sus vecinos para ver qué defiende. Todas estas opciones implementan el mismo bucle unidireccional que estudiaste; se diferencian en cuánta maquinaria propia interponen entre tú y las corrutinas.
MVI a mano
Un MutableStateFlow para el estado, un Channel para los eventos y una función reductora, todo cableado por ti. Orbit es exactamente esto extraído y endurecido: te quita el andamiaje repetido y te blinda contra la actualización perdida que el cableado ingenuo deja pasar.
Mavericks
El MVI de Airbnb, muy centrado en Android. Trae su propio envoltorio Async para los estados de carga, setState y withState, y persistencia automática del estado. Más framework y más vocabulario: potente en apps Android grandes, pero atado a la plataforma.
Ballast
También MVI Multiplatform, pero con más pilas incluidas: estrategias de entrada, side jobs, interceptores y viaje en el tiempo para depurar. Donde Ballast ofrece un runtime rico de funciones, Orbit se queda deliberadamente en el mínimo.
Orbit
El extremo minimalista del espectro: cuatro nombres sobre coroutines y Flow, Multiplatform y sin runtime propio. No compite en cantidad de funciones; compite en cuánto se atreve a no tener.
El eje que ordena la tabla es sencillo. El MVI a mano no interpone maquinaria, pero te obliga a mantenerla y a no equivocarte con la concurrencia; Mavericks y Ballast interponen bastante y te dan a cambio funciones listas para usar; Orbit interpone lo mínimo que todavía merece llamarse librería. Ninguno es incorrecto: son puntos distintos en el compromiso entre control y comodidad. Lo que Orbit apuesta es que, con coroutines debajo, el punto óptimo está mucho más cerca del mínimo de lo que la industria creyó durante años.
Traducido a una decisión práctica: si arrancas un proyecto nuevo, y sobre todo si es Multiplatform, Orbit te da MVI correcto con la menor superficie posible; si ya vives en un ecosistema Android grande que necesita persistencia de estado y utilidades de carga de fábrica, Mavericks encaja; y si quieres MVI Multiplatform con depuración por viaje en el tiempo e interceptores incluidos, Ballast es la opción con más pilas. Elegir bien es reconocer cuánta maquinaria vas a usar de verdad, no cuánta te ofrecen en el folleto.
Una sola lógica, muchas plataformas
La última pieza cae por su propio peso. Si la lógica de un feature no depende de nada de Android —solo de coroutines, de Flow y de Kotlin puro— entonces puede vivir en commonMain y compilar para todos los objetivos de Kotlin Multiplatform. El ViewModel con su container, sus intent y sus reduce es código común; lo único específico de cada plataforma es la interfaz que colecciona los dos flujos de salida.
flowchart TD CM[commonMain con ViewModel container intent reduce] --> AND[Android colecciona con Compose] CM --> IOS[iOS colecciona con SwiftUI] CM --> WEB[Escritorio y web coleccionan igual] style CM fill:#a6e3a1,color:#11111b style AND fill:#89b4fa,color:#11111b style IOS fill:#f9e2af,color:#11111b
Ese reparto es la recompensa de haberse apoyado en primitivas neutrales. Android colecciona stateFlow con Compose; iOS lo colecciona desde SwiftUI; escritorio y web hacen lo mismo. La regla de negocio —qué ocurre cuando el login tiene éxito, cómo se serializan las reducciones, qué es estado y qué es un evento de una vez— se escribe una sola vez y se prueba una sola vez, y solo la capa de colección se reescribe por plataforma.
Merece subrayar la asimetría: compartir la lógica no obliga a compartir la interfaz. Puedes escribir la pantalla con Compose en Android y con SwiftUI nativo en iOS —cada plataforma con su propio lenguaje visual— y aun así tener un solo ViewModel, una sola definición de qué es estado y qué es side effect, y una sola batería de tests sobre esa lógica. Es lo contrario del mínimo común denominador: no renuncias a la interfaz nativa para compartir el núcleo, porque lo único que cruza la frontera son dos Flow.
En términos de módulos, eso se traduce en un reparto limpio: el ContainerHost, la data class de estado y la sealed interface de side effects viven en commonMain, mientras que cada androidMain o iosMain aporta solo el arranque propio de su plataforma. La regla para saber qué es común es la misma que recorre todo el nivel: si algo depende únicamente de Kotlin y de corrutinas, sube a commonMain; si toca la plataforma, baja al módulo específico. Orbit no te da esa regla como una función que activar, sino como una consecuencia de no haber atado su núcleo a ninguna plataforma.
La lógica en commonMain no cambia, pero Swift no consume un Flow de forma nativa. El puente lo ponen las herramientas estándar de interoperabilidad de Kotlin Multiplatform —SKIE, KMP-NativeCoroutines o los ayudantes de colección de Orbit—, que convierten stateFlow y sideEffectFlow en algo que SwiftUI observa con naturalidad. El coste de compartir la lógica no es reescribirla: es solo enseñar a cada plataforma a coleccionar dos flujos.
Cierra el nivel con esta idea, porque es la que llevas ganando desde la primera lección y ahora se ve entera. Durante años la industria creyó que hacer MVI de verdad exigía construir maquinaria: un despachador de acciones, un bus de eventos, un motor de suscripción, un modelo de hilos, una estrategia de contrapresión, un ciclo de vida propio. Orbit demuestra que casi todo eso era accidental, no esencial. La biblioteca de corrutinas de Kotlin ya trae la asincronía sin bloqueo con suspend, ya trae la observación con StateFlow, ya trae la entrega única con Channel, ya trae la cancelación correcta con la concurrencia estructurada atada a viewModelScope. Cuando el sustrato ya resuelve las partes difíciles, el trabajo honesto de una librería se encoge hasta poner cuatro nombres —container, intent, reduce, postSideEffect— y apartarse. Fíjate en lo que eso te regala: cada cosa que Orbit no inventa es una cosa que no tienes que aprender dos veces, que no diverge del resto de tu app, que ya está probada por millones de líneas ajenas y que compila para toda plataforma donde viva Kotlin. Esa es la vara para medir cualquier herramienta que te encuentres, no solo esta: no preguntes qué hace, pregunta cuánto de lo que hace ya lo hacía el lenguaje debajo. Una abstracción que multiplica tu poder se apoya en primitivas que ya dominas y añade solo el nombre que les faltaba; una que multiplica tu superficie de aprendizaje reimplementa esas primitivas por dentro y te obliga a mantener dos verdades sobre lo mismo. Orbit eligió lo primero, y por eso su renuncia es su grandeza. Sal de este nivel sabiendo distinguir las dos, y habrás aprendido algo que sobrevive a Orbit, a MVI y hasta a Kotlin.
- Escribe un
intentque cargue dos recursos en paralelo conasyncyawaitdentro de uncoroutineScope, y funde ambos resultados en un soloreduce. - Escribe otro
intentque no termine nunca: colecciona unFlowde la base de datos y aplica unreduceen cada emisión. Explica cuándo se cancela y por qué no tienes que cancelarlo tú. - Muestra, con operadores de
Flow, cómo transformaríascontainer.stateFlowaguas abajo sin tocar elViewModel, y di qué habilita que sea un tipo de la biblioteca estándar y no propio de Orbit. - Coloca a Orbit, al MVI a mano, a Mavericks y a Ballast en una recta de menos a más maquinaria propia, y justifica el orden que elijas.
- Argumenta por qué depender solo de coroutines y Kotlin puro —y no de nada de Android— es la condición que hace posible compartir el
ViewModelen Kotlin Multiplatform.