wandres.dev
FLOW Y STATEFLOW · streams en Kotlin

Frío contra caliente: stateIn y shareIn

La última lección del nivel cierra el círculo entre las dos naturalezas de un flujo y muestra la frontera donde se cruza deliberadamente de una a otra. Primero fija la distinción operativa: quién enciende al productor, cuántas ejecuciones hay con varios coleccionistas y qué recibe quien llega tarde. Después presenta stateIn, que convierte un flujo frío en un StateFlow con una sola ejecución compartida, valor actual y scope explícito, y shareIn, su hermano sin valor inicial para lo que no es estado. El corazón de la lección son las políticas de arranque: Eagerly, que produce aunque nadie mire; Lazily, que arranca con el primero y no se detiene nunca; y WhileSubscribed con su tiempo de gracia, que apaga el productor cuando el último observador se va y lo mantiene vivo durante una rotación de pantalla, junto con la réplica de expiración que evita mostrar datos caducados al volver. Termina con la elección del scope, la explicación de por qué un scope demasiado largo es una fuga y demasiado corto una reconsulta constante, y la regla de dónde debe colocarse la frontera dentro de la arquitectura.

⏱ 18 min

Tienes las dos naturalezas sobre la mesa. El flujo frío es una expresión: no ocurre, se compone sin coste y se ejecuta entero para cada coleccionista. El flujo caliente es un proceso: ocurre con o sin público, tiene un valor presente y lo comparte con todos. Cada uno resuelve lo que el otro no puede, y por eso la pregunta interesante no es cuál es mejor sino dónde exactamente debe cruzarse la frontera entre ambos. Ese cruce tiene nombre y ceremonia: stateIn y shareIn, dos operadores que toman un flujo frío y lo encienden una sola vez bajo un ámbito que tú eliges y con una política que tú decides. La lección va de aprender a colocar esa frontera con intención, porque ponerla mal es la diferencia entre una consulta a la base de datos y siete.

🎯 Al terminar esta lección sabrás
  • Distinguir frío y caliente por sus tres consecuencias observables: quién arranca, cuántas ejecuciones y qué ve quien llega tarde.
  • Convertir un flujo frío en estado compartido con stateIn, y saber cuándo lo correcto es shareIn.
  • Elegir con criterio entre Eagerly, Lazily y WhileSubscribed y entender su tiempo de gracia.
  • Escoger el scope adecuado y reconocer los síntomas de uno demasiado largo o demasiado corto.

La frontera: quién enciende al productor

Resume la diferencia en tres preguntas y no volverás a dudar. ¿Quién arranca la producción? En frío, el coleccionista; en caliente, quien lo creó. ¿Cuántas ejecuciones hay si coleccionan dos? En frío, dos independientes; en caliente, una compartida por ambos. ¿Qué recibe quien se suscribe tarde? En frío, todo desde el principio, porque para él empieza ahora; en caliente, lo que la política de réplica le conceda, del último valor a nada en absoluto.

Esas tres respuestas explican por qué las capas bajas de una aplicación deben devolver frío y las altas exponer caliente. Un repositorio no sabe cuántas pantallas lo usarán ni cuánto vivirán, así que lo honesto es que devuelva una descripción sin coste, sin estado y sin ciclo de vida propio. Un ViewModel, en cambio, sí conoce a su público y su duración: sabe que la pantalla puede rotar, que sus observadores van y vienen y que dos consultas idénticas a la base de datos son una tontería. La frontera cae ahí, en el ViewModel, y no es casual: cae donde por primera vez alguien conoce el ciclo de vida.

El síntoma de no haberla puesto es inconfundible. Si tres partes de la interfaz coleccionan el mismo flujo frío del repositorio, hay tres consultas a la base de datos, tres suscripciones vivas y tres copias del mismo trabajo; y al girar la pantalla, la vista nueva vuelve a empezar de cero, con su parpadeo de lista vacía mientras la consulta se rehace. Nada de eso falla ruidosamente: solo consume batería y se ve mal.

class TareasViewModel(repo: TareasRepo) : ViewModel() {
    // repo.tareas() es frio: sin stateIn, cada coleccionista lo ejecuta entero
    val state: StateFlow<TareasState> = repo.tareas()
        .map { lista -> TareasState(tareas = lista, cargando = false) }
        .stateIn(
            scope = viewModelScope,                                // quien lo mantiene vivo
            started = SharingStarted.WhileSubscribed(5_000),       // cuando produce
            initialValue = TareasState(cargando = true),           // que se ve mientras tanto
        )
}

