intent: el bloque donde vive la lógica
Todo lo que ocurre en Orbit ocurre dentro de un intent, y esa exclusividad no es una convención estilística sino el mecanismo que hace predecible al sistema entero. Esta lección estudia el bloque con detalle: qué tipo de lambda es realmente, por qué su cuerpo es una corrutina suspendible y de qué ámbito desciende, qué significa que la llamada retorne de inmediato mientras el trabajo sigue, cómo se encolan y serializan los intents en un único consumidor, qué garantiza y qué no garantiza esa serialización cuando hay suspensiones largas por medio, y cómo se comportan las excepciones y la cancelación dentro del bloque. El objetivo es dejar de ver intent como un envoltorio y empezar a verlo como la unidad de ejecución del modelo.
En una arquitectura donde el estado es inmutable y las mutaciones están reguladas, hace falta un lugar acotado donde ocurra lo que sí es sucio: la llamada a la red, la lectura del disco, el temporizador, la decisión. Orbit reduce ese lugar a un único constructo y lo llama intent. La palabra es engañosa por familiar, porque en el vocabulario general de la arquitectura un intent es el mensaje que entra desde la interfaz, mientras que aquí es el bloque de código que se ejecuta al recibirlo. Ese desplazamiento semántico confunde a casi todo el mundo la primera semana y deja de importar en cuanto se entiende lo esencial: intent no es un dato, es una unidad de ejecución, y toda la lógica de un feature vive dentro de una de ellas o no vive en ninguna parte legítima.
- Explicar qué construye la llamada a
intenty por qué su cuerpo es una corrutina suspendible con receptor. - Situar el ámbito del que desciende esa corrutina y deducir cuándo se cancela sin escribir código de limpieza.
- Describir cómo se encolan y serializan los intents, y qué garantiza exactamente esa serialización.
- Anticipar el comportamiento de las excepciones, la cancelación y la reentrada dentro del bloque.
Un bloque suspendible con receptor
La firma de la función es la clave para entender todo lo demás. Al escribir un método público que llama a intent, no estás ejecutando el bloque: lo estás entregando.
fun buscar(termino: String) = intent {
reduce { state.copy(cargando = true, termino = termino) }
val resultados = repo.buscar(termino)
reduce { state.copy(cargando = false, items = resultados) }
if (resultados.isEmpty()) postSideEffect(BusquedaEffect.SinResultados)
}
Tres rasgos merecen atención por separado. El primero es que el bloque es suspendible: la llamada a repo.buscar puede suspender durante segundos sin bloquear ningún hilo, y las dos líneas de reduce que la rodean se ejecutan antes y después de esa espera con la naturalidad de un código secuencial. El segundo es que el bloque tiene receptor: dentro de él están disponibles reduce, postSideEffect, state y repeatOnSubscription, no porque sean miembros de tu clase sino porque el bloque se ejecuta sobre un objeto de contexto que Orbit provee. El tercero, y el más fácil de pasar por alto, es que la función externa no es suspendible: la vista la llama desde un manejador de eventos corriente y recibe el control de vuelta al instante.
Esa asimetría es intencionada y resuelve el problema de acoplamiento más común entre interfaz y lógica. La vista no espera resultados, no maneja corrutinas, no necesita un ámbito propio para lanzar nada y no puede olvidarse de cancelar. Solo notifica. Todo lo asíncrono queda del lado de la lógica, donde hay un ámbito con reglas y un estado que informa del progreso.
Existe además una variante que resuelve el problema de la reutilización sin romper la regla. Cuando dos intents comparten un tramo de lógica, la tentación es extraerlo a una función privada normal, pero entonces esa función no tiene receptor y pierde el acceso a reduce y a state. La solución idiomática es extraerlo como bloque anidado que se ejecuta dentro del intent que lo llama, en vez de encolarse como uno nuevo.
private suspend fun SimpleSyntax<PerfilState, PerfilEffect>.refrescar() = subIntent {
reduce { state.copy(cargando = true) }
val perfil = repo.cargar()
reduce { state.copy(cargando = false, nombre = perfil.nombre) }
}
fun abrir() = intent { refrescar() }
fun reintentar() = intent { limpiarError(); refrescar() }
La distinción es fina y decide el comportamiento: un intent anidado se encola y se ejecutará cuando toque, posiblemente después de otros; un bloque anidado se ejecuta en el flujo del intent actual, en su turno y en su orden. Cuando quieras componer pasos que deben ocurrir seguidos, el segundo es el correcto; cuando quieras disparar una acción independiente, el primero.
intent encola el bloque y devuelve el control. Eso implica que el estado no ha cambiado todavía cuando la línea siguiente de la vista se ejecuta, y que escribir dos llamadas seguidas no garantiza que sus efectos sean instantáneos ni observables en ese orden desde fuera. Si tu código de interfaz depende de leer el estado justo después de invocar un método, ese código está mal planteado: debe reaccionar al flujo de estado, no consultarlo tras una llamada.
De qué ámbito desciende y cuándo muere
El bloque no se ejecuta en un ámbito anónimo. Desciende del ámbito raíz que la fábrica recibió al construir el container, que en Android es habitualmente el viewModelScope. De esa filiación salen dos consecuencias que eliminan categorías enteras de errores.
La primera es la cancelación automática. Cuando el ViewModel se destruye, su ámbito se cancela, y la cancelación se propaga hacia abajo a cada corrutina descendiente: los intents en curso reciben una CancellationException en su próximo punto de suspensión y terminan. No hay que registrar trabajos en una lista ni recordar cancelarlos en un método de limpieza, porque la concurrencia estructurada convierte esa disciplina en una propiedad del árbol de corrutinas.
La segunda es que la unidad de cancelación es el intent completo, no la operación individual. Si un bloque hace tres llamadas seguidas y el ámbito se cancela durante la primera, las otras dos sencillamente no ocurren. Esto se comporta como uno espera y conviene decirlo explícitamente porque contradice la intuición de quien viene de gestionar trabajos sueltos: no hay que cancelar cada pieza, hay que cancelar el árbol.
flowchart TD S[Scope raiz del container] --> Q[Cola de intents] Q --> I1[Intent uno en ejecucion] I1 -.->|suspende y libera el consumidor| Q Q --> I2[Intent dos en ejecucion] S -->|se cancela el scope| X[Todos los intents vivos reciben cancelacion]
Queda un matiz importante sobre el hilo. El bloque no arranca en el hilo principal por defecto: Orbit lo despacha en el dispatcher configurado en el container, que en la configuración estándar no es el principal. Por eso una llamada de red escrita directamente en el cuerpo del intent no bloquea la interfaz, y por eso conviene no asumir nada sobre el hilo actual dentro del bloque: si necesitas un dispatcher concreto para una operación concreta, cámbialo explícitamente para esa operación.
La cola: un solo consumidor y un orden total
Aquí está el mecanismo que más consecuencias tiene y menos se explica. Los intents no se lanzan como corrutinas independientes que compiten entre sí: se envían a un canal que un único consumidor procesa. El consumidor toma un intent, ejecuta su bloque y, cuando ese bloque llega a un punto de suspensión, queda libre para tomar el siguiente. El resultado es un orden total de inicio: si la vista invoca tres métodos, sus tres bloques empiezan en el orden en que fueron encolados, siempre, sin importar cuántos hilos haya por debajo.
Esa propiedad es la que hace que las mutaciones sean razonables. Sin cola, dos intents que leen el estado y escriben una versión modificada podrían entrelazarse y perder una de las dos escrituras, el clásico problema de lectura-modificación-escritura concurrente. Con cola, y con la disciplina adicional que estudiaremos en la lección siguiente, ese entrelazado no puede producirse.
Ahora la parte que hay que enunciar con cuidado, porque es donde la mayoría se equivoca al razonar: la serialización garantiza el orden de inicio, no la exclusión mutua durante toda la vida del bloque. En cuanto un intent suspende, el consumidor avanza y otro intent empieza a ejecutarse. Dos bloques pueden por tanto estar vivos a la vez, cada uno esperando su respuesta de red. Lo que no puede ocurrir es que sus mutaciones del estado se solapen, porque esas mutaciones ocurren en un punto distinto y protegido.
Lo que la cola garantiza
Que los bloques empiecen en el orden de invocación, que las mutaciones se apliquen de forma secuencial y que ningún estado intermedio se pierda por una carrera entre escrituras.
Lo que la cola no garantiza
Que un intent termine antes de que empiece el siguiente. Las suspensiones ceden el turno, y por eso dos peticiones lentas pueden solaparse y devolver resultados en orden inverso al de salida.
De esa segunda columna sale un patrón que hay que conocer: la carrera del último escritor. Si el usuario teclea rápido y cada pulsación lanza un intent de búsqueda, tres peticiones pueden volver desordenadas y la más antigua escribir la última. Orbit no lo resuelve por ti dentro de la cola, y hace bien, porque la solución correcta depende del caso: cancelar la anterior, aplicar un retardo antirrebote, o descartar respuestas cuyo término ya no coincide con el del estado actual. Lo importante es saber que el problema existe y de dónde viene, en vez de descubrirlo como un fantasma.
fun escribir(termino: String) = intent {
reduce { state.copy(termino = termino) }
delay(300) // antirrebote
if (state.termino != termino) return@intent // llego una pulsacion mas nueva
val resultados = repo.buscar(termino)
if (state.termino != termino) return@intent // llego mientras esperabamos
reduce { state.copy(items = resultados) }
}
Merece la pena leer este fragmento como una lección sobre el modelo entero. La comprobación no consulta una variable auxiliar ni un contador de peticiones: consulta el propio estado, que es la única fuente de verdad sobre lo que el usuario quiere ahora mismo. Y la comprobación aparece dos veces porque hay dos suspensiones, y tras cada una el mundo pudo cambiar. Esa disciplina —después de cada espera, volver a preguntar si lo que estaba haciendo sigue teniendo sentido— es la contrapartida honesta de la libertad que da un bloque suspendible.
Si el cuerpo de un bloque hace trabajo intensivo sin suspender —un bucle largo, un cálculo pesado, una llamada bloqueante— el consumidor no se libera y todos los intents encolados esperan. La corrección no es fragmentar el cálculo a mano sino sacarlo a un contexto adecuado con un cambio explícito de dispatcher, que además introduce el punto de suspensión que devuelve el turno.
Excepciones, reentrada y disciplina de escritura
Una excepción no capturada dentro de un bloque se propaga por el árbol de corrutinas y, al descender del ámbito raíz, puede derribar el container entero: el consumidor muere, la cola deja de procesarse y la pantalla queda congelada respondiendo a nada. Es el fallo más desagradable de diagnosticar porque no produce un cierre visible sino una interfaz muda.
fun guardar() = intent {
reduce { state.copy(guardando = true) }
val resultado = runCatching { repo.guardar(state.borrador) }
resultado
.onSuccess { reduce { state.copy(guardando = false, guardado = true) } }
.onFailure { error ->
reduce { state.copy(guardando = false, error = error.mensajeVisible()) }
postSideEffect(EdicionEffect.AvisarFalloAlGuardar)
}
}
La regla práctica es tratar el fallo como una rama del dominio y no como una excepción que alguien de arriba recogerá. Un intent bien escrito termina siempre dejando el estado en una forma coherente, y eso vale también para el camino de error: si guardando se puso a cierto al empezar, alguna rama tiene que devolverlo a falso, o la pantalla se quedará girando para siempre. Conviene además no capturar la cancelación como si fuera un fallo, porque CancellationException es el mecanismo normal de terminación y tragársela rompe la concurrencia estructurada.
Sobre la reentrada, la respuesta es sencilla y libera de una preocupación: llamar a otro método que a su vez llama a intent desde dentro de un bloque no produce un anidamiento peligroso, sino un nuevo elemento en la cola que se procesará cuando toque. Componer lógica llamando a métodos públicos de la propia clase es legítimo. Lo que no es legítimo es intentar esperar el resultado de ese segundo intent, porque no devuelve nada: si necesitas secuencia, escribe la secuencia dentro de un mismo bloque, que para eso es suspendible.
Queda una decisión de estilo que en realidad es de arquitectura: cuántos métodos públicos debe tener la clase y con qué nombre. La superficie que la vista consume es la lista de intents, y esa lista es el catálogo de acciones posibles del usuario en esa pantalla. Nombrarlos por lo que el usuario hace —buscar, reintentar, marcarLeido— y no por lo que el sistema hace internamente mantiene la correspondencia entre el producto y el código, y hace que leer la clase equivalga a leer la especificación funcional. Un método llamado actualizarEstadoDeCarga delata que la vista está dando órdenes de implementación en vez de notificar hechos.
La granularidad correcta la marca la intención, no la estructura del estado. Si un formulario tiene ocho campos, no necesitas ocho intents genéricos de asignación: necesitas los que correspondan a decisiones reales, y con frecuencia uno solo que reciba el campo y su valor. La prueba es sencilla: si el nombre del método no se puede leer en voz alta como algo que una persona quiso hacer, la granularidad está mal.
Toda arquitectura de interfaz se enfrenta al mismo problema irreductible: el usuario actúa en instantes discretos, la pantalla debe describirse como un valor, y entre ambos hay operaciones que tardan. La respuesta habitual es dispersar esa espera por todas partes —una corrutina lanzada aquí, una devolución de llamada allá, un observador que despierta cuando llega el dato— y confiar en que nadie escriba en el estado al mismo tiempo que otro. Orbit hace lo contrario: concentra absolutamente todo el tiempo en un solo tipo de bloque, le da un ámbito con dueño, lo encola en un consumidor único y prohíbe implícitamente cualquier otra vía de mutación. Lo que se gana con esa concentración no es orden estético sino capacidad de razonar. Cuando toda la asincronía de un feature está dentro de bloques con la misma forma, se puede responder con certeza a preguntas que en otras arquitecturas exigen leer el archivo entero: dónde puede cambiar el estado, quién cancela este trabajo, en qué orden ocurren dos acciones del usuario, qué pasa si la pantalla muere ahora. Y hay una asimetría más profunda todavía: al hacer que la función externa sea normal y el cuerpo sea suspendible, Orbit coloca la frontera entre lo síncrono y lo asíncrono exactamente en el borde de la lógica, de modo que la interfaz jamás toca una corrutina y jamás puede equivocarse al gestionarla. La ignorancia de la vista sobre el tiempo no es una casualidad de la librería; es su tesis. Un intent es, literalmente, el permiso administrativo para que el tiempo entre en el sistema, y por eso todo lo demás puede permitirse ser un valor.
- Toma un método público de tu lógica y describe con precisión qué ocurre entre la invocación desde la vista y la primera mutación del estado.
- Provoca la carrera del último escritor lanzando dos búsquedas con latencias distintas y observa qué resultado gana. Después corrígela y justifica la estrategia elegida.
- Escribe un intent que capture su fallo como rama del dominio y comprueba que ninguna bandera de progreso queda encendida en ningún camino.
- Explica por qué un bloque que calcula sin suspender retrasa a los demás, y qué cambio mínimo devuelve el turno al consumidor.
- Argumenta por qué la serialización de la cola no implica exclusión mutua durante toda la vida del bloque, con un ejemplo de dos intents solapados.