Si alguna vez te has peleado con el famoso callback hell o has visto cómo tu aplicación se cierra inesperadamente por un crash, sabrás que gestionar la asincronía en Android puede ser un auténtico quebradero de cabeza. Las corrutinas de Kotlin llegaron para salvarnos el pellejo, permitiéndonos escribir código que parece lineal y sencillo, pero que por debajo hace magia moviendo tareas entre hilos sin que el usuario note ni un solo tirón en la pantalla.
El problema viene cuando lanzamos tareas que se quedan «colgadas» aunque la pantalla ya no exista, lo que nos lleva directos a las temidas fugas de memoria. Para evitar que la app se coma la RAM del dispositivo, necesitamos entender a la perfección dónde y cómo lanzar estas tareas. Aquí es donde entran en juego los scopes optimizados para el ciclo de vida, que se encargan de limpiar el desastre automáticamente cuando un componente muere.
Entendiendo los CoroutineScopes: ¿Quién manda aquí?
Un scope no es más que un límite que define cuánto tiempo debe vivir una corrutina. Si lanzamos algo en un ámbito que ya no es válido, la corrutina sigue ejecutándose en el vacío, consumiendo recursos y manteniendo referencias a objetos que ya deberían haber sido borrados. Para evitar esto, Android nos regala un par de herramientas ya configuradas.
El viewModelScope es la joya de la corona para la lógica de negocio. Se vincula directamente al ciclo de vida del ViewModel; es decir, que en el momento en que el ViewModel se destruye (porque el usuario salió de la pantalla definitivamente), todas las tareas que hayan empezado ahí se cancelan de golpe. Es la opción ideal para procesar datos que no deben sobrevivir a la destrucción de la vista pero que sí deben resistir cambios de configuración, como rotar la pantalla.
Por otro lado, tenemos el lifecycleScope, que está pegado al LifecycleOwner, ya sea una Activity o un Fragment. Si necesitas que una tarea se detenga exactamente cuando la Activity se destruye, este es tu sitio. Además, en el mundo de Jetpack Compose, contamos con
LaunchedEffect, para un correcto control de efectos secundarios en Compose, que crea un scope ligado a la composición del elemento. Si el componente sale de la pantalla, la corrutina se cancela, evitando que animaciones o llamadas a red sigan corriendo en segundo plano sin sentido.Dispatchers: El arte de no bloquear la interfaz
Lanzar la corrutina es solo la mitad del trabajo; ahora hay que decidir en qué hilo se ejecuta. Si intentas hacer una petición a una base de datos en el hilo principal, Android te lanzará un error o, peor aún, la interfaz se congelará y el usuario pensará que la app ha muerto.
- Dispatchers.Main: Es el hilo de la UI. Solo debes usarlo para cosas rapidísimas, como actualizar un texto o mostrar un botón. Cualquier trabajo pesado aquí es pecado capital.
- Dispatchers.IO: Está optimizado para operaciones de entrada y salida, como leer archivos, escribir en Room o hacer peticiones HTTP. Utiliza un pool de hilos elástico que permite muchas tareas simultáneas ya que la CPU no suele estar muy ocupada esperando la respuesta de la red.
- Dispatchers.Default: Es el músculo para el cálculo intensivo. Si tienes que parsear un JSON gigante o filtrar una lista de miles de elementos, este dispatcher usa un número de hilos basado en los núcleos reales de tu CPU para aprovechar el paralelismo al máximo.
La jugada maestra aquí es usar
withContext. Te permite cambiar el hilo en medio de una función suspendida. Puedes empezar en Main, saltar a IO para descargar un archivo y volver automáticamente al hilo principal para mostrar el resultado, todo sin escribir un solo callback.Concurrencia y Paralelismo: launch vs async
No todas las tareas asíncronas son iguales. A veces solo queremos que algo pase (disparar y olvidar) y otras veces necesitamos un resultado para seguir avanzando. Para esto tenemos dos constructores fundamentales.
El comando
launch es el más común. Lanza la tarea y nos devuelve un objeto Job, que es como un mando a distancia para controlar la corrutina. Con este Job podemos cancelar la tarea si el usuario decide cancelar la operación manualmente.Si necesitamos un valor de vuelta, usamos
async. Este constructor devuelve un Deferred, que es básicamente una promesa de que habrá un resultado. Lo interesante es que podemos lanzar varias tareas con async y luego llamar a await() para combinar los resultados. Si tienes 4 núcleos en tu móvil y lanzas tareas en Dispatchers.Default, verás que se ejecutan en paralelo real, reduciendo drásticamente el tiempo de espera.Flujos de datos reactivos con Flow y StateFlow
Cuando no necesitamos un único valor, sino un chorro de datos que cambian con el tiempo, pasamos a usar Flow. A diferencia de las corrutinas simples, un Flow es «cold», lo que significa que no empieza a trabajar hasta que alguien se suscribe a él mediante
collect.En el desarrollo moderno, lo ideal es convertir esos flujos en StateFlow usando el operador
stateIn dentro del ViewModel, siguiendo una guía completa de StateFlow y SharedFlow. Esto permite que la UI siempre tenga acceso al último estado emitido, incluso si el dispositivo se rota. Para consumir estos datos en Compose de forma segura, la recomendación es usar collectAsStateWithLifecycle. Esta función es clave porque detiene la recolección cuando la app pasa a segundo plano, ahorrando batería y memoria de forma inteligente.Estrategias avanzadas para un código robusto
Para que nuestra app no sea un castillo de naipes, debemos gestionar los errores. En las corrutinas, las excepciones se propagan hacia arriba. Si un hijo falla, el padre también cae, a menos que utilicemos un SupervisorJob. Esto es vital cuando lanzamos tareas independientes donde el fallo de una descarga no debería cancelar la descarga de las demás.
Además, hay que recordar que la cancelación es cooperativa. Si tienes un bucle que procesa millones de datos, la corrutina no se detendrá mágicamente aunque canceles el scope; debes llamar a
ensureActive() o comprobar isActive para que la corrutina sepa que debe morir y libere los recursos.El uso correcto de los scopes de Android, la elección del dispatcher adecuado y la implementación de flujos reactivos constituyen la base de cualquier aplicación profesional. Al delegar la gestión del ciclo de vida a
viewModelScope y lifecycleScope, y mover el trabajo pesado a los hilos de IO o Default, conseguimos una experiencia de usuario fluida, sin bloqueos y, sobre todo, libre de fugas de memoria que comprometan el rendimiento del dispositivo.Continúar leyendo...