stateIn y shareIn: encender una sola vez

stateIn hace tres cosas a la vez y conviene nombrarlas por separado. Lanza una corrutina en el scope indicado que colecciona el flujo frío una sola vez. Guarda cada valor emitido en un StateFlow que retiene el último. Y devuelve ese StateFlow para que cualquier número de observadores se enganche a la misma ejecución. La consulta a la base de datos pasa de una por observador a una en total, y quien llegue tarde recibe el valor actual sin esperar a que se rehaga nada.

El initialValue es obligatorio por la misma razón que en la lección anterior: un StateFlow no puede existir sin valor. Ese valor es lo que la pantalla muestra en el hueco entre la suscripción y la primera emisión real, así que casi siempre es tu estado de carga. Y ojo con la simetría: como es un StateFlow, hereda la conflación y la deduplicación por igualdad, de modo que la disciplina de inmutabilidad sigue siendo obligatoria aguas arriba.

🌊

Frio

Arranca el coleccionista, se ejecuta una vez por cada uno y quien llega tarde empieza desde el principio. Sin estado, sin ciclo de vida, sin coste mientras nadie mira.

🔥

Caliente

Produce con o sin público, una sola ejecución para todos, y quien llega tarde recibe lo que la réplica le conceda. Tiene estado y por eso necesita un scope.

🧊

stateIn

Cruza la frontera hacia un StateFlow: valor inicial obligatorio, valor actual consultable y conflación. Para lo que la pantalla es.

📡

shareIn

Cruza hacia un SharedFlow: sin valor inicial y con la réplica que elijas. Para caudales compartidos de los que no tiene sentido preguntar el valor actual.

shareIn es el hermano para lo que no es estado. No pide valor inicial y devuelve un SharedFlow, con la réplica que le indiques. Sirve cuando quieres compartir una única ejecución entre varios consumidores pero no tiene sentido hablar de “el valor actual”: un stream de mensajes entrantes, una telemetría, un flujo de eventos del sistema. La regla mnemotécnica es directa: si la pregunta “¿cuál es su valor ahora?” tiene sentido, es stateIn; si no lo tiene, es shareIn.

flowchart LR
R[Repositorio devuelve flujo frio sin coste] --> ST[stateIn en el ViewModel]
ST --> U[Una unica ejecucion compartida]
U --> V1[Vista uno]
U --> V2[Vista dos]
U --> V3[Vista tres llega tarde y recibe el valor actual]
style R fill:#89b4fa,color:#11111b
style ST fill:#cba6f7,color:#11111b
style U fill:#a6e3a1,color:#11111b

Las políticas de arranque y el tiempo de gracia

El parámetro started es el que decide cuándo el productor trabaja, y las tres opciones responden a compromisos distintos entre latencia y recursos. Eagerly arranca al crear el flujo y no se detiene nunca: el dato está listo desde el primer instante, al precio de consultar la base de datos y mantener la suscripción aunque el usuario esté en otra pantalla. Lazily espera al primer suscriptor y después ya no se detiene jamás, aunque todos se vayan. WhileSubscribed produce mientras haya al menos un observador y se apaga cuando el último se marcha.

Esa tercera es la que quieres casi siempre en Android, y su parámetro de tiempo tiene una historia concreta detrás. Al girar la pantalla, la vista se destruye y se recrea en cuestión de milisegundos: durante ese instante el número de suscriptores cae a cero, y con un apagado inmediato el productor se detendría y volvería a arrancar por una rotación, rehaciendo la consulta y provocando el parpadeo. El tiempo de gracia de unos cinco segundos cubre esa ventana y también un paso breve por segundo plano, mientras que una ausencia larga sí apaga el productor y libera los recursos. No es un número mágico: es la duración estimada de un cambio de configuración con margen.

// Produce siempre, aunque nadie mire: solo si la latencia inicial es critica
SharingStarted.Eagerly

// Arranca con el primer suscriptor y ya no se detiene: cuidado con las fugas
SharingStarted.Lazily

