Corrutinas Kotlin en Android

Corrutinas Kotlin en Android

Os dejo un vídeo explicativo de resumen y lo amplío en la descripción a continuación. Al final en el apartado de referencias del apartado de corrutinas tenéis más información para ampliar y complementar lo aquí expuesto.

Una de las grandes ventajas para el programador al hacer uso de corrutinas y las funciones suspend es que la lógica o el flujo de nuestro programa se asemeja a una programación lineal, donde una linea llama a una función y obtiene un resultado, la linea siguiente usa ese resultado, pero el resultado se ha obtenido en una operación asíncrona.

De esta forma no tenemos que definir funciones callback tan habituales en la programación con Android Views. Ahora con Compose y Corrutinas, aunque inicialmente es mas lío si conoces el funcionamiento anterior, todo se simplifica y finalmente es más intuitivo.

📦Coroutine Builders

BuilderDescripción
runBlockingBloquea el hilo actual hasta que termina la corrutina. Útil solo para tests y main en programas kotlin.
launchLanza una corrutina que no devuelve resultado pero puede retornar el handler de un Job.
asyncLanza una corrutina que devuelve un resultado futuro (Deferred<T>).
Se espera con await().

🧭 Scopes: ¿Dónde lanzamos las corrutinas?

Un scope define el contexto y ciclo de vida de una corrutina. Según el componente Android, usaremos uno distinto:

Componente AndroidScope recomendadoMotivo
ApplicationGlobalScopeVive toda la vida de la app
(ojo con memory leaks).
Activity / FragmentlifecycleScopeAtado al ciclo de vida de la Activity.
ViewModelviewModelScopeVive mientras viva el ViewModel.

Ejemplo (en Activity):

lifecycleScope.launch {
    val result = withContext(Dispatchers.IO) {
        DataProvider.doHeavyTask()
    }
    println(result)
}

⚙️ Dispatchers: ¿En qué hilo se ejecuta?

Los dispatchers deciden en qué tipo de hilo se ejecuta la corrutina:

DispatcherUso principal
Dispatchers.MainUI: actualizar vistas, interactuar con el usuario.
Dispatchers.IOEntrada/Salida: archivos, red, base de datos.
Dispatchers.DefaultTareas intensivas en CPU (sorting, JSON parsing, etc).

Regla general en Android

  • La corrutina se lanza en Dispatchers.Main (por defecto en lifecycleScope/viewModelScope).
  • Las funciones de suspensión pesadas deben ejecutarse en Dispatchers.IO o Default usando withContext.

Ejemplo:

viewModelScope.launch {
    val datos = withContext(Dispatchers.IO) {
        obtenerDatosDesdeInternet()
    }
    actualizarUI(datos) // Aquí sigue en el hilo principal
}

🛠️ Controlando múltiples tareas

🔁 Concurrente (con async)

val resultado1 = async { tareaLenta1() }
val resultado2 = async { tareaLenta2() }

val suma = resultado1.await() + resultado2.await()
  • async devuelve un Deferred.
  • await() suspende la corrutina hasta que el resultado esté listo.
  • Si se ejecutan en Dispatchers.Default o IO, pueden correr en paralelo si hay núcleos disponibles.

Paralelismo

Dos hilos pueden estar activos, pero eso no significa que se estén ejecutando simultáneamente.

🔸 Por ejemplo:

  • En un sistema con 1 solo núcleo (CPU), solo 1 hilo puede ejecutarse a la vez.
  • El sistema operativo intercambia entre hilos rápidamente (context switching), dando la ilusión de que están trabajando al mismo tiempo (concurrencia), pero no es paralelismo real.

🔍 Ejecución real de hilos

🔹 ¿Qué pasa cuando lanzas varios hilos?

  1. Si tienes 4 hilos y 1 núcleo → se ejecutan concurrentemente (el sistema los intercambia).
  2. Si tienes 4 hilos y 4 núcleos → pueden ejecutarse realmente en paralelo.
  3. Si tienes 10 hilos y 4 núcleos → hay paralelismo parcial (4 a la vez), el resto espera (concurrencia).
fun main() = runBlocking {
    repeat(10) { i ->
        launch(Dispatchers.Default) {
            println("Comienza tarea $i en hilo ${Thread.currentThread().name}")
            delay(1000)
            println("Termina tarea $i")
        }
    }
}
  • Dispatchers.Default usa un pool de hilos igual al número de núcleos.
  • Si tu máquina tiene 4 núcleos, verás que 4 tareas se ejecutan en paralelo, el resto se intercalan.
  • Todos los hilos son distintos, pero no todos se ejecutan al mismo tiempo.

🧠 ¿Se ejecutan en núcleos distintos?

Depende del dispatcher usado:

DispatcherQué hace
Dispatchers.DefaultUsa un pool de hilos múltiples, puede usar varios núcleos. ✅ Paralelo
Dispatchers.IOPara tareas de entrada/salida (acceso a red, ficheros). ✅ Paralelo
Dispatchers.MainSolo un hilo (el de UI). ❌ No paralelo
Sin especificarHereda el dispatcher del coroutineScope padre

