Qué problema resuelven Coroutines y Flow
En Android, muchas tareas no deben ejecutarse en el hilo principal (main thread): leer/escribir disco, hacer cálculos pesados, esperar respuestas de red o escuchar cambios continuos (por ejemplo, datos que se actualizan). Las coroutines permiten escribir código asíncrono de forma secuencial y segura, y Flow modela flujos de valores a lo largo del tiempo (0..N emisiones), ideal para UI reactiva.
Idea clave: coroutines para “hacer una tarea” (una operación suspendible) y Flow para “recibir actualizaciones” (stream de datos).
Dispatchers: dónde se ejecuta el trabajo
Un dispatcher decide en qué hilos se ejecuta una coroutine.
Dispatchers.Main: UI. Solo trabajo ligero (actualizar estado, preparar datos pequeños).Dispatchers.IO: E/S (disco, sockets). Adecuado para operaciones bloqueantes.Dispatchers.Default: CPU (parseo pesado, compresión, cálculos).Dispatchers.Unconfined: casos especiales; evita usarlo en apps normales.
Patrón típico: iniciar en Main (por ejemplo desde ViewModel) y cambiar a IO/Default con withContext para el trabajo pesado.
suspend fun loadFromDisk(path: String): String = withContext(Dispatchers.IO) { // Operación de E/S (bloqueante o costosa) java.io.File(path).readText()}Scopes: quién “posee” la coroutine
Un CoroutineScope define el ciclo de vida de las coroutines que lanza. En Android, lo importante es que el scope se cancele cuando el componente ya no se usa, para evitar fugas de memoria y trabajo innecesario.
- Escuche el audio con la pantalla apagada.
- Obtenga un certificado al finalizar.
- ¡Más de 5000 cursos para que explores!
Descargar la aplicación
viewModelScope: se cancela al limpiar el ViewModel.lifecycleScope: se cancela con el LifecycleOwner (Activity/Fragment).rememberCoroutineScope(): scope ligado a la composición actual en Compose (útil para eventos de UI).
Regla práctica: no crees scopes manuales con CoroutineScope(Dispatchers.Main) dentro de UI o repositorios, salvo casos muy controlados; usa los scopes provistos por la plataforma.
Suspending functions: ejemplos cortos y útiles
1) Ejecutar E/S sin bloquear la UI
Una función suspend puede pausar sin bloquear el hilo. Para E/S, cambia a Dispatchers.IO.
class UserCache(private val dir: java.io.File) { suspend fun saveUserJson(userId: String, json: String) = withContext(Dispatchers.IO) { java.io.File(dir, "$userId.json").writeText(json) } suspend fun readUserJson(userId: String): String? = withContext(Dispatchers.IO) { val f = java.io.File(dir, "$userId.json") if (f.exists()) f.readText() else null }}2) Paralelismo controlado con async/await
Si necesitas dos tareas independientes (por ejemplo, leer dos fuentes) puedes ejecutarlas en paralelo dentro de un scope estructurado.
suspend fun loadTwoFiles(a: String, b: String): Pair<String, String> = coroutineScope { val fa = async(Dispatchers.IO) { java.io.File(a).readText() } val fb = async(Dispatchers.IO) { java.io.File(b).readText() } fa.await() to fb.await()}Nota: coroutineScope asegura concurrencia estructurada: si una falla, el resto se cancela (salvo que uses supervisor).
3) timeouts y cancelación cooperativa
La cancelación en coroutines es cooperativa: el código debe suspender o comprobar el estado para detenerse. Para evitar bloqueos o esperas infinitas, usa timeouts.
suspend fun loadWithTimeout(): String = withTimeout(2_000) { // Simula espera; en código real sería una operación suspendible kotlinx.coroutines.delay(500) "OK"}Si necesitas comprobar cancelación en bucles CPU-intensivos:
suspend fun heavyLoop(): Long = withContext(Dispatchers.Default) { var acc = 0L repeat(50_000_000) { i -> if (i % 1_000_000 == 0) ensureActive() // lanza CancellationException si está cancelado acc += i } acc}Manejo de excepciones: reglas prácticas
try/catch en funciones suspendibles
Cuando llamas a una función suspendible, puedes capturar excepciones igual que siempre. Importante: CancellationException indica cancelación y normalmente no debe tragarse.
suspend fun safeRead(path: String): String? = try { withContext(Dispatchers.IO) { java.io.File(path).readText() }} catch (e: java.io.IOException) { null} catch (e: CancellationException) { throw e}CoroutineExceptionHandler (para coroutines “fire-and-forget”)
En coroutines lanzadas con launch, una excepción no capturada se propaga al scope. Puedes usar un handler para registrar o transformar errores, pero sigue siendo mejor capturar cerca de donde puedas reaccionar.
val handler = CoroutineExceptionHandler { _, throwable -> // Log, métricas, etc. println("Error: $throwable")}viewModelScope.launch(handler) { // ...}SupervisorJob para aislar fallos
En concurrencia estructurada, si un hijo falla, por defecto cancela a sus hermanos. Si quieres que un fallo no tumbe el resto, usa un supervisor (típico en capas que ejecutan tareas independientes).
suspend fun loadIndependently() = supervisorScope { val a = async { error("Falla A") } val b = async { "B" } runCatching { a.await() } b.await()}Flow: flujos de datos a lo largo del tiempo
Flow emite valores de forma asíncrona y secuencial. Es cold por defecto: no hace trabajo hasta que alguien lo recolecta (collect). Esto encaja bien con repositorios que exponen cambios y UI que reacciona.
Crear un Flow simple
fun tickerFlow(): kotlinx.coroutines.flow.Flow<Int> = flow { var n = 0 while (true) { emit(n++) kotlinx.coroutines.delay(1_000) }}Operadores comunes (map, filter, debounce, distinctUntilChanged)
val uiFlow = tickerFlow() .map { it * 2 } .filter { it % 4 == 0 } .distinctUntilChanged() .debounce(300)flowOn: mover el trabajo aguas arriba
flowOn cambia el dispatcher del trabajo upstream (antes de ese operador). Útil para parseos o lecturas dentro del flow.
fun readLinesFlow(file: java.io.File): Flow<String> = flow { file.forEachLine { emit(it) }}.flowOn(Dispatchers.IO)catch y retry: errores en Flow
catch intercepta excepciones del upstream (no las del collector). retry permite reintentos con condiciones.
val safeFlow: Flow<String> = readLinesFlow(java.io.File("x")) .retry(3) { e -> e is java.io.IOException } .catch { e -> emit("ERROR: ${e.message}") }collectLatest: cancelar trabajo anterior ante nuevas emisiones
Ideal para búsquedas: si llega un nuevo término, cancelas el procesamiento anterior.
viewModelScope.launch { queryFlow.collectLatest { query -> // Si llega otro query, este bloque se cancela automáticamente val results = doSearch(query) // actualizar estado... }}Caso integrado: Repositorio (Flow) → ViewModel (collect) → UI (reactiva)
Objetivo: un repositorio expone un Flow con el estado de una lista; el ViewModel lo convierte en estado observable para UI; la UI reacciona a cambios sin bloquear.
Paso 1: definir modelos de UI State
Representa carga, éxito y error. Esto evita que la UI tenga que inferir estados.
sealed interface ItemsUiState { data object Loading : ItemsUiState data class Success(val items: List<String>) : ItemsUiState data class Error(val message: String) : ItemsUiState}Paso 2: repositorio que expone Flow
Simularemos una fuente de datos que cambia. En un caso real, el repositorio podría combinar fuentes (memoria, disco, red) y emitir actualizaciones.
class ItemsRepository { private val _items = kotlinx.coroutines.flow.MutableStateFlow<List<String>>(emptyList()) val items: kotlinx.coroutines.flow.StateFlow<List<String>> = _items suspend fun refresh() { // Simula trabajo de E/S val newItems = withContext(Dispatchers.IO) { kotlinx.coroutines.delay(400) listOf("Uno", "Dos", "Tres") } _items.value = newItems } suspend fun addItem(name: String) { withContext(Dispatchers.Default) { // trabajo ligero de CPU (validación/normalización) val normalized = name.trim() _items.value = _items.value + normalized } }}Por qué StateFlow: mantiene el último valor y lo entrega inmediatamente a nuevos collectors, ideal para estado de UI.
Paso 3: ViewModel que recolecta y expone estado
El ViewModel transforma el flow del repositorio a un StateFlow de ItemsUiState. Usamos stateIn para compartir y cachear el último estado, evitando múltiples recolecciones costosas.
class ItemsViewModel( private val repo: ItemsRepository) : androidx.lifecycle.ViewModel() { val uiState: kotlinx.coroutines.flow.StateFlow<ItemsUiState> = repo.items .map<List<String>, ItemsUiState> { ItemsUiState.Success(it) } .onStart { emit(ItemsUiState.Loading) } .catch { e -> emit(ItemsUiState.Error(e.message ?: "Error desconocido")) } .stateIn( scope = viewModelScope, started = kotlinx.coroutines.flow.SharingStarted.WhileSubscribed(5_000), initialValue = ItemsUiState.Loading ) fun refresh() { viewModelScope.launch { try { repo.refresh() } catch (e: CancellationException) { throw e } catch (e: Exception) { // opcional: podrías exponer un evento de error } } } fun add(name: String) { viewModelScope.launch { repo.addItem(name) } }}WhileSubscribed(5_000) ayuda a evitar trabajo cuando no hay observadores (por ejemplo, pantalla en background), manteniendo un pequeño “grace period”.
Paso 4: UI en Compose que reacciona a cambios
La UI recolecta el StateFlow y renderiza según el estado. Para evitar fugas y colecciones fuera de ciclo de vida, usa collectAsStateWithLifecycle (requiere la integración de lifecycle en Compose).
@Composablefun ItemsScreen(vm: ItemsViewModel) { val state by androidx.lifecycle.compose.collectAsStateWithLifecycle(vm.uiState) when (val s = state) { is ItemsUiState.Loading -> { androidx.compose.material3.CircularProgressIndicator() } is ItemsUiState.Error -> { androidx.compose.material3.Text("Error: ${s.message}") } is ItemsUiState.Success -> { androidx.compose.foundation.lazy.LazyColumn { items(s.items.size) { idx -> androidx.compose.material3.Text(s.items[idx]) } } } }}Para acciones de usuario (botón “Refrescar”), llama a vm.refresh(). Para efectos puntuales (snackbar), suele convenir un canal de eventos separado (por ejemplo, SharedFlow), evitando mezclar eventos con estado persistente.
Pautas para evitar fugas de memoria y bloqueos
No bloquear el hilo principal
- No uses operaciones bloqueantes (I/O,
Thread.sleep, parseos pesados) en Main. - Para E/S:
withContext(Dispatchers.IO). - Para CPU:
withContext(Dispatchers.Default).
Evitar scopes “huérfanos”
- No guardes referencias a Activity/Composable dentro de repositorios o coroutines de larga vida.
- Evita
GlobalScope: no se cancela automáticamente y puede filtrar trabajo y referencias. - Usa
viewModelScopepara trabajo ligado a pantalla ylifecycleScopepara trabajo ligado a Activity/Fragment.
Cancelación correcta
- Si capturas excepciones, vuelve a lanzar
CancellationException. - En bucles largos, llama a
ensureActive()o usa puntos de suspensión frecuentes. - Prefiere
collectLatestcuando el trabajo anterior ya no importa (búsqueda, autocompletado).
Evitar múltiples colecciones costosas
- Si varios consumidores necesitan el mismo Flow, conviértelo a
StateFlowconstateIno compártelo conshareIn. - En UI, usa recolección con lifecycle (
collectAsStateWithLifecycle) para no seguir recolectando en background.
Backpressure y control de frecuencia
- Si el upstream emite muy rápido, usa
debounce(espera estabilidad) osample(toma muestras periódicas). - Usa
buffersi necesitas desacoplar productor/consumidor, pero con cuidado para no acumular memoria.
Bloqueos y sincronización
- Evita
synchronizedy locks pesados en código que corre en Main. - Para estado compartido en coroutines, considera
Mutexo modelar el estado conStateFlowy actualizaciones atómicas. - No hagas
runBlockingen UI; puede congelar la app.
Checklist rápido de implementación
| Necesidad | Herramienta | Recomendación |
|---|---|---|
| Una operación de E/S | Coroutine + withContext(IO) | Función suspend y cambio de dispatcher |
| Trabajo CPU | Dispatchers.Default | Evita hacerlo en Main |
| Datos que cambian | Flow/StateFlow | Repositorio expone Flow; UI recolecta |
| Cancelar trabajo anterior | collectLatest | Ideal para búsquedas |
| Compartir resultados | stateIn/shareIn | Evita múltiples colecciones costosas |
| Evitar fugas | Scopes de Android | viewModelScope, lifecycleScope, no GlobalScope |