// La opcion sana en Android: se apaga al irse el ultimo, con margen para rotar
SharingStarted.WhileSubscribed(stopTimeoutMillis = 5_000)

// Ademas caduca la replica: al volver mucho despues, no se ven datos rancios
SharingStarted.WhileSubscribed(stopTimeoutMillis = 5_000, replayExpirationMillis = 0)
💡
La expiracion de replica evita mostrar datos rancios

WhileSubscribed acepta un segundo tiempo que dice cuánto conserva el último valor tras apagarse. Con expiración cero, si el usuario vuelve horas después el flujo arranca desde el valor inicial en vez de mostrarle una fotografía caducada; con el valor por defecto, infinito, la conserva para siempre. La elección depende de si un dato viejo confunde o tranquiliza: para un saldo bancario, expíralo; para un listado que cambia poco, consérvalo y refresca en segundo plano.

⚠️
Lazily es la fuga silenciosa mas comun

Lazily parece un término medio prudente y casi nunca lo es: una vez arrancado, el productor sigue coleccionando la base de datos, el sensor o el socket aunque no quede nadie mirando, hasta que muera el scope entero. En un ViewModel que sobrevive a toda la pantalla eso es trabajo indefinido a espaldas del usuario, y no aparece en ningún informe de errores porque técnicamente nada ha fallado. Si dudas entre Lazily y WhileSubscribed, elige la segunda; si de verdad necesitas que el productor no se detenga, escríbelo con Eagerly y que la intención quede visible.

El scope correcto y dónde poner la frontera

Hay un matiz de WhileSubscribed que sorprende la primera vez y que hay que tener presente al depurar: cuando el productor se apaga y más tarde vuelve a arrancar, el flujo frío de aguas arriba se ejecuta de nuevo desde el principio. No se reanuda donde estaba, porque un flujo frío no tiene “donde estaba”: se vuelve a coleccionar entero. Para una consulta a la base de datos eso es justo lo que quieres —datos frescos al volver—, pero si aguas arriba hay un contador, una acumulación con runningFold o una paginación en curso, se reinician con él. Cuando el estado acumulado deba sobrevivir a la ausencia de observadores, la respuesta no es WhileSubscribed sino guardar ese acumulado fuera del flujo.

ℹ️
La cuenta de suscriptores es una senal, y la puedes escuchar

WhileSubscribed funciona porque el flujo compartido sabe en todo momento cuántos observadores tiene, y esa cuenta está disponible también para ti a través de subscriptionCount. Con ella puedes encender un socket solo mientras alguien mira, pausar un sondeo periódico o registrar métricas de uso real. Es la misma idea que ya viste en Orbit con repeatOnSubscription: atar el trabajo caro a la presencia de público en lugar de a un ciclo de vida que hay que recordar manualmente.

El otro parámetro es el scope, y define la vida del flujo compartido: mientras ese ámbito viva, la corrutina que colecciona puede existir; cuando muera, se cancela con todo lo suyo. En un ViewModel la respuesta es viewModelScope, que dura exactamente lo que dura la pantalla lógica y sobrevive a las rotaciones. Los dos errores son simétricos y ambos se diagnostican por sus síntomas.

Un scope demasiado largo —uno global de aplicación para algo que solo importa en una pantalla— es una fuga: la ejecución sobrevive al ViewModel, sigue consultando y sigue reteniendo referencias mucho después de que nadie las necesite. Un scope demasiado corto —atado a la vista y no al ViewModel— es una reconsulta constante: cada rotación destruye el flujo compartido, la ejecución se rehace desde cero y stateIn deja de ahorrar lo que venía a ahorrar. La regla es única: el scope del estado compartido debe durar exactamente lo que dura la unidad de estado que representa, ni un instante más ni uno menos.

Hay una versión especialmente escurridiza del scope demasiado corto: construir el flujo compartido dentro de una función que se vuelve a llamar. Si escribes stateIn en el cuerpo de un Composable, en un getter de propiedad o dentro de una función que la interfaz invoca en cada paso, estás creando un flujo compartido nuevo cada vez, y por lo tanto una ejecución nueva cada vez: has escrito el operador que evita duplicar ejecuciones dentro del sitio que las duplica.

// Mal: el getter construye un StateFlow nuevo en cada acceso
val state: StateFlow<TareasState>
    get() = repo.tareas().map(::aEstado).stateIn(viewModelScope, Eagerly, inicial)

