wandres.dev
ESTADO EN COMPOSE · recomposición

collectAsStateWithLifecycle: consumir el StateFlow

Entre el StateFlow que expone el ViewModel y la pantalla que lo dibuja hay una frontera delicada: la del ciclo de vida. Esta lección estudia por qué collectAsState es insuficiente en Android y por qué collectAsStateWithLifecycle es la forma correcta de cruzar esa frontera. Se desmonta la diferencia entre el ciclo de vida de la composición y el del propietario de la vista, el coste real de mantener una colección viva con la app en segundo plano, la mecánica de repeatOnLifecycle que suspende y reanuda la suscripción en STARTED, y la interacción con el conteo de suscriptores de WhileSubscribed. Se cierra con la disciplina de dónde leer el estado para que la recomposición no se propague más arriba de lo necesario.

⏱ 18 min

El estado ya está donde debe estar: en un StateFlow dentro del container, sin depender de Android, listo para ser observado. Falta cruzar la última frontera, que es la más traicionera de todas: la de una app móvil cuyo proceso puede quedar en segundo plano en cualquier instante sin que nadie se lo pida al usuario. Un Flow no sabe nada de eso. Una corrutina que colecciona seguirá coleccionando mientras su ámbito viva, esté la pantalla visible o enterrada bajo otras tres apps, y cada emisión que llega a una pantalla invisible es trabajo pagado a cambio de nada. collectAsStateWithLifecycle existe para poner esa disciplina donde el Flow no la tiene, y entender por qué hace falta explica media década de fugas y consumos de batería inexplicables.

🎯 Al terminar esta lección sabrás
  • Distinguir el ciclo de vida de la composición del ciclo de vida del propietario de la vista.
  • Entender por qué collectAsState sigue coleccionando con la pantalla en segundo plano y qué cuesta eso.
  • Comprender repeatOnLifecycle en estado iniciado como el mecanismo que suspende y reanuda la suscripción.
  • Saber dónde leer el estado para acotar el ámbito de recomposición y no propagarla hacia arriba.

Dos ciclos de vida que no coinciden

La confusión de fondo es que en una app con Compose conviven dos relojes distintos. Está el ciclo de vida de la composición, que empieza cuando un componible entra en el árbol y termina cuando sale de él, y está el ciclo de vida del propietario de la vista, que atraviesa creado, iniciado y reanudado siguiendo lo que hace el usuario con la app. Cuando el usuario pulsa el botón de inicio, la Activity pasa a detenida pero la composición no se desmonta: los componibles siguen en el árbol, y con ellos siguen vivas todas las corrutinas atadas a la composición. Nada se ha destruido; simplemente ha dejado de verse.

collectAsState mide con el primer reloj. Lanza la colección en el ámbito de la composición y la mantiene hasta que el componible abandona el árbol. Es lo correcto en un escritorio o en la web, donde no existe la noción de app en segundo plano, y es exactamente lo incorrecto en Android. La consecuencia práctica es que el StateFlow sigue recibiendo emisiones, y si detrás de ese flujo hay una consulta a la base de datos, un socket abierto o una recolección de sensores, todo eso continúa produciendo con la pantalla invisible.

// Mide con el reloj de la composicion: sigue coleccionando en segundo plano
val estado by viewModel.container.stateFlow.collectAsState()

// Mide con el reloj del propietario de la vista: se detiene al salir de iniciado
val estado by viewModel.container.stateFlow.collectAsStateWithLifecycle()

Conviene medir el coste real antes de aceptar que esto importa, porque en una pantalla trivial no se nota nada. El coste aparece cuando detrás del flujo hay producción de verdad: una consulta a la base de datos que se reevalúa en cada escritura de otra parte de la app, un sondeo periódico al servidor, la escucha de la ubicación, un socket que mantiene una conexión abierta. Con collectAsState, todo eso sigue funcionando con la pantalla enterrada bajo otras apps, y cada emisión provoca además una recomposición de una interfaz que nadie mira. Multiplica por las pantallas que el usuario dejó atrás sin cerrar y tendrás la explicación de una clase entera de informes de consumo de batería que nadie sabía reproducir.