🔍 Si estás en runBlocking sin dispatcher, por defecto usas un solo hilo. Si usas Dispatchers.Default, hay paralelismo real (si hay nucleos suficientes)

Cuidado: async ≠ automático en hilos distintos

val a = async { ... }  // No garantiza que sea en otro hilo
  • Si estás en Dispatchers.Main, se ejecuta en el mismo hilo que tú.
  • Para asegurarte de que es paralelo, debes usar withContext(Dispatchers.Default) o async(Dispatchers.Default).

Ejemplo paralelo real

runBlocking {
    val tiempo = measureTimeMillis {
        val a = async(Dispatchers.Default) {
            delay(1000); 10
        }
        val b = async(Dispatchers.Default) {
            delay(1000); 20
        }

        println("Resultado: ${a.await() + b.await()}")
    }
    println("Tiempo: $tiempo ms")
}

⏱️ Esto se ejecutará en ~1000 ms (porque las dos corrutinas corren en paralelo).

📌 Resumen

PreguntaRespuesta
¿async ejecuta en otro hilo automáticamente?❌ No necesariamente. Depende del Dispatcher.
¿Es concurrente?✅ Siempre. Se ejecutan en paralelo lógico.
¿Es paralelo?✅ Solo si usas un Dispatcher con múltiples hilos (Default, IO, etc.).
¿await() bloquea?❌ No bloquea el hilo. Solo suspende hasta que el resultado esté disponible.

🧩 Ejemplo completo con withContext, async, launch

viewModelScope.launch {
    val datos = withContext(Dispatchers.IO) {
        descargarDatos()
    }

    val procesado = async(Dispatchers.Default) {
        procesarDatos(datos)
    }

    actualizarUI(procesado.await())
}

🧬 Ciclo de vida: Job, join(), cancel()

Cada corrutina lanzada con launch o async devuelve un objeto Job.

Métodos clave:

MétodoQué hace
job.join()Suspende hasta que ese Job termine (función de suspensión).
job.cancel()Cancela el job y todas sus tareas hijas.
val job = viewModelScope.launch {
    val res1 = suspendingTask1()
    val res2 = suspendingTask2()
    println("Resultado: ${res1 + res2}")
}

job.cancel() // cancela todo si aún está en ejecución</code>

🧪 ¿Y qué pasa con runBlocking?

fun main() {
    runBlocking {
        val datos = obtenerDatos()
        println(datos)
    }
}
  • Solo se usa en tests o funciones main de consola.
  • Bloquea el hilo actual (al contrario que launch o async).
  • Útil para probar funciones suspend.

✅ Buenas prácticas en Android

  • No uses GlobalScope salvo casos muy justificados.
  • Usa lifecycleScope y viewModelScope para evitar fugas de memoria.
  • Ejecuta tareas pesadas con withContext(Dispatchers.IO) o Default.
  • Usa launch para tareas sin retorno y async cuando necesites un valor.
  • Recuerda: async lanza concurrentemente, pero el paralelismo depende del dispatcher y núcleos disponibles.
  • Tenemos varios Scopes, Application, Activity, ViewModel, … es importante diferenciar en qué ámbito definimos/lanzamos la corrutina.
  • Cada Scope tiene un dispatcher por defecto.
    El Dispatcher.default es el por defecto para el GlobalScope.
    Este dispatcher coloca la corrutina en un hilo diferente del hilo principal.
    En Kotlin, todas las corrutinas se deben ejecutar en un despachador, incluso cuando se ejecutan en el subproceso principal.
    La corrutinas se pueden suspender a sí mismas, y el despachador es responsable de reanudarlas.
    Para especificar en qué lugar deberían ejecutarse las corrutinas, Kotlin proporciona tres despachadores que puedes utilizar:
    • Dispatcher.Main: Utiliza este despachador para ejecutar una corrutina en el Main Thread de Android.
      Solo debes usar este despachador para interactuar con la IU y realizar trabajos rápidos.
    • Dispatcher.Default: Este despachador está optimizado para realizar trabajo que usa la CPU de manera intensiva fuera del Main Thread. Puede usar tantos subprocesos como cores tenga la CPU. Ya que estas son tareas intensivas, no tiene sentido tener más ejecuciones al mismo tiempo, porque la CPU estará ocupada. Algunos casos prácticos de ejemplo son clasificar una lista y analizar JSON.
    • Dispatcher.IO: Este despachador está optimizado para realizar E/S de disco o red fuera del subproceso principal. ya que no usan la CPU, se puede tener muchas en ejecución al mismo tiempo. Las Apps de Android, lo que más hacen, es interactuar con el dispositivo y hacer peticiones de red, por lo que probablemente usarás este la mayoría del tiempo. Algunos ejemplos incluyen usar el componente Room, leer desde archivos o escribir en ellos, y ejecutar operaciones de red.
  • Lo que querremos en Android es que la corrutina se ejecute en el hilo principal, pero que las funciones de suspensión que se lancen desde la corrutina se ejecuten en hilos separados.
    Esto es así para que la corrutina tenga acceso a todos los componentes del hilo principal y pueda actualizar datos de éstos con los resultados que vienen de las funciones de suspensión.
  • Con withContext(Dispatcher){código} podemos decidir que parte de código dentro de una corrutina se ejecuta en el scope asociado al dispatcher que se pasa como parámetro.
    withContext() devuelve el retorno que tenga la función o código dentro de el, bloqueando la corrutina hasta que obtiene el resultado, pero liberando el hilo principal.
    Veamos el ejemplo (usado en Application):