// Bien: una sola propiedad inicializada una vez, con una sola ejecucion detras
val state: StateFlow<TareasState> =
    repo.tareas().map(::aEstado).stateIn(viewModelScope, WhileSubscribed(5_000), inicial)

La regla que evita esa clase entera de fallos es tan tonta como eficaz: el resultado de stateIn o shareIn debe vivir en una propiedad inicializada una sola vez, nunca en un getter ni en el cuerpo de una función que se reejecuta. Si el flujo compartido no tiene una identidad estable, no comparte nada.

Queda la pregunta arquitectónica, y con ella se cierra el nivel. La frontera se pone en el punto más alto donde todavía nadie conoce el ciclo de vida, es decir, justo en el ViewModel: por debajo, todo frío y componible; por encima, un único caliente que la interfaz consume. Subirla más —calentar en el repositorio— lo obliga a inventarse un scope y un ciclo de vida que no le corresponden y a decidir por sus clientes cuándo producir. Bajarla más —dejar que cada Composable colecciona el flujo frío— multiplica ejecuciones y devuelve el parpadeo. Ese punto medio no es un compromiso: es el único lugar donde la información necesaria para decidir está disponible.

📝
En pruebas, la politica importa tanto como la logica

Un test que colecciona un flujo creado con WhileSubscribed y no mantiene la suscripción abierta puede ver cómo el productor se apaga a mitad de la comprobación y obtener resultados desconcertantes. En pruebas, o mantienes viva la colección durante todo el escenario, o construyes el estado con Eagerly para que la política deje de ser una variable del experimento. Que ese detalle influya en un test es la mejor prueba de que started no es un ajuste menor: es comportamiento observable.

Compartir es una decision, y toda decision quiere un sitio donde tomarse

Lo que hace elegante a este diseño es que Kotlin no eligió por ti y, sin embargo, te dejó un único sitio razonable donde elegir. Podría haber hecho todos los flujos calientes, como los Subject de la generación anterior, y entonces cada stream cargaría con estado, ciclo de vida y coste desde el momento de nacer, y componer dejaría de ser gratis. Podría haberlos hecho todos fríos sin salida, y entonces compartir una ejecución exigiría cachés a mano, con su invalidación y sus carreras. En vez de eso separó las dos naturalezas y puso entre ellas un operador con nombre propio que te obliga a contestar tres preguntas antes de cruzar: bajo qué ámbito vive esto, cuándo debe estar produciendo y qué se ve mientras no hay nada. Fíjate en que esas tres preguntas no tienen respuesta universal: dependen de quién mira, de cuánto dura la pantalla y de si un dato viejo tranquiliza o engaña. Ninguna biblioteca puede contestarlas por ti sin equivocarse la mitad de las veces, y la honestidad del diseño consiste en no fingir que puede. De ahí sale el principio que sobrevive a este nivel entero y probablemente a Kotlin: cuando una decisión no tiene respuesta correcta universal, el trabajo del diseñador de la herramienta no es adivinarla sino concentrarla —darle un nombre, un sitio y una firma— para que aparezca escrita en el código en lugar de repartida como una propiedad emergente del sistema. Un stateIn con sus tres argumentos es la política de compartición de esa pantalla dicha en voz alta, revisable en una pull request y modificable en una línea. Cada vez que evalúes una abstracción, pregúntate dónde vive su decisión difícil: si no la encuentras en ninguna parte, no es que no exista, es que está esparcida por todo tu programa.

⚔️ Coloca la frontera con intencion
  1. Convierte un flujo frío de repositorio en estado de pantalla con stateIn, justificando por separado el scope, la política y el valor inicial que elijas.
  2. Colecciona ese mismo flujo sin stateIn desde tres puntos de la interfaz y mide o razona cuántas ejecuciones del productor ocurren.
  3. Compara Eagerly, Lazily y WhileSubscribed en una pantalla que el usuario abandona un minuto, y describe qué hace el productor en cada caso.
  4. Explica qué ocurre exactamente durante una rotación con un tiempo de gracia de cero y por qué cinco segundos lo arreglan.
  5. Argumenta por qué la conversión pertenece al ViewModel y no al repositorio, y qué información necesaria para decidir le falta a la capa de datos.