Saltar a contenido

AA2.2 Flow: operadores y estado derivado

Qué se ha ejecutado y qué no

Los ejemplos con salida se han compilado y ejecutado de verdad al construir esta web (Kotlin con kotlinx-coroutines 1.11.0). Los fragmentos de ViewModel y de Compose marcados como «sin ejecutar» necesitan Android.

Flow frío: no hace nada hasta que lo recoges

Un Flow describe una secuencia de valores que llegan con el tiempo. Un Flow normal es frío: el código de su constructor no se ejecuta hasta que alguien lo recoge con collect, y se ejecuta de nuevo por cada colector.

fun numeros(): Flow<Int> = flow {
    println("  (el flow empieza)")
    for (i in 1..3) {
        delay(10)
        emit(i)
    }
}

fun main() = runBlocking {
    val f = numeros()
    println("creado, aún no ha pasado nada")
    f.collect { println("colector A recibe $it") }
    f.collect { println("colector B recibe $it") }
}

Salida:

creado, aún no ha pasado nada
  (el flow empieza)
colector A recibe 1
colector A recibe 2
colector A recibe 3
  (el flow empieza)
colector B recibe 1
colector B recibe 2
colector B recibe 3

flow { ... } no imprimió nada al crearse; cada collect lo arranca desde cero. Esto es lo contrario de un StateFlow, que es caliente: existe y guarda su valor aunque nadie lo recoja.

Los operadores del día a día

Se encadenan como en las colecciones, pero trabajan cuando llegan los valores:

fun main() = runBlocking {
    (1..10).asFlow()
        .filter { it % 2 == 0 }
        .map { it * it }
        .onEach { println("  pasa por onEach: $it") }
        .take(3)
        .onCompletion { println("fin del flujo") }
        .collect { println("recibido $it") }
}

Salida:

  pasa por onEach: 4
recibido 4
  pasa por onEach: 16
recibido 16
  pasa por onEach: 36
recibido 36
fin del flujo

take(3) corta el flujo al tercer valor, y fíjate en que ni siquiera se calcula el cuadrado de 8 ni de 10: las tuberías de Flow son perezosas.

Operador Qué hace
map, filter, take, onEach Como en las listas
distinctUntilChanged() Ignora un valor igual al anterior
debounce(ms) Solo deja pasar un valor si no llega otro en los siguientes ms
flowOn(dispatcher) Cambia dónde se ejecuta lo de arriba (nunca lo de abajo)
catch { } Captura errores del flujo de arriba
onCompletion { } Se ejecuta al terminar, con o sin error

Errores en un flujo: catch solo mira hacia arriba

fun main() = runBlocking {
    flow {
        emit(1)
        emit(2)
        throw IllegalStateException("se cayó la red")
    }
        .catch { e ->
            println("catch captura: ${e.message}")
            emit(-1)                         // puedo emitir un valor de recuperación
        }
        .collect { println("recibido $it") }
}

Salida:

recibido 1
recibido 2
catch captura: se cayó la red
recibido -1

El catch captura lo que falló antes que él y deja emitir un valor de recuperación. No captura errores del collect de después. Si el error es del collect, el try/catch va alrededor del collect.

Búsqueda mientras escribes

Es el caso estrella. El usuario teclea, y no quieres lanzar una petición por letra. Cuatro operadores resuelven el problema:

suspend fun buscarEnElServidor(consulta: String): List<String> {
    delay(50)                                           // la «red»
    return listOf("$consulta-1", "$consulta-2")
}

fun main() = runBlocking {
    // lo que el usuario escribe: tres letras seguidas muy rápido, una pausa, y una más
    val teclado = flow {
        emit("k"); delay(30)
        emit("ko"); delay(30)
        emit("kot"); delay(400)                         // pausa larga: aquí se lanza la búsqueda
        emit("kotl"); delay(400)
    }

    teclado
        .debounce(150)                                   // espera a que deje de escribir
        .distinctUntilChanged()                          // si no cambió, no repitas
        .filter { it.length >= 2 }                       // mínimo dos letras
        .onEach { println("buscando «$it»") }
        .mapLatest { buscarEnElServidor(it) }            // si llega otra consulta, cancela la anterior
        .collect { println("resultados: $it") }
}

Salida:

buscando «kot»
resultados: [kot-1, kot-2]
buscando «kotl»
resultados: [kotl-1, kotl-2]

Las tres primeras pulsaciones (k, ko, kot) llegan con 30 ms entre sí, así que debounce(150) las descarta menos la última: solo se busca «kot», después de la pausa. Luego llega «kotl» y se busca de nuevo. Dos peticiones en vez de cuatro.

mapLatest (y su hermano flatMapLatest) añade lo más importante: si llega una consulta mientras la anterior aún se está buscando, cancela la antigua. Así una respuesta lenta de «kot» nunca puede pisar a los resultados de «kotl».

Combinar varias fuentes: combine

Una pantalla casi nunca depende de una sola fuente. combine junta varios flujos y emite cada vez que cambia cualquiera de ellos, con el último valor de todos:

data class ListaUi(val consulta: String, val resultados: List<String>)

fun main() = runBlocking {
    val consulta = MutableStateFlow("")
    val catalogo = MutableStateFlow(listOf("manzana", "pera", "plátano", "mandarina"))

    val ui: Flow<ListaUi> = combine(consulta, catalogo) { q, lista ->
        ListaUi(q, lista.filter { it.contains(q, ignoreCase = true) })
    }

    val job = launch(Dispatchers.Unconfined) { ui.collect { println(it) } }

    consulta.value = "man"
    catalogo.value = catalogo.value + "mango"       // el catálogo cambia: se recalcula con la consulta actual
    consulta.value = "pl"
    job.cancel()
}

