Configurar el container: scope, dispatcher y SavedState
La fábrica del container acepta bastante más que un estado inicial, y cada parámetro decide una propiedad operativa del sistema. Esta lección recorre la configuración completa con criterio de ingeniería: qué implica elegir el ámbito raíz y por qué en Android casi siempre debe ser el del ViewModel, cómo el dispatcher de los intents determina en qué hilo empieza la lógica y qué se gana al fijarlo en pruebas, qué problema resuelve repeatOnSubscription al atar trabajo continuo a la presencia de observadores y por qué su margen temporal evita reiniciarlo en cada rotación, y cómo la variante con SavedState persiste el estado a través de la muerte del proceso con las restricciones de serialización que eso impone.
Las cuatro lecciones anteriores describieron un mecanismo cerrado: una interfaz, una cola, un reductor aislado y dos salidas. Todo eso funciona con la configuración por defecto y por eso se puede aprender sin tocarla. Pero la configuración por defecto es un conjunto de decisiones tomadas por otro, y cada una de ellas responde a una pregunta que en algún momento tu proyecto tendrá que responder por su cuenta: cuánto vive esta lógica, en qué hilo empieza, qué debe apagarse cuando nadie mira y qué debe sobrevivir a que el sistema mate el proceso para liberar memoria. Los parámetros de la fábrica son el sitio donde esas cuatro preguntas tienen respuesta explícita. Conocerlos no es afinar detalles: es saber qué estabas aceptando cuando no los tocabas.
- Elegir el ámbito raíz del
containercon criterio y deducir de esa elección cuándo muere todo el trabajo asíncrono. - Explicar el papel del dispatcher de intents y por qué fijarlo hace deterministas las pruebas.
- Aplicar
repeatOnSubscriptionpara atar trabajo continuo a la presencia de observadores sin reiniciarlo en cada rotación. - Persistir el estado a través de la muerte del proceso con la variante de SavedState y sus restricciones de serialización.
El ámbito raíz: quién decide cuánto vive todo
La fábrica necesita un ámbito de corrutinas del que colgar la cola de intents, y esa elección determina la vida útil de absolutamente todo el trabajo asíncrono del feature. En Android la respuesta idiomática es el ámbito del ViewModel, y la fábrica de orbit-viewmodel lo toma sin que haya que pasarlo.
class BusquedaViewModel(
private val repo: BusquedaRepo,
) : ContainerHost<BusquedaState, BusquedaEffect>, ViewModel() {
override val container = container<BusquedaState, BusquedaEffect>(
initialState = BusquedaState(),
buildSettings = {
intentDispatcher = Dispatchers.Default
repeatOnSubscribedStopTimeout = 500L
},
onCreate = { observarHistorial() },
)
}
Ese ámbito sobrevive a los cambios de configuración y se cancela cuando la pantalla se retira definitivamente, lo que da a los intents exactamente la duración de la unidad de interfaz a la que pertenecen. La consecuencia que más tranquiliza es negativa: no existe ninguna corrutina de este feature capaz de sobrevivir a su destrucción, y por tanto no existe la clase de fuga en que un trabajo olvidado sigue actualizando algo que ya no está.
De ahí sale también la regla para decidir qué no debe vivir aquí. Si un trabajo tiene que completarse aunque el usuario abandone la pantalla —subir un archivo, enviar un pedido, sincronizar— atarlo al ámbito de la pantalla es un error de ubicación. Ese trabajo pertenece a un ámbito de mayor duración, sea un repositorio con ámbito propio o un planificador del sistema, y el intent correcto se limita a encolarlo y a reflejar en el estado que quedó encolado.
En un módulo compartido sin ViewModel, la fábrica genérica recibe el ámbito de forma explícita. Eso obliga a decidir quién lo crea y quién lo cancela, y la respuesta correcta casi siempre es que lo posea la misma pieza que posee la instancia de la lógica. Un ámbito global es cómodo un día y una fuga garantizada al siguiente, porque desacopla la vida del trabajo de la vida de la pantalla que lo pidió.
El dispatcher: en qué hilo empieza la lógica
Los ajustes del container incluyen el dispatcher que se usa al empezar a ejecutar cada intent. Por defecto no es el principal, y esa decisión evita el fallo clásico de bloquear la interfaz con trabajo pesado escrito ingenuamente en el cuerpo del bloque. La implicación directa es que no debes asumir en qué hilo estás dentro de un intent: si una operación exige el principal, cámbialo explícitamente para esa operación.
Conviene separar tres responsabilidades que se confunden con facilidad. El dispatcher del container decide dónde arranca el bloque. Las funciones suspendibles bien escritas de las capas inferiores ya cambian de contexto internamente si lo necesitan, de modo que envolver una llamada de repositorio en un cambio de contexto suele ser redundante. Y las mutaciones del estado no dependen del hilo en absoluto, porque su serialización la garantiza la exclusión del reductor y no la afinidad a un hilo concreto.
El motivo más fuerte para tocar este ajuste no es el rendimiento sino la comprobabilidad. Un dispatcher de pruebas con planificador controlado convierte un sistema concurrente en uno determinista: puedes avanzar el tiempo virtual a voluntad, comprobar el estado en instantes intermedios y afirmar sobre la secuencia exacta de emisiones sin esperas reales ni tolerancias arbitrarias.
@Test
fun `el antirrebote descarta las pulsaciones intermedias`() = runTest {
val vm = BusquedaViewModel(repoFalso).test(this, BusquedaState())
vm.testIntent { escribir("ho") }
advanceTimeBy(100) // aun dentro de la ventana de espera
vm.testIntent { escribir("hola") }
advanceUntilIdle() // se agota el tiempo virtual
// solo la ultima pulsacion llego a consultar el repositorio
}
Un retardo de trescientos milisegundos no cuesta trescientos milisegundos de prueba: cuesta cero, porque el planificador salta el tiempo en vez de esperarlo. Esa propiedad cambia lo que es razonable comprobar, y de golpe se vuelven baratas las pruebas de comportamientos temporales —antirrebote, reintentos con espera creciente, tiempos de espera agotados— que sin ella eran lentas y frágiles.
En producción
Deja el valor por defecto salvo que tengas una razón medida. Cambiarlo al principal para arreglar un problema de hilo casi siempre esconde una llamada bloqueante mal ubicada.
En pruebas
Sustitúyelo por un dispatcher con planificador de prueba. Ganas ejecución determinista, control del tiempo virtual y aserciones sobre estados intermedios sin ninguna espera real.
repeatOnSubscription: trabajar solo mientras alguien mira
Hay un tipo de trabajo que no encaja en el modelo de un intent que empieza y termina: la observación continua de una fuente que emite indefinidamente, como una consulta reactiva a la base de datos o una conexión de tiempo real. Si lo lanzas en onCreate, se queda vivo mientras viva el ViewModel, incluso con la pantalla en segundo plano durante minutos, consumiendo batería y datos por nadie.
repeatOnSubscription resuelve exactamente eso. Es un bloque disponible dentro de un intent cuyo contenido se ejecuta mientras haya al menos un coleccionista del estado, y que se cancela cuando el número de coleccionistas cae a cero.
private fun observarHistorial() = intent {
repeatOnSubscription {
repo.historial()
.collect { entradas -> reduce { state.copy(historial = entradas) } }
}
}
La sutileza que hace usable el mecanismo es el margen temporal. Al girar el dispositivo, la vista se destruye y se recrea, y el conteo de suscriptores pasa por cero durante unos milisegundos. Sin margen, cada rotación cancelaría y reabriría la conexión, con el coste y los parpadeos que eso implica. El ajuste de espera antes de detener —quinientos milisegundos es un valor razonable— absorbe esa transición: el conteo baja a cero, arranca el temporizador, la vista vuelve a suscribirse antes de que expire y el trabajo nunca se interrumpe.
stateDiagram-v2 [*] --> SinObservadores SinObservadores --> Activo: aparece el primer coleccionista Activo --> EnGracia: el conteo baja a cero EnGracia --> Activo: vuelve un coleccionista antes del timeout EnGracia --> SinObservadores: expira el timeout y se cancela el bloque
Hay que distinguir con cuidado este mecanismo del ámbito raíz, porque resuelven problemas distintos y a veces se confunden. El ámbito decide cuándo muere todo; repeatOnSubscription decide cuándo se pausa un trabajo concreto. Un intent puntual que guarda un formulario no debe ir dentro de este bloque, porque si el usuario abandona la pantalla mientras se guarda, el trabajo se cancelaría a mitad. El bloque es para observación continua cuyo resultado solo tiene sentido si hay alguien mirando.
SavedState: sobrevivir a la muerte del proceso
El ámbito del ViewModel protege del cambio de configuración, no de la muerte del proceso. Cuando el sistema mata la aplicación en segundo plano para liberar memoria, todo el estado en memoria desaparece, y al volver el usuario espera encontrar su pantalla como la dejó. Orbit ofrece una variante de la fábrica que persiste el estado en el paquete de estado guardado y lo restaura de forma transparente.
class FormularioViewModel(
savedStateHandle: SavedStateHandle,
) : ContainerHost<FormularioState, FormularioEffect>, ViewModel() {
override val container = container<FormularioState, FormularioEffect>(
initialState = FormularioState(),
savedStateHandle = savedStateHandle,
)
}
El funcionamiento es directo: cada mutación guarda el estado resultante en el manejador, y al reconstruirse el ViewModel tras la muerte del proceso, el container arranca con el estado restaurado en vez de con el inicial. Pero la comodidad tiene condiciones que hay que entender antes de adoptarla en todas partes.
La primera es de tipo: el estado debe ser serializable en el formato que el manejador admite, lo que en la práctica obliga a que la clase de estado y todo lo que contenga sean tipos persistibles. Un estado que guarde una referencia a un objeto vivo, un manejador de recursos o una función no puede persistirse, y esa restricción es una buena presión de diseño: empuja a que el estado sea datos y no infraestructura.
La segunda es de tamaño. El paquete de estado guardado no es un almacén general y tiene un límite práctico severo; meter una lista de mil elementos ahí es la receta de un fallo por transacción demasiado grande, que además se manifiesta en dispositivos concretos y en situaciones difíciles de reproducir. La regla es persistir lo irrecuperable —el texto que el usuario escribió, la selección que hizo, el paso del asistente en que estaba— y volver a pedir lo demás.
Cuando el estado de una pantalla mezcla ambas cosas, y casi siempre las mezcla, la solución es dividirlo. Un container persistente puede custodiar la parte pequeña e irrecuperable mientras la parte voluminosa se recarga desde el repositorio en onCreate; o, más limpiamente, el estado se modela con una subestructura explícita para lo persistible.
data class AsistenteState(
val entrada: EntradaUsuario = EntradaUsuario(), // persistible y pequeno
val catalogo: List<Opcion> = emptyList(), // recargable, no se guarda
val cargando: Boolean = true,
)
Nombrar la frontera dentro del propio estado tiene una ventaja que va más allá del límite de tamaño: convierte en visible una decisión que de otro modo queda enterrada en la configuración. Cualquiera que lea la clase sabe qué sobrevive a la muerte del proceso y qué no, sin tener que abrir la fábrica ni recordar las reglas del sistema operativo.
Guardar en el estado guardado datos que se pueden volver a leer del repositorio duplica una fuente de verdad y crea una desincronización silenciosa: al restaurar, la pantalla muestra información antigua que ninguna capa considera suya. Lo persistido debe ser aquello que solo existía en la memoria de la pantalla. Todo lo que tenga origen en una capa de datos debe recargarse desde ella al volver, y el onCreate es el sitio para hacerlo.
Es tentador leer estos parámetros como opciones y no lo son: son las cuatro preguntas que cualquier arquitectura de interfaz debe responder, expuestas en un solo sitio en vez de repartidas por el código como decisiones implícitas. La primera es la de la duración —cuánto vive el trabajo que esta pantalla inició— y su respuesta define qué es una fuga en este proyecto. La segunda es la de la ejecución —dónde corre lo que escribo— y su respuesta define qué puede bloquear la interfaz y qué se puede hacer determinista en una prueba. La tercera es la de la relevancia —qué debe dejar de ocurrir cuando nadie mira— y su respuesta define el consumo de batería y datos de una aplicación real, que casi siempre pasa más tiempo en segundo plano que en primero. La cuarta es la de la memoria —qué debe sobrevivir a que el sistema nos borre— y su respuesta define si el usuario pierde su trabajo cuando atiende una llamada. Ninguna de las cuatro es evitable, y la diferencia entre una arquitectura madura y una improvisada no está en las respuestas concretas, que dependen del producto, sino en si las respuestas están escritas en un sitio revisable o dispersas en cien decisiones tomadas de una en una por gente distinta a lo largo de dos años. Cuando la fábrica del container concentra las cuatro en un bloque de configuración, convierte lo que en otras arquitecturas es folclore oral del equipo en código que alguien puede leer, discutir en una revisión y cambiar en un solo sitio. Esa es la forma final de la idea que recorre todo el nivel: Orbit no te hace más rápido escribiendo lógica, te obliga a decir en voz alta lo que ya estabas decidiendo.
- Localiza en tu proyecto un trabajo que hoy vive en el ámbito de una pantalla y que debería sobrevivirla. Muévelo y describe qué queda en el intent.
- Sustituye el dispatcher por uno de prueba y escribe una aserción sobre un estado intermedio que antes era imposible de comprobar sin esperas.
- Envuelve una observación continua en
repeatOnSubscriptiony comprueba, con registros, qué ocurre al rotar el dispositivo con y sin margen temporal. - Añade SavedState a una pantalla con formulario, mata el proceso desde las herramientas de desarrollo y verifica qué se restaura y qué se recarga.
- Justifica campo por campo qué parte de tu estado merece persistirse y qué parte debe volver a pedirse, aplicando la distinción entre persistir y cachear.