Coroutines + Flow vs. LiveData — ¿cuándo usás realmente cada uno en 2025?
LiveData no está muerto, pero ya no es de donde arranca el manejo de estado nuevo en Android — esta es la regla de decisión real entre trabajo greenfield en Compose y pantallas legacy basadas en Views.
APRENDE HACIENDOPon a prueba tu intuición3 pasos
Coroutines + Flow vs. LiveData — ¿cuándo usás realmente cada uno en 2025?
Usa los ejemplos y la explicación de la lección para comprobar tu idea.
Reflexión personal. No se envía a ningún servicio de IA.#Dónde LiveData todavía gana en el papel
LiveData fue diseñado para un solo trabajo: guardar un valor, notificar a los observers, y respetar automáticamente el Lifecycle para que una Activity/Fragment destruida nunca reciba un callback y nunca genere una fuga. observe(viewLifecycleOwner) { ... } deja de entregar valores en el momento en que el lifecycle cae por debajo de STARTED, y retoma con el último valor al volver arriba. Durante años eso lo convirtió en la opción por defecto para exponer el estado de un ViewModel a una UI basada en View, y es genuinamente simple: no hay que manejar un scope de coroutine, no hay que explicar la distinción entre frío y caliente, y la interoperabilidad con Java es trivial.
El problema es que LiveData es deliberadamente delgado. Tiene map y switchMap y básicamente nada más — sin debounce, combine, distinctUntilChanged (bueno, ese sí existe, se agregó después), flatMapLatest, ni manejo de backpressure — y todo corre en el hilo principal por contrato, así que cualquier transformación que involucre funciones suspend o trabajo en background igual necesita una vía de escape hacia coroutines.
#Dónde Flow (y StateFlow/SharedFlow) tomaron la delantera
Flow es parte de primera clase de las coroutines de Kotlin, así que cualquier cosa ya escrita como función suspend o coroutine se compone naturalmente con él — sin un framework reactivo separado que atornillar, sin una regla de confinamiento de hilo que sortear. StateFlow te da el comportamiento de "guardar un valor actual, notificar observers" que ofrecía LiveData, pero con una cadena de operadores real, testabilidad vía librerías como Turbine, y un encaje natural en Compose a través de collectAsStateWithLifecycle() — que, importante, replica exactamente el comportamiento de "dejar de recolectar cuando no está visible" que tenía LiveData, cerrando la brecha que hizo que collectAsState() (su predecesor sin conciencia del lifecycle) fuera riesgoso en producción por un tiempo.
class SearchViewModel @Inject constructor(private val repository: SearchRepository,) : ViewModel() {private val query = MutableStateFlow("")val results: StateFlow<List<SearchResult>> = query.debounce(300).filter { it.length >= 2 }.distinctUntilChanged().flatMapLatest { repository.search(it) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())private val _events = MutableSharedFlow<SearchEvent>(extraBufferCapacity = 1)val events: SharedFlow<SearchEvent> = _events.asSharedFlow()fun onQueryChanged(text: String) {query.value = text}fun onResultClicked(result: SearchResult) {viewModelScope.launch { _events.emit(SearchEvent.OpenDetail(result.id)) }}}
Ese pipeline de búsqueda mientras se tipea — debounce después filter después distinctUntilChanged después flatMapLatest — es el ejemplo de entrevista con Flow más común de todos, precisamente porque LiveData no tiene ninguna forma equivalente de expresar "cancelá la búsqueda en curso cuando llega una tecla nueva" sin rastrear y cancelar un Job manualmente.
@Composablefun SearchScreen(viewModel: SearchViewModel = hiltViewModel()) {val results by viewModel.results.collectAsStateWithLifecycle()LaunchedEffect(Unit) {viewModel.events.collect { event ->when (event) {is SearchEvent.OpenDetail -> { /* navegar al detalle */ }}}}SearchResultsList(results = results)}
Este SharedFlow (con extraBufferCapacity = 1, sin replay) está haciendo el trabajo que antes requería un hack tipo SingleLiveEvent encima de LiveData — un evento de un solo disparo (navegación, snackbar) que no debería volver a dispararse solo porque la pantalla se recreó y se volvió a suscribir. StateFlow siempre repite su valor actual (esa es la idea, para estado); SharedFlow con replay = 0 deliberadamente no lo hace (esa es la idea, para eventos).
#La regla de decisión real en 2025
- Pantallas nuevas en Compose, o cualquier lógica de negocio que ya sea Flow-nativa (Room, DataStore, la mayoría de las librerías modernas exponen Flow) —
StateFlowpara estado,SharedFlowpara eventos de un solo disparo, recolectados concollectAsStateWithLifecycle(). Esta es la opción por defecto; la propia guía y los samples de Google (Now in Android, la guía de arquitectura) lideran con Flow, y la documentación deLiveDatala describe como adecuada para casos simples e interoperabilidad con Java, no como la recomendación de cara al futuro. - Una pantalla todavía construida sobre el sistema de Views/Fragments, especialmente una a mitad de migración —
LiveDataen ese límite sigue siendo perfectamente razonable, o se puede hacer un puente conflow.asLiveData()para que la lógica delViewModelsubyacente siga basada en Flow mientras la pantalla legacy sigue observandoLiveDatacomo siempre. - Interoperabilidad con una librería que solo expone
LiveData(un caso notable: los observers deWorkInfodeWorkManager) — convertir en el límite con.asFlow()en vez de dejar queLiveDatase propague de vuelta hacia código que por lo demás está basado en Flow. - Cualquier cosa que necesite
combine,debounce,flatMapLatest, o trabajo fuera del hilo principal — Flow, sin mucho debate;LiveDatasimplemente no tiene el vocabulario para eso.
🧠 Check your understanding
0/2 · 0/2 answered1. ¿Por qué importa collectAsStateWithLifecycle() para Flow en Compose, comparado con collectAsState() a secas?
2. ¿Cuál es el riesgo de usar un SharedFlow con replay = 0 por defecto y sin extraBufferCapacity para un evento de UI de un solo disparo emitido desde init { }?