Cold flows — el default
Un flow creado con el builder flow { } es cold: el bloque de código adentro no corre hasta que alguien lo colecta. Cada nuevo colector ejecuta el bloque desde cero, independientemente de los demás.
// Este flow es cold — el bloque no corre hasta collect()
val productosFlow: Flow<List<Producto>> = flow {
println("Llamando a la API...") // solo corre cuando hay un colector
val productos = api.getProductos()
emit(productos)
}
// Sin colector, no pasa nada
// productosFlow ← solo la definición, la API no fue llamada
// Primer colector → ejecuta el bloque, llama a la API
productosFlow.collect { println("Colector 1: $it") }
// Output: "Llamando a la API..." → "Colector 1: [...]"
// Segundo colector → vuelve a ejecutar el bloque, vuelve a llamar a la API
productosFlow.collect { println("Colector 2: $it") }
// Output: "Llamando a la API..." → "Colector 2: [...]"
// ↑ Dos llamadas de red para dos colectores — cold behavior
Los flows de Room (dao.observarTodos()) y los builders flow { }, flowOf(), asFlow() son todos cold. La mayoría de los flows que usás en el día a día son cold.
Cold es el comportamiento correcto la mayoría de las vecesPara una pantalla que carga datos, cada instancia de la pantalla debería hacer su propia carga — no compartir la del colector anterior. Cold es el default por una buena razón.
Hot flows — existen independientemente de los colectores
Un flow hot existe y emite valores sin importar si hay colectores. Si nadie está escuchando cuando se emite un valor, ese valor se puede perder (o guardarse en un buffer, dependiendo de la configuración). Si un nuevo colector se suscribe tarde, puede no recibir los valores anteriores.
// StateFlow y SharedFlow son hot flows
val contadorFlow = MutableStateFlow(0)
// Emitir sin colectores — el valor se guarda, no se pierde
contadorFlow.value = 1
contadorFlow.value = 2
// Un colector que llega tarde
contadorFlow.collect { println(it) }
// Solo imprime: 2 (el último valor — StateFlow siempre guarda el último)
// No imprime 0 ni 1 — ya pasaron
StateFlow — el estado actual de algo
StateFlow es un hot flow que siempre tiene un valor. Es el equivalente directo de LiveData con la semántica de que siempre hay un estado vigente.
// StateFlow tiene SIEMPRE un valor — no puede estar "vacío"
val _uiState = MutableStateFlow(ProductosUiState()) // valor inicial obligatorio
val uiState: StateFlow<ProductosUiState> = _uiState.asStateFlow()
// Características de StateFlow:
// 1. Siempre tiene un valor (accesible como .value)
// 2. Solo emite cuando el valor CAMBIA (comparación con equals())
// 3. Colectores nuevos reciben el valor actual inmediatamente
// 4. Múltiples colectores comparten el mismo valor — hot
// Solo emite si el valor es diferente al actual:
_uiState.value = ProductosUiState(isLoading = true)
_uiState.value = ProductosUiState(isLoading = true) // NO emite — mismo valor
_uiState.value = ProductosUiState(isLoading = false) // SÍ emite — valor diferente
// Acceso sincrónico al valor actual:
val estadoActual = uiState.value // siempre disponible, sin colectar
// Actualización atómica con update {}:
_uiState.update { estadoActual ->
estadoActual.copy(isLoading = false, productos = nuevaLista)
}
// update {} es thread-safe — evita race conditions vs asignar .value directamente
SharedFlow — eventos de un solo uso
SharedFlow es un hot flow más flexible que StateFlow: no tiene un valor inicial obligatorio, puede tener un buffer de valores anteriores configurable, y puede emitir el mismo valor múltiples veces.
// SharedFlow sin replay — los colectores solo reciben emisiones futuras
val _efectos = MutableSharedFlow<LoginEfecto>()
val efectos: SharedFlow<LoginEfecto> = _efectos.asSharedFlow()
// Emitir un efecto (side effect de un solo uso):
viewModelScope.launch {
_efectos.emit(LoginEfecto.NavegaAHome)
}
// El colector solo recibe lo que se emite MIENTRAS está activo
// Si el Fragment está en background y se emite → el evento se pierde
// (para esto es mejor Channel, ver sección de cuándo usar cada uno)
// SharedFlow con replay — guarda los últimos N valores para colectores nuevos
val _eventos = MutableSharedFlow<Evento>(replay = 3)
// Los nuevos colectores reciben los últimos 3 eventos emitidos
// SharedFlow con extraBufferCapacity — no bloquea el emisor si no hay colectores
val _logs = MutableSharedFlow<String>(
replay = 0,
extraBufferCapacity = 64, // buffer de 64 elementos
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
La diferencia clave entre StateFlow y SharedFlow
// StateFlow:
// ✓ Tiene valor inicial (siempre hay un estado)
// ✓ Colectores nuevos reciben el valor actual inmediatamente
// ✓ Solo emite cuando el valor cambia (deduplicación automática)
// ✓ .value accesible sincrónicamente
// ✗ No puede emitir el mismo valor dos veces seguidas
// ✗ No tiene historial de más de 1 elemento (replay = 1 fijo)
// → Ideal para: estado de la UI (UiState del ViewModel)
// SharedFlow:
// ✓ Configurable: replay, buffer, onBufferOverflow
// ✓ Puede emitir el mismo valor múltiples veces
// ✓ Puede tener 0 valor inicial (no siempre hay un estado)
// ✗ No tiene .value (no hay un "valor actual" garantizado)
// ✗ Más configuración necesaria para usarlo correctamente
// → Ideal para: eventos únicos (navegación, snackbars, errores efímeros)
// Resumen en una línea:
// StateFlow = "¿cuál es el estado ACTUAL?"
// SharedFlow = "¿qué EVENTOS ocurrieron?"
stateIn — convertir un cold flow en StateFlow
El caso de uso más común: tenés un cold flow del repositorio (Room, Retrofit, etc.) y querés exponerlo como StateFlow desde el ViewModel. stateIn hace esa conversión.
// Sin stateIn — el repositorio hace una nueva llamada por cada colector
class ProductosViewModel(repo: ProductoRepository) : ViewModel() {
val productos: Flow<List<Producto>> = repo.getProductos()
// Si la UI colecta esto dos veces (ej: rotación de pantalla),
// el repositorio hace DOS llamadas de red
}
// Con stateIn — una sola subscripción compartida entre todos los colectores
class ProductosViewModel(repo: ProductoRepository) : ViewModel() {
val productos: StateFlow<List<Producto>> = repo.getProductos()
.stateIn(
scope = viewModelScope, // el scope que maneja la subscripción
started = SharingStarted.WhileSubscribed(5000), // cuándo activar/desactivar
initialValue = emptyList() // valor mientras el flow no emitió nada
)
// Una sola subscripción al repositorio — todos los colectores la comparten
}
// Transformar antes de convertir en StateFlow — el patrón más común:
val uiState: StateFlow<ProductosUiState> = repo.getProductos()
.map { productos -> ProductosUiState(productos = productos) }
.catch { e -> emit(ProductosUiState(error = e.message)) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = ProductosUiState(isLoading = true)
)
shareIn — compartir un cold flow entre múltiples colectores
shareIn convierte un cold flow en SharedFlow. Útil cuando necesitás compartir un flow entre varios componentes sin que cada uno dispare la fuente de datos por separado.
// Caso de uso: una sola conexión WebSocket compartida entre múltiples colectores
val mensajesWebSocket: SharedFlow<Mensaje> = webSocketRepository
.conectar() // cold flow — sin shareIn haría una conexión por colector
.shareIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
replay = 0 // los nuevos colectores no reciben mensajes anteriores
)
// Caso de uso: location updates compartidos entre múltiples features
val ubicacion: SharedFlow<Ubicacion> = locationRepository
.observarUbicacion()
.shareIn(
scope = applicationScope, // scope de la app, no del ViewModel
started = SharingStarted.WhileSubscribed(5000),
replay = 1 // los nuevos colectores reciben la última ubicación conocida
)
// Con replay = 1, shareIn se comporta similar a stateIn
// Diferencia: shareIn con replay=1 no tiene initialValue,
// el primer colector espera hasta que el flow emita algo
stateIn vs shareIn con replay=1Si necesitás que siempre haya un valor disponible (como el estado de la UI), usá stateIn — tiene initialValue que garantiza que nunca hay un "todavía no emitió nada". Si el valor puede no estar disponible inicialmente y eso es válido, shareIn con replay=1 también funciona.
SharingStarted — cuándo activar y desactivar la subscripción
Tanto stateIn como shareIn aceptan un parámetro started que controla cuándo se activa la subscripción al flow de origen:
// SharingStarted.Eagerly
// → Activa la subscripción inmediatamente al crear el StateFlow/SharedFlow
// → Nunca la desactiva (corre mientras el scope esté activo)
// → Usar cuando los datos son siempre necesarios y baratos de mantener activos
.stateIn(scope, SharingStarted.Eagerly, initialValue)
// SharingStarted.Lazily
// → Activa la subscripción cuando el PRIMER colector se suscribe
// → Nunca la desactiva después
// → Útil cuando la activación tiene costo pero la desactivación no importa
.stateIn(scope, SharingStarted.Lazily, initialValue)
// SharingStarted.WhileSubscribed(stopTimeoutMillis)
// → Activa cuando hay al menos un colector
// → Desactiva stopTimeoutMillis después de que el ÚLTIMO colector se va
// → El stopTimeout permite sobrevivir a rotaciones de pantalla (que duran ~300ms)
// → Es el parámetro que más importa elegir bien
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000), // 5 segundos de gracia
initialValue = emptyList()
)
// Con 5000ms: si el usuario rota el teléfono (Fragment destruido ~300ms,
// recreado después), la subscripción NO se interrumpe
// Si el usuario va al background por más de 5s → se desactiva (ahorra recursos)
// Si el usuario vuelve → se reactiva, el StateFlow ya tiene el initialValue
// ¿Qué valor para stopTimeoutMillis?
// 0 → desactiva inmediatamente al perder el último colector (máximo ahorro)
// 5000 → sobrevive rotaciones cómodamente (recomendado para la mayoría)
// Long.MAX_VALUE → nunca desactiva (equivalente a Lazily después del primer colector)
El patrón completo en el ViewModel
class ProductosViewModel @Inject constructor(
private val repo: ProductoRepository
) : ViewModel() {
// ── Estado de la UI — StateFlow con stateIn ──────────────
// Una sola subscripción al repo, compartida entre todos los colectores de la UI
val uiState: StateFlow<ProductosUiState> = repo.getProductos()
.map { productos ->
ProductosUiState(productos = productos, isLoading = false)
}
.catch { e ->
emit(ProductosUiState(error = e.message, isLoading = false))
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = ProductosUiState(isLoading = true)
)
// ── Eventos de un solo uso — Channel (no SharedFlow) ─────
// Channel garantiza que cada evento se entrega exactamente una vez
// SharedFlow puede perder eventos si no hay colector en ese momento
private val _efectos = Channel<ProductosEfecto>(Channel.BUFFERED)
val efectos = _efectos.receiveAsFlow()
fun seleccionar(producto: Producto) {
viewModelScope.launch {
_efectos.send(ProductosEfecto.NavigarADetalle(producto.id))
}
}
fun reintentar() {
// stateIn se reactiva automáticamente cuando hay un colector
// Si querés forzar una nueva carga, el patrón es resetear el estado
// o usar una variable de retrigger:
viewModelScope.launch {
repo.sincronizar()
}
}
}
// ─── En el Fragment ───────────────────────────────────────────
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
// Estado — StateFlow
launch {
viewModel.uiState.collect { state -> renderState(state) }
}
// Eventos — Channel como Flow
launch {
viewModel.efectos.collect { efecto -> handleEfecto(efecto) }
}
}
}
Cuándo usar cada uno — el mapa completo
# ── COLD FLOW (flow { }, flowOf, dao.observar*) ───────────────
# → Operaciones que deben ejecutarse por separado para cada consumidor
# → Queries de base de datos en Room
# → Llamadas de red que cada pantalla debe hacer independientemente
# → Transformaciones de datos (map, filter, flatMap)
# ── StateFlow (MutableStateFlow) ─────────────────────────────
# → Estado mutable que la UI observa
# → UiState en el ViewModel
# → Configuración de la app que cambia con el tiempo
# → Cualquier "¿cuál es el estado actual de X?"
# ── SharedFlow (MutableSharedFlow) ────────────────────────────
# → Eventos que múltiples colectores necesitan recibir simultáneamente
# → Streams de datos como WebSockets o SSE (Server-Sent Events)
# → Cuando necesitás controlar replay y buffer manualmente
# → Nota: para eventos de un solo uso en el ViewModel, Channel es mejor
# ── Channel (receiveAsFlow()) ─────────────────────────────────
# → Eventos de un solo uso: navegación, Snackbars, dialogs
# → Garantiza entrega exactamente una vez (aunque no haya colector en ese momento)
# → El ViewModel emite, la UI consume cuando está activa
# → Es el reemplazo de SingleLiveEvent de LiveData
# ── stateIn ───────────────────────────────────────────────────
# → Convertir un cold flow del repositorio en StateFlow del ViewModel
# → Cuando querés que múltiples colectores compartan una sola subscripción
# → Cuando necesitás acceso sincrónico al valor actual (.value)
# → Siempre con WhileSubscribed(5000) en el ViewModel
# ── shareIn ───────────────────────────────────────────────────
# → Compartir un cold flow entre múltiples colectores sin stateIn
# → WebSockets, GPS, sensores — una sola conexión para múltiples consumidores
# → Cuando NO necesitás un valor inicial (stateIn lo requiere, shareIn no)
# → A nivel de Application (no ViewModel) para datos globales