Salida:

ListaUi(consulta=, resultados=[manzana, pera, plátano, mandarina])
ListaUi(consulta=man, resultados=[manzana, mandarina])
ListaUi(consulta=man, resultados=[manzana, mandarina, mango])
ListaUi(consulta=pl, resultados=[plátano])

Es la forma de construir un UiState derivado: la entrada son varias fuentes de verdad y la salida es lo que la pantalla muestra, siempre coherente.

De frío a caliente: stateIn

Un ViewModel quiere exponer un StateFlow, pero lo que tiene tras combine es un Flow frío. stateIn lo convierte, y el parámetro más importante es cuándo debe estar activo el flujo de arriba:

Estrategia Se activa Se para
SharingStarted.Eagerly Nada más crearlo Nunca (mientras viva el ámbito)
SharingStarted.Lazily Con el primer colector Nunca
SharingStarted.WhileSubscribed(5000) Con el primer colector 5 segundos después del último colector

WhileSubscribed(5000) es la elección habitual en Android: mientras la pantalla se ve, el flujo trabaja; si la app pasa a segundo plano, se pausa y no gasta batería; y los 5 segundos de margen evitan reiniciarlo en un giro de pantalla (que desconecta y vuelve a conectar al colector en un instante). Se puede ver:

fun main() = runBlocking {
    val origen = flow {
        println("  [origen] arranca")
        try {
            var i = 0
            while (true) { emit(i++); delay(20) }
        } finally {
            println("  [origen] se detiene")
        }
    }

    val estado = origen.stateIn(this, SharingStarted.WhileSubscribed(100), initialValue = -1)
    println("creado: valor inicial ${estado.value} (el origen aún no ha arrancado)")

    val job = launch { estado.collect { } }             // aparece el primer colector
    delay(70)
    println("con colector: ¿ya hay valores del origen? ${estado.value >= 0}")
    job.cancel()                                        // desaparece el último colector
    delay(50)
    println("50 ms después de irse el colector, el origen sigue vivo (margen de 100 ms)")
    delay(150)
    println("pasado el margen, se detuvo")
    coroutineContext.cancelChildren()
}

Salida:

creado: valor inicial -1 (el origen aún no ha arrancado)
  [origen] arranca
con colector: ¿ya hay valores del origen? true
50 ms después de irse el colector, el origen sigue vivo (margen de 100 ms)
  [origen] se detiene
pasado el margen, se detuvo

Sin colectores, el origen ni siquiera arranca; con uno, funciona; al marcharse el último, espera el margen y solo entonces se detiene.

Convertir una API de callbacks en Flow

Muchas APIs antiguas de Android (ubicación, sensores, conectividad) avisan con callbacks. callbackFlow las convierte en un Flow. Aquí con una API inventada, para poder ejecutarla:

// Una API «antigua» con registro y baja de oyentes
class SensorFalso {
    private var oyente: ((Int) -> Unit)? = null
    fun registrar(f: (Int) -> Unit) { oyente = f; println("  sensor: oyente registrado") }
    fun quitar() { oyente = null; println("  sensor: oyente quitado") }
    fun medir(valor: Int) { oyente?.invoke(valor) }
}

fun SensorFalso.lecturas(): Flow<Int> = callbackFlow {
    registrar { trySend(it) }
    awaitClose { quitar() }                         // se ejecuta al dejar de recoger: ¡imprescindible!
}

fun main() = runBlocking {
    val sensor = SensorFalso()
    val job = launch(Dispatchers.Unconfined) { sensor.lecturas().collect { println("lectura: $it") } }
    sensor.medir(10)
    sensor.medir(20)
    job.cancelAndJoin()
    sensor.medir(30)                                // nadie escucha ya
    println("fin")
}

Salida:

  sensor: oyente registrado
lectura: 10
lectura: 20
  sensor: oyente quitado
fin

El bloque awaitClose { ... } es obligatorio: es donde se da de baja el oyente. Sin él, al dejar de recoger el flujo el oyente se quedaría registrado (una fuga). El valor 30 no se imprime porque el oyente ya se quitó.

En la pantalla (sin ejecutar)

Con stateIn el ViewModel expone un StateFlow que se recoge como en la AA1:

// Sin ejecutar: necesita Android
class BuscadorViewModel(repositorio: ProductosRepositorio) : ViewModel() {
    private val consulta = MutableStateFlow("")

    val estado: StateFlow<BuscadorUi> = consulta
        .debounce(300)
        .distinctUntilChanged()
        .flatMapLatest { q -> repositorio.buscar(q) }           // devuelve un Flow<List<Producto>>
        .map<List<Producto>, BuscadorUi> { BuscadorUi.Datos(it) }
        .catch { emit(BuscadorUi.Error(it.message ?: "Error")) }
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), BuscadorUi.Cargando)

    fun alEscribir(texto: String) { consulta.value = texto }
}

Errores frecuentes

Error Cómo evitarlo
Recoger un Flow en lifecycleScope.launch sin más repeatOnLifecycle o collectAsStateWithLifecycle() en Compose
Lanzar una petición por cada letra debounce + distinctUntilChanged + flatMapLatest
callbackFlow sin awaitClose Siempre: ahí se da de baja el oyente
flowOn para cambiar el hilo de abajo flowOn solo afecta a lo que está por encima
catch esperando capturar un error del collect try/catch alrededor del collect
stateIn(Eagerly) para todo WhileSubscribed(5000) para pausar en segundo plano

Para practicar

Los ejercicios AA2.4 a AA2.6 de AA2 · Ejercicios practican operadores, la búsqueda con debounce y combine.