La diferencia cabe en una palabra del nombre y separa dos comportamientos que no se parecen. La segunda variante recibe implícitamente el LifecycleOwner del contexto local y encierra la colección en repeatOnLifecycle con el estado iniciado como umbral: mientras la pantalla esté al menos iniciada, la colección corre; en cuanto baja de ese umbral, la corrutina se cancela por completo; cuando vuelve a subir, se lanza una nueva. No es una pausa perezosa ni un filtro de emisiones: es cancelación real y relanzamiento, que es lo único que libera de verdad los recursos aguas arriba.

ℹ️
Por que el umbral es iniciado y no reanudado

Iniciado significa visible aunque no tenga el foco; reanudado significa además en primer plano e interactuable. Elegir iniciado como umbral es lo que permite que una pantalla parcialmente tapada por un diálogo, en modo multiventana o en transición siga mostrando datos actualizados en lugar de congelarse. Si el umbral fuera reanudado, cualquier diálogo del sistema dejaría la pantalla de debajo con datos viejos. Iniciado es el punto exacto donde el usuario todavía puede ver, que es lo que justifica seguir pagando por producir datos.

Cancelar aguas arriba: el efecto que se propaga

Lo interesante no es que la interfaz deje de recibir, sino lo que esa cancelación desencadena hacia atrás. Si el StateFlow de la pantalla se construyó con stateIn y la política WhileSubscribed, el número de coleccionistas gobierna si la producción sigue viva. Cuando collectAsStateWithLifecycle cancela su colección, el contador de suscriptores baja a cero, y tras el tiempo de gracia configurado la fuente de arriba se detiene también: la consulta a la base de datos se cierra, el sondeo de red se para, la escucha del sensor se libera.

flowchart TD
A[Usuario sale de la pantalla] --> B[Lifecycle baja de iniciado]
B --> C[repeatOnLifecycle cancela la corrutina]
C --> D[Suscriptores del StateFlow bajan a cero]
D --> E[WhileSubscribed detiene la fuente aguas arriba]
E --> F[Sin consultas ni sensores en segundo plano]
style F fill:#a6e3a1,color:#11111b
style C fill:#89b4fa,color:#11111b

El tiempo de gracia merece un comentario, porque es donde se decide si la optimización ayuda o estorba. Una rotación de pantalla destruye y recrea la Activity en cuestión de milisegundos; sin margen, el contador de suscriptores bajaría a cero y la fuente se reiniciaría entera para nada. Cinco segundos es el valor convencional: suficiente para absorber un cambio de configuración y demasiado corto para mantener trabajo vivo cuando el usuario se ha ido de verdad.

val estadoUi: StateFlow<UiState> = repo.observarItems()
    .map { items -> UiState(items = items, cargando = false) }
    .stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5_000),
        initialValue = UiState(cargando = true),
    )

Con Orbit el reparto es el mismo aunque el stateFlow lo construya el container: la política de arranque se configura en los ajustes del contenedor y la pantalla sigue coleccionando con collectAsStateWithLifecycle. Lo que no cambia nunca es el principio: la interfaz declara cuándo está mirando, y la capa de datos decide qué hacer con esa información. Ninguna de las dos consulta a la otra.

El valor inicial merece una nota, porque es donde se manifiesta un compromiso incómodo. Al cancelarse la colección, la interfaz conserva el último valor que recibió, así que al volver a primer plano la pantalla muestra datos posiblemente viejos durante los milisegundos que tarda la primera emisión nueva. La alternativa —volver al valor inicial de carga— produciría un parpadeo mucho peor: el usuario vería su pantalla vaciarse y rellenarse cada vez que vuelve de responder un mensaje. Mostrar lo último conocido y actualizar en cuanto llegue algo mejor es casi siempre la elección correcta, y solo deja de serlo cuando la caducidad del dato es crítica, como en un precio o un saldo, donde conviene marcar visualmente que lo mostrado se está revalidando.

Existe además una variante de grano más fino para cuando no quieres un estado de Compose sino solo restringir un flujo al ciclo de vida antes de operar con él: envolver el flujo con el ayudante consciente del ciclo de vida y coleccionarlo después donde te convenga. Es la pieza de bajo nivel sobre la que se construye la función que has visto, y aparece cuando necesitas encadenar operadores entre la restricción de visibilidad y el consumo final.