GlobalScope.launch(Dispatcher.Main){ //this:CoroutineScope
  val result = withContext(Dispatcher.IO){
                  DataProvider.DoHeavyTask()
               }
  println(result)
}
  • Cuando queremos realizar algo a nivel de Activity necesitamos un Scope a nivel de Activity para que cuando muera la activity, el proceso muera también.
    El objeto CoroutineScope realiza un seguimiento de cualquier corrutina que crea mediante los elementos launch o async.
    Android proporcina para la Activity el lifecycleScope y para el ViewModel el viewModelScope
    Veamos el ejemplo (usado en Activity y en ViewModel): (No hace falta indicar el Dispatcher.Main en el launch porque por defecto usan ese.
//Para usar en una clase Activity
lifecycleScope.launch{ //this:CoroutineScope
  val result = withContext(Dispatcher.IO){
                  DataProvider.DoHeavyTask()
               }
  println(result)
}

//Para usar en una clase ViewModel
viewModelScope.launch{ //this:CoroutineScope
  val result = withContext(Dispatcher.IO){
                  DataProvider.DoHeavyTask()
               }
  println(result)
}
  • Builders:
    • runBlocking: Este builder bloquea el hilo actual hasta que se terminen todas las tareas dentro de esa corrutina.
      Esto va en contra de lo que queremos lograr con las corrutinas. Entonces, ¿para qué sirve?
      Es muy útil para implementar tests sobre suspending tasks. En tus tests, envuelve la suspending task que desea probar con una llamada runBlocking y podrás asertar sobre el resultado y evitar que el test finalice antes de que finalice la tarea en segundo plano.
      O para probar como hemos hecho anteriormente en el proyecto Kotlin.
      No se usará habitualmente.
    • launch: Este es el builder más usado.
      Lo utilizarás mucho porque es la forma más sencilla de crear corrutinas.
      A diferencia de runBlocking, no bloqueará el subproceso actual (si usamos los dispatchers adecuados, claro).
      Este builder siempre necesita un scope.
      launch devuelve un Job, que es otra clase que implementa CoroutineContext.
      Los jobs tienen un par de funciones interesantes que pueden ser muy útiles, las vemos más abajo.
      Pero es importante entender que un Job puede tener a su vez otro Job padre.
      Ese job padre tiene cierto control sobre los hijos, y ahí es donde entran en juego estas funciones (Join y Cancel).
    • async: async permite ejecutar varias tareas en segundo plano en paralelo.
      No es una función de suspensión en sí misma, por lo que cuando ejecutamos async, el proceso en segundo plano se inicia, pero la siguiente línea se ejecuta de inmediato.
      async siempre debe llamarse dentro de otra corrutina y devuelve un job especializado que se llama Deferred.
      Este objeto Deferred tiene una nueva función llamada await() que es la que bloquea.
      Llamaremos a await() solo cuando necesitemos el resultado.
      Si el resultado aún no esta listo, la corrutina se suspende en ese punto.
      Si ya tenemos el resultado, simplemente lo devolverá y continuará.
      De esta manera, puedes ejecutar tantas tareas en segundo plano como necesites.

Jobs y funciones

  • El elemento Job es un controlador de corrutinas.
    Cada corrutina que creas con launch o async muestra una instancia de Job que identifica de forma única la corrutina y administra su ciclo de vida.
  • job.join(): Con está función, puedes bloquear la corrutina asociada con el job hasta que todos los jobs hijos hayan finalizado.
    Todas las funciones de suspensión que se llaman dentro de una corrutina están vinculadas a job, así que el job puede detectar cuándo finalizan todos los jobs hijos y después continuar la ejecución.
    job.join() es una función de suspensión en sí misma, por lo que debe llamarse dentro de otra corrutina.
val job = GlobalScope.launch(Dispatchers.Main) {
    doCoroutineTask()
    val res1 = suspendingTask1()
    val res2 = suspendingTask2()
    process(res1, res2)
}
job.join()
  • job.cancel(): Esta función cancelará todos sus jobs hijos asociados.
    Así que, si por ejemplo mientras se está ejecutando suspendingTask1() se llama a cancel(), este no devolverá el valor a res1 y suspendingTask2() no se ejecutará nunca. Recordar que como no se ha usado async las suspendingTasks del ejemplo van en secuencial.
    job.cancel() esta es una función normal, por lo que no requiere una corrutina para ser llamada.

Algunas referencias