Coroutines y Flow en Kotlin: concurrencia asíncrona sin callbacks ni RxJava

Una coroutine es una unidad de trabajo que puede pausarse y reanudarse sin bloquear el hilo en el que corre. Eso es todo. La definición es sencilla, pero las consecuencias prácticas cambian bastante cómo se escribe el código asíncrono.

En Java tradicional, cuando haces una llamada de red o lees un fichero, el hilo se queda bloqueado esperando. Con coroutines, la ejecución se suspende en ese punto y el hilo queda libre para hacer otra cosa. Cuando llega la respuesta, la coroutine se reanuda donde lo dejó.

Las dos funciones principales para lanzar coroutines son launch y async:

launch { // lanza y olvida
    val datos = obtenerDatosDeRed() // suspend point
    mostrarEnUI(datos)
}

val resultado = async { // lanza y devuelve un valor
    calcularAlgo()
}
val valor = resultado.await() // espera aquí

La palabra clave suspend marca las funciones que pueden pausarse. Una suspend fun solo se puede llamar desde otra suspend function o desde dentro de una coroutine. El compilador de Kotlin se encarga de lo demás: transforma el código en una máquina de estados que gestiona la suspensión internamente.

La diferencia con los threads es importante. Puedes tener miles de coroutines corriendo sobre un puñado de hilos, porque mientras una está suspendida (esperando I/O, por ejemplo) el hilo está disponible para las demás. Con threads, cada uno ocupa memoria y tiempo de contexto del sistema operativo.

CoroutineScope y structured concurrency

Toda coroutine vive dentro de un scope. Cuando ese scope se cancela, todas las coroutines que lanzó se cancelan también. Esto es lo que se llama structured concurrency y es uno de los puntos donde Kotlin se diferencia de otras implementaciones.

Antes de structured concurrency, era fácil dejar coroutines huérfanas: lanzabas algo, el usuario salía de la pantalla y la coroutine seguía corriendo sin que nadie la controlase. Con scopes, el ciclo de vida es predecible.

coroutineScope {
    launch { tarea1() }
    launch { tarea2() }
    // espera a que ambas terminen antes de continuar
}

En Android tienes dos scopes ya listos:

  • viewModelScope: se cancela cuando el ViewModel se destruye. Perfecto para llamadas de red o lógica de negocio que depende del ViewModel.
  • lifecycleScope: se cancela cuando el Lifecycle owner (Activity o Fragment) se destruye. Útil para coleccionar flows en la UI.
class MiViewModel : ViewModel() {
    fun cargarDatos() {
        viewModelScope.launch {
            val datos = repositorio.obtenerDatos()
            _uiState.value = UiState.Success(datos)
        }
    }
}

No necesitas cancelar nada manualmente. El scope lo hace por ti cuando toca.

launch vs async

launch es para efectos secundarios: llamar a una API, guardar en base de datos, actualizar la UI. Devuelve un Job con el que puedes cancelar la coroutine si lo necesitas, pero no te da ningún valor de vuelta.

async es para cuando necesitas el resultado de un cálculo. Devuelve un Deferred<T> y usas await() para esperar el valor:

val usuario = async { repositorio.getUsuario(id) }
val pedidos = async { repositorio.getPedidos(id) }

// las dos llamadas corren en paralelo
val perfil = Perfil(usuario.await(), pedidos.await())

Ese patrón es muy útil cuando tienes varias llamadas independientes: en vez de ejecutarlas una detrás de otra, las lanzas en paralelo y esperas los resultados juntos. Si cada llamada tarda 300ms, el total sigue siendo 300ms en vez de 600ms.

Un error habitual es usar async cuando basta con launch, o llamar a await() justo después de async sin dejar que corra en paralelo:

// esto NO es paralelo, es secuencial
val a = async { calcularA() }.await()
val b = async { calcularB() }.await()

// esto SÍ es paralelo
val dA = async { calcularA() }
val dB = async { calcularB() }
val resultado = dA.await() + dB.await()

Flow: streams asíncronos fríos

Hasta ahora hemos hablado de operaciones únicas: haces una llamada y obtienes un resultado. Flow<T> es para cuando la fuente de datos emite varios valores a lo largo del tiempo: actualizaciones de base de datos, eventos de sensor, respuestas paginadas.

Un Flow es frío: no hace nada hasta que alguien lo colecciona. Puedes definirlo una vez y que múltiples consumidores lo activen por separado:

fun numerosFlow(): Flow<Int> = flow {
    emit(1)
    delay(100)
    emit(2)
    delay(100)
    emit(3)
}

// para consumirlo:
viewModelScope.launch {
    numerosFlow().collect { valor ->
        println(valor)
    }
}

Lo interesante viene con los operadores. Flow tiene un catálogo amplio que funciona igual que las colecciones de Kotlin, pero de forma asíncrona:

  • map: transforma cada valor emitido.
  • filter: descarta valores que no cumplan la condición.
  • take(n): colecciona solo los primeros n valores.
  • debounce(ms): espera a que pasen ms milisegundos sin nuevos valores antes de emitir. Muy útil para búsquedas en tiempo real.
  • distinctUntilChanged: evita emitir si el valor nuevo es igual al anterior.
  • combine: combina dos flows y emite cada vez que cualquiera de los dos cambia.
  • zip: combina dos flows emparejando los valores por posición.
