Recoger un Flow con el ciclo de vida
Al terminar: Usar collectAsStateWithLifecycle y ver cómo un partido guardado en remember se reinicia al rotar.
Al terminar esta lección, la pantalla del marcador recogerá el marcador de un partido en vivo con collectAsStateWithLifecycle, y tendrás un interruptor para cambiar a collectAsState y ver en Logcat la diferencia cuando la app pasa a segundo plano. Además, el partido vivirá dentro de remember, y verás cómo se reinicia al girar el móvil. Es un fallo a propósito: lo resuelve el ViewModel en la etapa siguiente (un ViewModel es un objeto que Android conserva aunque la pantalla se destruya y se cree de nuevo al girar el móvil).
Importa porque recoger un Flow mal en Android no rompe nada a la vista, pero deja trabajo corriendo para una pantalla que nadie ve. Y porque ver un fallo antes de arreglarlo es lo que hace que el arreglo se entienda.
Tema: collectAsStateWithLifecycle frente a collectAsState. No hay nada nuevo que instalar: la función vive en lifecycle-runtime-compose, que instalaste en Efectos y ciclo de vida: DisposableEffect.
De Flow a estado de Compose
Compose solo se redibuja cuando cambia un estado de Compose (State). Un Flow no lo es, así que hay que convertirlo: recoger sus valores y meterlos en un State. Hay dos funciones para hacerlo.
collectAsState | collectAsStateWithLifecycle | |
|---|---|---|
| Recoge mientras… | el composable está en la composición | el ciclo de vida está al menos en STARTED (la pantalla se ve) |
| App en segundo plano | sigue recogiendo | se cancela; al volver, se reanuda con el último valor |
| Dónde funciona | cualquier plataforma (código común) | solo Android |
| Librería | compose-runtime | lifecycle-runtime-compose (la que instalaste en Efectos y ciclo de vida: DisposableEffect) |
val marcadorEnVivo = MutableStateFlow(Marcador(0, 0)) // un StateFlow: siempre tiene un valor
val flujoNormal = marcadorEnVivo.onEach { Log.d("Marcador", "llega $it") } // tras onEach ya es un Flow normal, sin valor garantizado
// Con un StateFlow no hace falta valor inicial: siempre tiene uno.
val marcador by marcadorEnVivo.collectAsStateWithLifecycle()
// Con un Flow normal sí hay que darlo:
val marcadorConLog by flujoNormal.collectAsStateWithLifecycle(initialValue = Marcador(0, 0))Error típico: collectAsState en una app Android
val marcador by marcadorEnVivo.collectAsState()val marcador by marcadorEnVivo.collectAsStateWithLifecycle()El primero compila y se ve igual en pantalla; el fallo es invisible. Con la app en segundo plano, la Activity está parada pero la composición sigue existiendo, así que la recogida continúa: trabajo (y batería) para una pantalla que nadie ve. Lo vas a comprobar en Logcat.
Un ejemplo antes de la pantalla: un termómetro
La misma forma que usarás en Marcador, con otros datos: un termómetro inventado que emite temperaturas, con un interruptor «ahorrar batería» que decide cómo se recoge. No es la recogida de Marcador.
// Quien lo usa (dentro de otro composable): el flujo de temperaturas empieza en 21.5,
// y de ahí sale el «21.5 °C» de abajo
// val temperaturas = MutableStateFlow(21.5)
// Termometro(temperaturas)
@Composable
fun Termometro(temperaturas: StateFlow<Double>) {
var ahorrar by remember { mutableStateOf(true) } // variable by: la lambda de abajo la lee siempre al día
val flujo = remember { // el flujo con onEach se crea UNA vez, no en cada pasada
temperaturas.onEach { Log.d("Termo", "llega $it (ahorrar=$ahorrar)") } // onEach: deja pasar cada valor y hace algo con él; aquí lo escribe
}
val grados by if (ahorrar) { // by vale con cualquier expresión que dé un State
flujo.collectAsStateWithLifecycle(initialValue = temperaturas.value) // tras onEach ya no es StateFlow: pide valor inicial
} else {
flujo.collectAsState(initial = temperaturas.value) // ojo: aquí el parámetro se llama initial
}
Row(verticalAlignment = Alignment.CenterVertically) {
Switch(checked = ahorrar, onCheckedChange = { ahorrar = it }) // el interruptor cambia la variable
Text("$grados °C")
}
}Lo que ves: el interruptor encendido y 21.5 °C; en Logcat, llega 21.5 (ahorrar=true).
Si mueves el interruptor, la línea siguiente de Logcat dice (ahorrar=false). Empieza otra recogida, y la lambda lee la variable by cada vez que pasa un valor, así que ve siempre el valor de ahora: una variable by guarda un objeto State y lo consulta cada vez que la nombras. Un val normal, en cambio, sería una copia, como en Efectos en Compose: LaunchedEffect.
Reto · Ejercicio: el podómetro
Vas a ver en Logcat lo que la lección afirma: con la app en segundo plano, una recogida sigue y la otra se para. Para mirar Logcat no sirve el modo interactivo: usa Run Preview, la vista previa ejecutada en el emulador que presentó Estado en Compose (el triángulo verde junto a @Preview).
En la carpeta ejercicios (la que creaste en La rotación y la muerte del proceso): clic derecho sobre com.sebastiannarvaez.marcador → New → Package → escribe .ejercicios.podometro. Luego clic derecho sobre esa carpeta → New → Kotlin Class/File → File → Podometro.
Escribe un composable Podometro y su vista previa:
- Recuerda un
StateFlowde pasos (un flujo que siempre tiene un valor, el del ejemplo de las temperaturas), que empieza en 0. - Al entrar en la composición, un efecto le suma un paso cada segundo, para siempre.
- A partir de ese flujo crea dos flujos nuevos, cada uno con su propio
onEachy recordado con suremember(así se crean una sola vez). Cada valor que pase por ellos deja una línea en Logcat con la etiquetaPodometro: «con ciclo de vida: N» en uno y «sin ciclo de vida: N» en el otro (como el termómetro de arriba). - Recoge el primer flujo teniendo en cuenta el ciclo de vida y el segundo sin tenerlo, los dos con valor inicial 0, y muestra «Con ciclo de vida: N pasos» y «Sin ciclo de vida: N pasos».
Ejecuta la vista previa con Run Preview y filtra Logcat con tag:Podometro. En pantalla, los dos números suben a la vez, y en Logcat sale una pareja de líneas por segundo. Pulsa Home, espera cinco segundos y vuelve a la app desde recientes. Debes ver que tras Home solo siguen las líneas de «sin ciclo de vida»; al volver, «con ciclo de vida» reaparece saltando al número de ahora (sin los intermedios: es un StateFlow) y vuelven las parejas. Al terminar, elige otra vez app en el selector de ejecución de arriba.
Ver solución
package com.sebastiannarvaez.marcador.ejercicios.podometro
import android.util.Log
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
@Composable
fun Podometro(modifier: Modifier = Modifier) {
val pasos = remember { MutableStateFlow(0) } // un StateFlow: siempre tiene un valor
LaunchedEffect(Unit) { // simula los pasos: uno por segundo
while (true) {
delay(1000)
pasos.update { it + 1 }
}
}
val flujoConCiclo = remember { pasos.onEach { Log.d("Podometro", "con ciclo de vida: $it") } }
val flujoSinCiclo = remember { pasos.onEach { Log.d("Podometro", "sin ciclo de vida: $it") } }
val conCiclo by flujoConCiclo.collectAsStateWithLifecycle(initialValue = 0)
val sinCiclo by flujoSinCiclo.collectAsState(initial = 0)
Column(modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Con ciclo de vida: $conCiclo pasos")
Text("Sin ciclo de vida: $sinCiclo pasos")
}
}
@Preview(showBackground = true)
@Composable
private fun PodometroPreview() {
MaterialTheme { Podometro() }
}La recogida con ciclo de vida se para al pasar la app a segundo plano y se reanuda al volver; la otra sigue recogiendo (y gastando) aunque nadie mire la pantalla.
Paso · el partido en vivo dentro de remember
Objetivo. La pantalla del marcador tiene un partido en vivo: al pulsar «Empezar partido», el minuto avanza, los goles del guion llegan solos y el marcador se recoge de un StateFlow con collectAsStateWithLifecycle. Un interruptor cambia entre las dos funciones y Logcat (Recoleccion) escribe cada valor que llega. Al girar el móvil, todo vuelve a empezar.
Nada que instalar. Todo lo que usa este paso ya está en tu proyecto: collectAsStateWithLifecycle viene de lifecycle-runtime-compose (la instalaste en Efectos y ciclo de vida: DisposableEffect; si Android Studio marca en rojo androidx.lifecycle.compose.collectAsStateWithLifecycle, falta esa dependencia o su Sync Now); MutableStateFlow, onEach y update vienen de las corrutinas que declaraste en :app en El hilo principal y el ANR; y despuesDe es una función de tu módulo :torneo.
Dónde está el fichero. PantallaMarcador.kt está en app → kotlin+java → com.sebastiannarvaez.marcador → feature → marcador → ui: ábrelo con doble clic en el panel Project, con el selector en Android.
Qué hay que cambiar. Cuatro cambios. Tres te los doy hechos, para que te quedes con lo nuevo de la lección (la recogida y el interruptor), que escribes tú después:
Efectos.kt: seleccionaPanelDelCronometroentera (desde su@Composablehasta su llave de cierre) y sustitúyela por la de abajo (pierde Pausa y Reiniciar y gana un solo botón, «Empezar partido»: un partido en vivo solo avanza). Después borra la líneaimport androidx.compose.foundation.layout.Row, que deja de usarse. Android Studio también marcará en grisimport androidx.compose.ui.Alignment: es un aviso (un nombre importado que ya nadie usa), no un error: bórralo.PantallaMarcador.kt, imports: los de abajo se añaden junto a los demás (o con Option+Enter sobre cada nombre en rojo, eligiendo el deandroidx).PantallaMarcador.kt, el estado. Dentro dePantallaMarcador, dejaval partido = Ejemplo.rayoContraTorosy sustituye todo lo que viene después, desde la líneavar golesLocalhasta la líneaTituloDeLaActividad(estado.marcador), ambas incluidas, por el bloque nuevo de abajo. Ese tramo incluyevar pausasy las dos llamadas de la lección anterior: el bloque nuevo ya las trae, así que no se quedan en el fichero dos veces. Es lo que borras:
var golesLocal by rememberSaveable { mutableIntStateOf(0) }
var golesVisitante by rememberSaveable { mutableIntStateOf(0) }
val estado = EstadoMarcador(partido, Marcador(golesLocal, golesVisitante))
var minuto by rememberSaveable { mutableIntStateOf(0) }
var corriendo by rememberSaveable { mutableStateOf(false) }
var ultimoAviso by rememberSaveable { mutableStateOf("ninguno") }
var pausas by rememberSaveable { mutableIntStateOf(0) }
RelojDelPartido(corriendo, minutoActual = minuto, msPorMinuto = 250) { nuevoMinuto ->
minuto = nuevoMinuto
if (nuevoMinuto % 15 == 0) {
ultimoAviso = "minuto $nuevoMinuto con ${estado.marcador}"
}
if (nuevoMinuto >= MINUTOS_DEL_PARTIDO) corriendo = false
}
ObservadorDelCiclo(alPararse = { pausas++ })
TituloDeLaActividad(estado.marcador)PantallaMarcador.kt,MarcadorContent(…)y la constante. Sustituye la llamada aMarcadorContent(…)entera por la de abajo (cambian elonGol, el panel del cronómetro y unaRowenextras), y añade una constante nueva a nivel de fichero, justo después de la funciónPantallaMarcador.
Por qué remember y no rememberSaveable para el estado. En State hoisting los goles pasaron a rememberSaveable para sobrevivir a la rotación (la diferencia entre las dos, en Estado en Compose: remember se pierde al rotar y rememberSaveable guarda en el Bundle, si el valor cabe). Aquí vuelven a remember a propósito: los goles a mano, el minuto, el botón y el aviso pertenecen al mismo partido que marcadorEnVivo, que no cabe en un Bundle. Si unos sobrevivieran y el otro no, tras girar verías el minuto 45' con un 0-0 y un reloj muerto. Con todo en remember, el partido entero se reinicia junto, y el fallo se ve limpio. Solo pausas sigue en rememberSaveable: cuenta las veces que la pantalla pasó a segundo plano, y eso no es del partido.
Piezas nuevas de este paso:
partido.eventos: la lista de eventos del guion del partido; cada uno tiene unminuto.Marcador.despuesDe(evento, partido): función de:torneoque devuelve el marcador tras un evento (un gol suma; lo demás lo deja igual).val x by if (c) { a } else { b }:byfunciona con cualquier expresión que devuelva unState; aquí elige cuál recoger.Button(enabled = …): conenabled = falseel botón se ve apagado y no responde al toque; aquí, «Empezar partido» solo se puede pulsar si el partido no está en marcha y todavía no ha empezado (!corriendo && minuto == 0).
Ya conoces MutableStateFlow y .update de StateFlow y SharedFlow, fold de Colecciones (aquí acumula el marcador tras cada evento del minuto) y Switch de Recomposición. onEach deja pasar cada valor de un flujo y hace algo con él (lo viste de pasada en Flow frío); el termómetro de arriba ya lo usa.
Log.d(tag, mensaje) ya lo conoces de El ciclo de vida de la Activity. Row, Arrangement y Alignment ya los usa PantallaMarcador desde Compose: composables y modifiers.
Lo que te doy hecho
El panel del cronómetro, que sustituye al de Efectos.kt:
@Composable
fun PanelDelCronometro(
minuto: Int,
corriendo: Boolean,
ultimoAviso: String,
pausas: Int,
onEmpezar: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Minuto $minuto'", style = MaterialTheme.typography.headlineMedium)
Button(onClick = onEmpezar, enabled = !corriendo && minuto == 0) {
Text(if (minuto >= MINUTOS_DEL_PARTIDO) "Partido terminado" else if (corriendo) "En juego" else "Empezar partido")
}
Text("Último aviso (cada 15'): $ultimoAviso")
Text("Veces que la pantalla pasó a segundo plano: $pausas")
}
}Los imports nuevos de PantallaMarcador.kt:
import android.util.Log
import androidx.compose.material3.Switch
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.sebastiannarvaez.marcador.torneo.despuesDe
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.updateEl estado nuevo, con un hueco donde irá tu recogida. El resto ya está completo: el partido en vivo, los goles a mano, el reloj que aplica los goles del guion y las dos llamadas de la lección anterior:
val marcadorEnVivo = remember { MutableStateFlow(Marcador(0, 0)) }
var golesLocalAMano by remember { mutableIntStateOf(0) }
var golesVisitanteAMano by remember { mutableIntStateOf(0) }
// ← AQUÍ VA TU RECOGIDA: conCicloDeVida, flujo, marcadorDelPartido y estado
var minuto by remember { mutableIntStateOf(0) }
var corriendo by remember { mutableStateOf(false) }
var ultimoAviso by remember { mutableStateOf("ninguno") }
var pausas by rememberSaveable { mutableIntStateOf(0) }
RelojDelPartido(corriendo, minutoActual = minuto, msPorMinuto = MS_POR_MINUTO) { nuevoMinuto ->
marcadorEnVivo.update { antes ->
partido.eventos
.filter { it.minuto == nuevoMinuto } // los eventos del guion que caen en este minuto
.fold(antes) { m, evento -> m.despuesDe(evento, partido) } // se aplican uno a uno al marcador de antes
}
minuto = nuevoMinuto
if (nuevoMinuto % 15 == 0) {
val vivo = marcadorEnVivo.value
ultimoAviso = "minuto $nuevoMinuto con ${Marcador(vivo.local + golesLocalAMano, vivo.visitante + golesVisitanteAMano)}"
}
if (nuevoMinuto >= MINUTOS_DEL_PARTIDO) corriendo = false
}
ObservadorDelCiclo(alPararse = { pausas++ })
TituloDeLaActividad(estado.marcador)La llamada a MarcadorContent (los goles pasan a llamarse golesLocalAMano y golesVisitanteAMano; el panel pierde onAlternar y onReiniciar y gana onEmpezar), con un hueco para el interruptor, y la constante (250L son los milisegundos que dura cada minuto de partido; sustituye al 250 suelto del reloj):
MarcadorContent(
estado = estado,
onGol = { lado ->
when (lado) {
Lado.LOCAL -> golesLocalAMano++
Lado.VISITANTE -> golesVisitanteAMano++
}
},
modifier = Modifier.padding(paddingValues),
extras = {
PanelDelCronometro(
minuto, corriendo, ultimoAviso, pausas,
onEmpezar = { corriendo = true },
)
// ← AQUÍ VA TU INTERRUPTOR
ComparacionDeEstado()
OrdenDeLosModifiers()
InsigniaSobreEscudo()
},
)private const val MS_POR_MINUTO = 250LLo que escribes tú
Intenta escribirlo tú, en los dos huecos (el termómetro de arriba tiene la misma forma):
El primer hueco, la recogida, son cuatro cosas, en este orden:
conCicloDeVida: un valor recordado que empieza entruey dice si se recoge teniendo en cuenta el ciclo de vida.flujo:marcadorEnVivoenvuelto una sola vez (conremember) para que cada valor que pase deje una línea en Logcat, con el tagRecoleccion, con el marcador y la forma de recogerlo.marcadorDelPartido: ese flujo recogido con una función o con la otra, segúnconCicloDeVida.estado: elEstadoMarcadorde la pantalla, cuyo marcador suma los goles del partido en vivo y los goles a mano.
El segundo hueco, el interruptor: en una fila, un interruptor ligado a conCicloDeVida y un texto que diga qué función se usa: «collectAsStateWithLifecycle» o «collectAsState (sin ciclo de vida)».
Debes ver el marcador cambiar por sí solo mientras el partido está en marcha, y el 1-0 hacia el minuto 12. Y en Logcat, con el interruptor encendido:
D Recoleccion: llega 0-0 (con ciclo de vida)
D Recoleccion: llega 1-0 (con ciclo de vida)Intenta primero. Cuando termines, compara con la solución.Mostrar el código
La recogida, en el primer hueco:
var conCicloDeVida by remember { mutableStateOf(true) }
val flujo = remember {
marcadorEnVivo.onEach {
Log.d("Recoleccion", "llega $it (${if (conCicloDeVida) "con ciclo de vida" else "collectAsState"})")
}
}
val marcadorDelPartido by if (conCicloDeVida) {
flujo.collectAsStateWithLifecycle(initialValue = marcadorEnVivo.value)
} else {
flujo.collectAsState(initial = marcadorEnVivo.value)
}
val estado = EstadoMarcador(
partido,
Marcador(marcadorDelPartido.local + golesLocalAMano, marcadorDelPartido.visitante + golesVisitanteAMano),
)El interruptor, en el segundo hueco:
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Switch(checked = conCicloDeVida, onCheckedChange = { conCicloDeVida = it })
Text(if (conCicloDeVida) "collectAsStateWithLifecycle" else "collectAsState (sin ciclo de vida)")
}Comprobación: así queda el tramo de estado de PantallaMarcador con el primer hueco ya relleno, de punta a punta (tiene que ser igual al tuyo):
val marcadorEnVivo = remember { MutableStateFlow(Marcador(0, 0)) }
var golesLocalAMano by remember { mutableIntStateOf(0) }
var golesVisitanteAMano by remember { mutableIntStateOf(0) }
var conCicloDeVida by remember { mutableStateOf(true) }
val flujo = remember {
marcadorEnVivo.onEach {
Log.d("Recoleccion", "llega $it (${if (conCicloDeVida) "con ciclo de vida" else "collectAsState"})")
}
}
val marcadorDelPartido by if (conCicloDeVida) {
flujo.collectAsStateWithLifecycle(initialValue = marcadorEnVivo.value)
} else {
flujo.collectAsState(initial = marcadorEnVivo.value)
}
val estado = EstadoMarcador(
partido,
Marcador(marcadorDelPartido.local + golesLocalAMano, marcadorDelPartido.visitante + golesVisitanteAMano),
)
var minuto by remember { mutableIntStateOf(0) }
var corriendo by remember { mutableStateOf(false) }
var ultimoAviso by remember { mutableStateOf("ninguno") }
var pausas by rememberSaveable { mutableIntStateOf(0) }
RelojDelPartido(corriendo, minutoActual = minuto, msPorMinuto = MS_POR_MINUTO) { nuevoMinuto ->
marcadorEnVivo.update { antes ->
partido.eventos
.filter { it.minuto == nuevoMinuto }
.fold(antes) { m, evento -> m.despuesDe(evento, partido) }
}
minuto = nuevoMinuto
if (nuevoMinuto % 15 == 0) {
val vivo = marcadorEnVivo.value
ultimoAviso = "minuto $nuevoMinuto con ${Marcador(vivo.local + golesLocalAMano, vivo.visitante + golesVisitanteAMano)}"
}
if (nuevoMinuto >= MINUTOS_DEL_PARTIDO) corriendo = false
}
ObservadorDelCiclo(alPararse = { pausas++ })
TituloDeLaActividad(estado.marcador)Qué hace cada pieza
- Un solo reloj. El bucle de
RelojDelPartido(de Efectos en Compose: LaunchedEffect) es la única fuente del tiempo: en cada minuto aplica al marcador los goles del guion que caen en ese minuto y, si toca aviso, lo calcula con ese mismo marcador (marcadorEnVivo.value), no con el que la pantalla recogió en la composición anterior, que puede ir un paso por detrás. remember { MutableStateFlow(…) }. El partido se crea la primera vez y se guarda mientras el composable siga en la composición. Es lo que rompe al girar.onEachyLog.d. Escriben cada valor que llega a la recogida, con el nombre de la función usada. Sin recogida, no hay líneas.- La lambda de
onEachleeconCicloDeVidaaunque se creó una sola vez. Está dentro deremember { … }, así que se crea la primera vez y no se rehace. PeroconCicloDeVidaes una variableby: la lambda captura elStateque hay detrás, no una copia detrue, y cada vez que escribe en Logcat lo lee de nuevo. Por eso, al mover el interruptor, las líneas siguientes ya dicencollectAsState. Con unval activo = conCicloDeVidafuera de la lambda, se habría quedado contruepara siempre. - El
Switchy elifdeby. Mover el interruptor cambiaconCicloDeVida, Compose recomponePantallaMarcadory elifelige la otra función de recogida.
Compara las dos funciones en Logcat
Ejecuta la app con Run, con la variante gratisDebug elegida en View → Tool Windows → Build Variants (como en Gradle: flavors y variantes) y el emulador arrancado (como en La Activity). Abre View → Tool Windows → Logcat y escribe tag:Recoleccion en el campo de búsqueda de arriba. Para vaciarlo entre pruebas, usa el icono de la papelera de la ventana Logcat. Si prefieres la terminal, funciona igual:
adb logcat -c
adb logcat -s Recoleccionadb logcat -c vacía el registro y adb logcat -s Recoleccion se queda escuchando ese tag: déjalo en una terminal y sigue las pruebas en el emulador; con Ctrl+C lo cortas. Para volver a leerlo de una vez, usa adb logcat -d -s Recoleccion.
- Con el interruptor en collectAsStateWithLifecycle, pulsa Empezar partido e inmediatamente Home. Espera unos 15 segundos y vuelve a la app. Verás algo como:
D Recoleccion: llega 0-0 (con ciclo de vida)
D Recoleccion: llega 1-1 (con ciclo de vida)La primera línea, llega 0-0, es el valor de partida y solo sale al abrir la pantalla. Después, con la app parada no llega ninguna línea. Al volver llegó directamente 1-1: el 1-0 se lo saltó, porque un StateFlow solo entrega el último valor.
- Cierra la app del todo y ábrela otra vez (el partido vuelve a 0-0): en la lista de aplicaciones recientes del emulador, desliza la tarjeta de Marcador hacia arriba y toca su icono en la lista de aplicaciones; o, desde Android Studio, pulsa Stop y luego Run. Pon el interruptor en collectAsState (al moverlo sale una línea
llega 0-0 (collectAsState): empieza una recogida nueva, y unStateFlowentrega a quien empieza su valor actual). Después vacía Logcat (la papelera de la ventana Logcat, oadb logcat -c), para que las líneas anteriores no se mezclen con estas, y repite lo del punto 1: Empezar partido, Home, unos 15 segundos. Verás algo como:
D Recoleccion: llega 1-0 (collectAsState)
D Recoleccion: llega 1-1 (collectAsState)Esta vez las líneas llegan con la app parada: la recogida sigue viva. Los tiempos varían según la máquina; lo que no varía es que solo la segunda versión escribe con la pantalla oculta.
Provoca el reinicio al rotar
Con el partido en marcha y un gol ya marcado (el primero llega hacia el minuto 12, a los pocos segundos), gira el móvil como en Estado en Compose: con los botones de giro de la barra pegada a la ventana del emulador o, si no los encuentras, con las órdenes de adb que esa lección explica (y cómo devolver el giro a automático al terminar).
Lo que verás: el marcador vuelve a 0-0, el minuto a 0' y el botón vuelve a decir «Empezar partido». En Logcat, tras llega 1-0, aparece llega 0-0 (con ciclo de vida). La línea de las pausas, en cambio, sube en 1: al girar, la pantalla vieja deja de verse (ON_STOP) antes de destruirse, y pausas sobrevive porque está en rememberSaveable.
Por qué ocurre. Al girar, Android destruye la Activity y la composición se descarta. remember no conserva nada: nace otro MutableStateFlow, sin empezar, en 0-0; el reloj (un LaunchedEffect) muere con la composición. No es un fallo de collectAsStateWithLifecycle: el partido no debía vivir aquí.
Por qué rememberSaveable no lo arregla. Guarda en un Bundle, y un MutableStateFlow no cabe en él. El sitio para algo que debe sobrevivir a la rotación es el ViewModel, que es la siguiente etapa: ViewModel en Marcador: el partido. Deja el fallo como está.
Punto de control
- La app compila y se ejecuta sin errores en rojo (si prefieres comprobarlo en la Terminal de Android Studio,
./gradlew :app:assembleGratisDebugtermina enBUILD SUCCESSFUL). - «Empezar partido» arranca el reloj; el 1-0 llega hacia el minuto 12 y el botón pasa a «En juego».
- En Logcat con
tag:Recoleccion, con el interruptor encollectAsStateWithLifecycleno aparece ninguna línea mientras la app está en segundo plano, y concollectAsStatesí. - Al girar el móvil con el partido en marcha, todo vuelve a 0-0, minuto 0' y «Empezar partido» (es lo esperado).
- Los goles de los botones (
golesLocalAMano) se suman al marcador del partido en vivo: con un 1-0 del guion y un gol a mano del visitante, la pantalla dice 1-1.
Comprueba lo que entendiste
5 preguntas · necesitas 4 para completar la lección
1.Con la app en segundo plano (Home), ¿qué hace
collectAsStateWithLifecycle?2.Usas
collectAsStateen una pantalla de Android. ¿Cuál es su fallo principal?3.Recoges un
Flownormal (no unStateFlow) concollectAsStateWithLifecycle. ¿Qué necesitas dar además?4.El partido está guardado con
remember. Al girar el móvil vuelve a 0-0. ¿Por qué?5.¿Por qué cambiar
rememberporrememberSaveableno arregla el partido en vivo?