¿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; ... }