repositorio.buscarProductos(query)
    .debounce(300)
    .distinctUntilChanged()
    .map { lista -> lista.filter { it.stock > 0 } }
    .collect { resultados ->
        _productos.value = resultados
    }

StateFlow y SharedFlow: flows calientes

A diferencia del Flow normal, StateFlow y SharedFlow son calientes: están activos independientemente de si alguien los colecciona o no.

StateFlow siempre tiene un valor actual. Es el sustituto natural de LiveData en proyectos que usan coroutines: expones un StateFlow desde el ViewModel y la UI lo colecciona.

class MiViewModel : ViewModel() {
    private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
    val uiState: StateFlow<UiState> = _uiState.asStateFlow()

    fun cargar() {
        viewModelScope.launch {
            val datos = repositorio.getDatos()
            _uiState.value = UiState.Success(datos)
        }
    }
}

La regla habitual: expones la versión inmutable (StateFlow) y guardas la mutable (MutableStateFlow) como privada. Así la UI no puede modificar el estado directamente.

SharedFlow es más flexible: no tiene valor inicial, puede configurar un replay para que los nuevos suscriptores reciban los últimos n valores, y puede tener múltiples suscriptores activos. Viene bien para eventos de un solo uso: navegación, snackbars, errores puntuales.

private val _eventos = MutableSharedFlow<Evento>()
val eventos: SharedFlow<Evento> = _eventos.asSharedFlow()

fun lanzarError(mensaje: String) {
    viewModelScope.launch {
        _eventos.emit(Evento.Error(mensaje))
    }
}

Cancelación y timeouts

Las coroutines de Kotlin usan cancelación cooperativa: no se interrumpen por la fuerza, sino que comprueban si han sido canceladas cada vez que llegan a un suspend point. Si tu código no tiene suspend points (por ejemplo, un bucle muy intensivo de CPU), tienes que añadir llamadas a yield() o isActive para que la cancelación funcione.

val job = launch {
    repeat(1000) { i ->
        if (!isActive) return@launch // comprobación manual
        procesarElemento(i)
    }
}

Para poner límite de tiempo a una operación tienes dos opciones:

// lanza TimeoutCancellationException si tarda más de 5 segundos
withTimeout(5_000) {
    operacionLenta()
}

// devuelve null en vez de lanzar excepción
val resultado = withTimeoutOrNull(5_000) {
    operacionLenta()
}
if (resultado == null) {
    // timeout
}

Para cancelar explícitamente, llamas a cancel() sobre el Job que devuelve launch, o sobre el scope entero si quieres cancelarlo todo:

val job = launch { ... }
job.cancel()
job.join() // espera a que termine la cancelación

Dispatchers: en qué hilo corre la coroutine

Por defecto, una coroutine hereda el dispatcher del scope que la lanzó. En Android con viewModelScope, eso es Dispatchers.Main. Pero no todo el código puede ni debe correr en el hilo principal.

Los tres dispatchers que usarás habitualmente:

  • Dispatchers.Main: el hilo de la UI en Android. Para actualizar vistas y observar StateFlow.
  • Dispatchers.IO: pool de hilos pensado para operaciones bloqueantes de entrada/salida: llamadas de red, lectura de ficheros, consultas a base de datos.
  • Dispatchers.Default: pool de hilos para trabajo intensivo de CPU: ordenar listas grandes, parsing, cálculos.

Cambias el dispatcher dentro de una suspend function con withContext:

suspend fun obtenerUsuarios(): List<Usuario> {
    return withContext(Dispatchers.IO) {
        // esto corre en un hilo de IO, no en Main
        baseDeDatos.usuarioDao().getAll()
    }
}

// quien llame a esta función puede estar en Main tranquilamente
viewModelScope.launch {
    val usuarios = obtenerUsuarios() // cambia a IO internamente
    _lista.value = usuarios // vuelve a Main
}

El patrón habitual en Android es lanzar desde viewModelScope (Main) y usar withContext(Dispatchers.IO) dentro de las funciones del repositorio. La UI nunca sabe en qué hilo corre el repositorio.

Migrar de RxJava a coroutines

Si tienes un proyecto con RxJava, no hace falta migrar todo de golpe. Kotlin proporciona extensiones de interoperabilidad en la librería kotlinx-coroutines-rx2 o rx3:

// convertir un Observable a Flow
observable.asFlow().collect { ... }

// convertir un Flow a Observable
flow.asObservable()

// convertir un Single a suspend fun
single.await()

Las equivalencias más directas para quien viene de RxJava:

  • Observable.fromCallable { } ? flow { emit(...) }
  • Single.fromCallable { } ? suspend fun
  • subscribeOn(Schedulers.io()) ? withContext(Dispatchers.IO)
  • observeOn(AndroidSchedulers.mainThread()) ? withContext(Dispatchers.Main) o simplemente coleccionar desde Main
  • BehaviorSubject ? MutableStateFlow
  • PublishSubject ? MutableSharedFlow

Para proyectos nuevos en 2026 no hay duda: coroutines desde el principio. RxJava tiene mucho más código para conseguir lo mismo y la curva de aprendizaje es más empinada. Además, el soporte oficial de Android (Jetpack, Room, Retrofit, Paging) apunta a coroutines y Flow como primera opción.

Si te interesa profundizar en Kotlin: concurrencia y el modelo de coroutines, o quieres ver por qué las coroutines en Android sustituyeron a RxJava, tienes más contexto en esos artículos.

Imagen: Pexels / Daniil Komov

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP