Concurrencia y tareas en segundo plano con Coroutines y Flow

Capítulo 9

Tiempo estimado de lectura: 9 minutos

+ Ejercicio

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.

Continúa en nuestra aplicación.
  • Escuche el audio con la pantalla apagada.
  • Obtenga un certificado al finalizar.
  • ¡Más de 5000 cursos para que explores!
O continúa leyendo más abajo...
Download App

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 viewModelScope para trabajo ligado a pantalla y lifecycleScope para 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 collectLatest cuando el trabajo anterior ya no importa (búsqueda, autocompletado).

Evitar múltiples colecciones costosas

  • Si varios consumidores necesitan el mismo Flow, conviértelo a StateFlow con stateIn o compártelo con shareIn.
  • 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) o sample (toma muestras periódicas).
  • Usa buffer si necesitas desacoplar productor/consumidor, pero con cuidado para no acumular memoria.

Bloqueos y sincronización

  • Evita synchronized y locks pesados en código que corre en Main.
  • Para estado compartido en coroutines, considera Mutex o modelar el estado con StateFlow y actualizaciones atómicas.
  • No hagas runBlocking en UI; puede congelar la app.

Checklist rápido de implementación

NecesidadHerramientaRecomendación
Una operación de E/SCoroutine + withContext(IO)Función suspend y cambio de dispatcher
Trabajo CPUDispatchers.DefaultEvita hacerlo en Main
Datos que cambianFlow/StateFlowRepositorio expone Flow; UI recolecta
Cancelar trabajo anteriorcollectLatestIdeal para búsquedas
Compartir resultadosstateIn/shareInEvita múltiples colecciones costosas
Evitar fugasScopes de AndroidviewModelScope, lifecycleScope, no GlobalScope

Ahora responde el ejercicio sobre el contenido:

¿Cuál enfoque es el más adecuado para evitar bloquear la UI y manejar datos que se actualizan continuamente en una pantalla Android?

¡Tienes razón! Felicitaciones, ahora pasa a la página siguiente.

¡Tú error! Inténtalo de nuevo.

Las operaciones costosas deben salir del hilo principal usando withContext(Dispatchers.IO/Default). Para cambios continuos, Flow/StateFlow emite valores en el tiempo y se recolecta desde scopes con ciclo de vida (p. ej., viewModelScope) para evitar bloqueos y fugas.

Siguiente capítulo

Permisos, almacenamiento y multimedia en Android con Kotlin

Arrow Right Icon
Portada de libro electrónico gratuitaAndroid desde Cero con Kotlin
64%

Android desde Cero con Kotlin

Nuevo curso

14 páginas

Descarga la aplicación para obtener una certificación gratuita y escuchar cursos en segundo plano, incluso con la pantalla apagada.