// Grano fino: primero restringir al ciclo de vida, después operar y coleccionar
val owner = LocalLifecycleOwner.current
LaunchedEffect(owner) {
    viewModel.container.stateFlow
        .flowWithLifecycle(owner.lifecycle, Lifecycle.State.STARTED)
        .map { it.mensajesSinLeer }
        .distinctUntilChanged()
        .collect { contador -> insignia.actualizar(contador) }
}

También conviene saber que el umbral es un parámetro y no un dogma. La función acepta el estado mínimo activo, de modo que una pantalla que consume batería de forma agresiva —una cámara, un mapa con seguimiento— puede exigir el estado reanudado para detenerse en cuanto pierde el foco, aunque siga parcialmente visible. Subir el umbral es una decisión de producto disfrazada de detalle técnico: estás decidiendo que para ese dato concreto ver a medias no justifica el gasto.

Dónde leer el estado importa tanto como cómo

Queda una decisión que suele pasarse por alto y que determina el rendimiento real de la pantalla. Leer un estado observable dentro de un componible suscribe a ese componible a los cambios de ese valor. Si lees el estado completo en la raíz de la pantalla y luego repartes campos a los hijos, cualquier cambio en cualquier campo invalida la raíz entera, y aunque Compose se salte los hijos cuyos parámetros no cambiaron, has movido el punto de invalidación al lugar más alto y más caro del árbol.

@Composable
fun PerfilScreen(viewModel: PerfilViewModel) {
    val estado by viewModel.container.stateFlow.collectAsStateWithLifecycle()

    Column {
        Cabecera(nombre = estado.nombre)              // solo se recompone si cambia nombre
        Contador(valor = estado.mensajesSinLeer)      // solo se recompone si cambia el contador
        Lista(items = estado.items, onClick = viewModel::abrir)
    }
}

Este reparto funciona porque los hijos reciben valores estables y comparables: si nombre no cambió, Cabecera se salta. La regla general que se deriva es sencilla de enunciar y fácil de violar: lee el estado lo más abajo posible en el árbol y pasa hacia abajo los valores concretos, no el objeto entero, y cuando un valor cambie a alta frecuencia —un desplazamiento, una animación, un campo de texto— pásalo como una función que lo produce en lugar de como un valor ya leído, para que la lectura ocurra en la fase de dibujo y no invalide la composición.

Hay una tensión real entre esta regla y la del izado que estudiarás a continuación, y conviene verla ahora para no aplicar ninguna de las dos como dogma. Izar empuja el estado hacia arriba para que un solo dueño lo gobierne; acotar la recomposición empuja la lectura hacia abajo para que la invalidación sea barata. No se contradicen porque hablan de cosas distintas: quién posee el dato es una decisión de arquitectura, y dónde se lee es una decisión de rendimiento. La combinación correcta es un único dueño arriba y lecturas tardías abajo, que se consigue pasando funciones que leen en vez de valores ya leídos cuando la frecuencia lo justifica.

Un último matiz sobre la desestructuración por delegación. Escribir el estado con by es cómodo, pero recuerda que la lectura ocurre en el punto donde usas la variable, no donde la declaras; si la usas dentro de una lambda que se ejecuta más tarde, la suscripción se registra en el ámbito de esa lambda y no en el del componible. Eso no es un detalle sintáctico: es exactamente el mecanismo que te permite mover la lectura de fase, y explica por qué dos códigos que parecen idénticos pueden tener perfiles de recomposición muy distintos.

💡
El nombre de la funcion delata el reloj que usa

Cuando dudes entre dos API de Compose que hacen aparentemente lo mismo, mira si el nombre menciona el ciclo de vida. collectAsState y collectAsStateWithLifecycle no son sinónimos con distinto largo: son dos contratos distintos sobre cuándo se deja de trabajar. En una app Android con Activity o Fragment la respuesta correcta es prácticamente siempre la segunda, y la primera queda para los objetivos de Compose donde no existe un propietario de ciclo de vida, como escritorio o web en Multiplatform.

Derivar en la interfaz sin ensuciar el modelo

