Si te estás metiendo en el mundo de la programación reactiva con Kotlin, seguramente te habrás dado cuenta de que manejar el flujo de datos no es moco de pavo. Los Kotlin Flows han llegado para sustituir a librerías más complejas como RxJava, ofreciendo una manera mucho más natural y sencilla de gestionar secuencias asíncronas aprovechando todo el poder de las corrutinas.
Entender cómo pasar de un flujo que solo se activa cuando alguien lo pide a uno que está siempre ahí, listo para dar información, es la clave para que tu aplicación no consuma recursos como si no hubiera un mañana. Aquí es donde entran en juego los conceptos de flujos fríos y calientes, y cómo transformarlos usando herramientas específicas para que la experiencia del usuario sea fluida y sin tirones.
La esencia de los Cold Flows
Por defecto, los flujos en Kotlin son fríos. Esto significa que son básicamente como una receta de cocina: el código no se ejecuta hasta que alguien decide llamar a la función collect(). Si tienes dos colectores escuchando el mismo flujo frío, el productor se ejecutará dos veces desde el principio, lo que puede ser un desastre si estás haciendo peticiones a una API o consultas pesadas a la base de datos.
Existen varias maneras de montar estos flujos. El método asFlow() es ideal para convertir colecciones existentes; flowOf() sirve para valores ya definidos, y el constructor flow { … } es el más flexible, ya que permite emitir valores mediante la función emit() y ejecutar funciones de suspensión sin complicaciones.
En cuanto a su procesamiento, los flujos son secuenciales, lo que quiere decir que esperan a que un elemento termine antes de pasar al siguiente. Para manipular estos datos, contamos con operadores intermedios como map() o filter(), que crean un nuevo flujo transformando el anterior, y operadores terminales como collect() o first(), que son los que realmente disparan la ejecución del flujo.
Entrando en el terreno de los Hot Flows
A diferencia de los fríos, los flujos calientes no esperan a que haya un suscriptor para empezar a trabajar; están activos y permanecen en la memoria independientemente de quién los esté escuchando. Son perfectos para situaciones donde necesitas compartir un estado común o emitir eventos que lleguen a varias partes de la app al mismo tiempo.
Dentro de este ecosistema tenemos dos protagonistas: StateFlow y SharedFlow. El primero es como un contenedor de estado que siempre recuerda el último valor emitido y lo entrega inmediatamente a cualquier nuevo suscriptor. Es el sustituto moderno de LiveData, aunque requiere obligatoriamente un valor inicial en su constructor.
Por otro lado, SharedFlow es más como un bus de eventos. No necesita un valor inicial y es la herramienta ideal para gestionar eventos únicos, como una navegación o un mensaje de error, ya que no retiene el estado por defecto (si replay es 0), evitando que el evento se dispare de nuevo al rotar la pantalla.
Transformación con stateIn y shareIn
Convertir un flujo frío en uno caliente es fundamental para optimizar la arquitectura de Android. Para crear un StateFlow a partir de un flujo frío, utilizamos el operador stateIn. Este operador necesita un CoroutineScope (normalmente el viewModelScope), una política de inicio y un valor inicial para poder funcionar correctamente.
Si lo que buscamos es una difusión de eventos sin mantener un estado actual, la opción es shareIn. Aquí configuramos el número de elementos que se deben repetir para los nuevos suscriptores y la política de comportamiento. Una de las opciones más habituales es SharingStarted.WhileSubscribed(5000), que mantiene el flujo activo durante 5 segundos tras el último suscriptor, permitiendo que la rotación de la pantalla no reinicie la carga de datos.
Es vital diferenciar que mientras stateIn produce un StateFlow con acceso síncrono a través de la propiedad .value, shareIn genera un SharedFlow que es más flexible en cuanto a la configuración del búfer y la gestión de la contrapresión mediante políticas como BufferOverflow.DROP_OLDEST.
Recolección segura y ciclo de vida
No basta con tener un flujo caliente; hay que saber consumirlo. Si lanzas un collect() dentro de un simple launch en la UI, corres el riesgo de seguir procesando datos en segundo plano, lo que puede provocar fugas de memoria o crashes. La solución estándar es utilizar repeatOnLifecycle(Lifecycle.State.STARTED).
Esta API garantiza que la recolección se detenga cuando la vista pasa a estado STOPPED y se reinicie al volver a STARTED. En el mundo de Jetpack Compose, la alternativa es collectAsStateWithLifecycle() para la gestión de estado, que hace exactamente lo mismo: deja de escuchar cuando la app no es visible, ahorrando batería y CPU de forma inteligente.
Estrategias de Testing para flujos
Probar flujos calientes tiene su miga. No basta con mirar el valor final, ya que podrías perderte estados intermedios debido a la conflación de StateFlow. Para solucionar esto, la librería Turbine se ha convertido en la herramienta estrella, permitiendo esperar emisiones específicas con awaitItem() de forma determinista.
Cuando probamos un ViewModel que usa stateIn, es un error común olvidar que debe haber al menos un colector activo durante la prueba. Si no hay nadie escuchando, el operador stateIn no activará el flujo subyacente y los valores nunca se actualizarán en la propiedad value, haciendo que el test falle sin motivo aparente.
Para los flujos fríos, se recomienda el uso de repositorios falsos (fakes) que emitan valores predefinidos. Podemos verificar la primera emisión con first() o convertir el flujo en una lista con toList() siempre que la secuencia sea finita, asegurando que la lógica de negocio se comporta como esperamos antes de subir el código a producción.
Dominar la transición de flujos fríos a calientes mediante stateIn y shareIn permite construir aplicaciones Android mucho más robustas, donde la gestión del estado de la interfaz y la emisión de eventos se separan con claridad. Mientras que los flujos fríos son ideales para pipelines de datos bajo demanda, los calientes aseguran que la información esté disponible y compartida eficientemente, siempre y cuando se respeten los ciclos de vida mediante repeatOnLifecycle y se validen correctamente con herramientas como Turbine.
Continúar leyendo...