¿Por qué coroutines?

En Android, el hilo principal (main thread) es responsable de dibujar la UI: si bloqueás ese hilo más de 16ms haciendo una llamada de red o una query a la base de datos, la app se traba. La solución clásica eran callbacks y AsyncTask — ambos dolorosos y propensos a bugs.

Las coroutines de Kotlin permiten escribir código asíncrono de forma secuencial, como si fuera sincrónico. Sin callbacks anidados, sin boilerplate. Y son livianas: podés tener miles de coroutines corriendo simultáneamente con un costo mínimo comparado con threads del sistema operativo.

// Sin coroutines — callback hell
api.getUser(id) { user ->
    db.saveUser(user) {
        ui.showUser(user) // ¿Y si la Activity ya murió?
    }
}

// Con coroutines — lineal, legible
val user = api.getUser(id)   // suspende, no bloquea
db.saveUser(user)            // suspende, no bloquea
ui.showUser(user)            // en el hilo principal

suspend functions

Una función marcada con suspend puede ser pausada y resumida sin bloquear el hilo en el que corre. Solo puede ser llamada desde otra función suspend o desde dentro de una coroutine.

// Una suspend function — puede pausarse durante la espera
suspend fun obtenerUsuario(id: Int): Usuario {
    return apiService.getUser(id) // suspende acá hasta que llega la respuesta
}

// Función normal — NO puede llamar a suspend directamente
fun mostrarUsuario(id: Int) {
    val user = obtenerUsuario(id) // ERROR de compilación
}

// Necesitás lanzar una coroutine primero
fun mostrarUsuario(id: Int) {
    viewModelScope.launch {
        val user = obtenerUsuario(id) // OK: estamos dentro de una coroutine
        binding.tvNombre.text = user.nombre
    }
}

La magia de suspendEl compilador transforma las funciones suspend en una máquina de estados bajo el capó. No hay threads adicionales creados — la coroutine se "estaciona" en un punto de suspensión y el thread queda libre para hacer otras cosas.

CoroutineScope

Toda coroutine vive dentro de un scope. El scope define el ciclo de vida de la coroutine: si el scope se cancela, todas sus coroutines hijas se cancelan también. Esto garantiza que no haya leaks.

// viewModelScope — ligado al ciclo de vida del ViewModel
// Se cancela automáticamente cuando el ViewModel se destruye
viewModelScope.launch {
    val datos = repository.obtenerDatos()
    _uiState.value = UiState.Success(datos)
}

// lifecycleScope — ligado al Fragment/Activity
// Se cancela cuando el componente es destruido
lifecycleScope.launch {
    // código corto de UI
}

// Nunca uses GlobalScope en producción
// GlobalScope.launch { ... } // MAL: vive para siempre, no respeta el ciclo de vida

Dispatchers — en qué hilo corre cada cosa

El Dispatcher determina en qué hilo o pool de threads ejecuta la coroutine:

// Dispatchers.Main — hilo principal de Android. Para UI.
// Es el default en viewModelScope y lifecycleScope.
withContext(Dispatchers.Main) {
    binding.tvEstado.text = "Listo"
}

// Dispatchers.IO — pool optimizado para operaciones de I/O:
// red, base de datos, archivos. Hasta 64 threads simultáneos.
withContext(Dispatchers.IO) {
    val datos = apiService.getDatos()  // llamada de red
    database.dao().insertar(datos)     // Room
}

// Dispatchers.Default — pool para CPU-intensivo:
// parsing de JSON grande, ordenamiento, cálculos complejos.
withContext(Dispatchers.Default) {
    val resultado = procesarDatosComplejos(lista)
}

// Ejemplo real: cambiar de dispatcher dentro de una suspend function
suspend fun cargarYGuardar(): List<Producto> {
    val productos = withContext(Dispatchers.IO) {
        apiService.getProductos()  // red
    }
    return productos
}

Room y Retrofit ya manejan el dispatcherSi usás room-ktx y Retrofit con suspend, ambas librerías cambian al dispatcher correcto internamente. No necesitás envolver cada llamada en withContext(Dispatchers.IO) — aunque hacerlo no hace daño.

launch vs async/await

// launch — "fire and forget". No retorna un valor útil.
// Retorna un Job que podés cancelar.
val job = viewModelScope.launch {
    repository.sincronizar()
}
job.cancel() // podés cancelarlo si hace falta

// async — lanza una coroutine que retorna un Deferred<T>
// Usalo cuando necesitás el resultado, especialmente en paralelo.
viewModelScope.launch {
    // Ejecución secuencial — una espera a la otra:
    val usuario = repository.getUsuario(id)         // 500ms
    val pedidos = repository.getPedidos(usuario.id) // 300ms
    // Total: 800ms

    // Ejecución paralela con async — mucho más eficiente:
    val usuarioDeferred = async { repository.getUsuario(id) }         // inicia
    val pedidosDeferred = async { repository.getPedidosGlobales() }   // inicia en paralelo
    val usuario = usuarioDeferred.await()   // espera resultado
    val pedidos = pedidosDeferred.await()   // ya puede estar listo
    // Total: ~500ms (el más lento de los dos)
}

Manejo de errores

// try/catch dentro de la coroutine — el más directo
viewModelScope.launch {
    try {
        val datos = repository.obtenerDatos()
        _uiState.value = UiState.Success(datos)
    } catch (e: IOException) {
        _uiState.value = UiState.Error("Sin conexión")
    } catch (e: HttpException) {
        _uiState.value = UiState.Error("Error del servidor: ${e.code()}")
    }
}

// CoroutineExceptionHandler — para errores no capturados en launch
val handler = CoroutineExceptionHandler { _, throwable ->
    Log.e("VM", "Error no capturado", throwable)
    _uiState.value = UiState.Error(throwable.message ?: "Error")
}

viewModelScope.launch(handler) {
    val datos = repository.obtenerDatos()
    _uiState.value = UiState.Success(datos)
}

async y excepcionesSi usás async, las excepciones no se lanzan al crear el Deferred — se lanzan cuando llamás a await(). Siempre envolvé el await() en try/catch, o usá supervisorScope para que el fallo de un async no cancele a los demás.

Cancelación cooperativa

Las coroutines son cooperativamente cancelables: el código debe cooperar para que la cancelación funcione. Las funciones suspend de las librerías (Room, Retrofit, delay) ya son cancelables. Tu propio código CPU-intensivo debe chequear isActive:

viewModelScope.launch {
    for (item in listaGrande) {
        if (!isActive) return@launch  // revisar antes de cada iteración pesada
        procesarItem(item)
    }
}

// O usar ensureActive() que lanza CancellationException si fue cancelado:
viewModelScope.launch {
    for (item in listaGrande) {
        ensureActive()
        procesarItem(item)
    }
}

CancellationException es especialNunca atrapes CancellationException con un catch genérico sin relanzarla. Si lo hacés, la coroutine no va a poder cancelarse correctamente. Siempre relanzá: catch (e: Exception) { if (e is CancellationException) throw e; ... }