Queda un caso frecuente que la regla anterior no resuelve por sí sola: la interfaz necesita un valor que no está en el estado pero se deduce de él, y deducirlo en cada recomposición sería caro o provocaría invalidaciones espurias. El ejemplo canónico es un botón de volver arriba que aparece cuando el usuario ha pasado del primer elemento: el índice de desplazamiento cambia decenas de veces por segundo, pero la respuesta a la pregunta relevante cambia una sola vez.

val listState = rememberLazyListState()

// Sin derivar: la condicion se evalua y propaga en cada pixel de scroll
val mostrarBoton = listState.firstVisibleItemIndex > 0

// Derivando: solo se notifica cuando el resultado booleano cambia de verdad
val mostrarBoton by remember {
    derivedStateOf { listState.firstVisibleItemIndex > 0 }
}

La diferencia es de granularidad de notificación, no de cálculo. El estado derivado observa las lecturas que hace su bloque, lo recalcula cuando alguna cambia y solo despierta a sus lectores si el resultado difiere del anterior. Es, en pequeño, la misma idea que la conflación de un StateFlow: interponer una comparación entre una fuente ruidosa y un consumidor caro.

📝
Derivar no es una excusa para meter negocio en la pantalla

Esta herramienta sirve para valores de presentación deducidos de otros valores de presentación, casi siempre de alta frecuencia. Si lo que estás derivando es una regla del dominio —si un pedido es válido, si el usuario puede confirmar, qué precio final se aplica— ese cálculo pertenece al reductor y debe llegar ya resuelto en el estado. La señal de alarma es tener que importar reglas de negocio dentro de un bloque de derivación en un archivo de interfaz.

El ciclo de vida no es una molestia de Android, es informacion

Cuesta años dejar de ver el ciclo de vida como un impuesto burocrático que la plataforma cobra por dejarte dibujar. Casi todo el código Android malo de la década pasada nació de esa lectura: gente peleando contra el ciclo de vida, guardando referencias para sobrevivir a él, inventando envoltorios para ignorarlo. Pero el ciclo de vida no es un obstáculo entre tú y la pantalla: es la única fuente fiable de un dato que ninguna otra capa del sistema conoce, y es un dato precioso. Ese dato es si el usuario está mirando. Piensa en lo que significa. Toda la producción de datos de una app —consultar, sondear, escuchar sensores, mantener sockets, decodificar, calcular— tiene sentido económico solo si hay unos ojos al final de la cadena. Sin esa señal, un sistema reactivo es una fábrica que produce a máxima capacidad sin saber si el almacén está vacío o si nadie compra; con esa señal, la producción se acopla al consumo real. Por eso collectAsStateWithLifecycle no es un ayudante de conveniencia sino la pieza que conecta dos mundos que necesitaban hablarse: traduce un hecho de la plataforma —esta pantalla ya no se ve— a un hecho del mundo reactivo —este flujo ya no tiene coleccionistas— y deja que la contrapresión haga el resto sin que nadie escriba una condición. Y observa la elegancia de la traducción: no hay una llamada de vuelta al repositorio, ni un método de pausar, ni una bandera de visible; hay un contador de suscriptores que baja solo. La visibilidad de la pantalla se convierte en una propiedad emergente del grafo de flujos. Cuando entiendas eso, dejarás de pelear con el ciclo de vida y empezarás a usarlo como lo que es: el sensor más barato y más fiable de si tu trabajo le importa a alguien ahora mismo.

⚔️ Ata el flujo al reloj correcto
  1. Explica con precisión qué ocurre con una corrutina lanzada por collectAsState cuando el usuario pulsa el botón de inicio, y por qué la composición no se desmonta.
  2. Justifica por qué el umbral de repeatOnLifecycle es el estado iniciado y qué se rompería si fuera reanudado.
  3. Traza la cadena completa desde que el usuario abandona la pantalla hasta que se cierra la consulta a la base de datos, nombrando cada eslabón.
  4. Razona por qué WhileSubscribed necesita un tiempo de gracia y qué pasaría con una rotación de pantalla si valiera cero.
  5. Toma una pantalla tuya que lea el estado completo en la raíz y reescríbela pasando valores concretos a los hijos; argumenta qué recomposiciones dejan de ocurrir.