Saltar al contenido
K
Jetpack ComposePaso a paso38 min de lectura · 8 min de máquina Intermedio

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.

collectAsStatecollectAsStateWithLifecycle
Recoge mientras…el composable está en la composiciónel ciclo de vida está al menos en STARTED (la pantalla se ve)
App en segundo planosigue recogiendose cancela; al volver, se reanuda con el último valor
Dónde funcionacualquier plataforma (código común)solo Android
Libreríacompose-runtimelifecycle-runtime-compose (la que instalaste en Efectos y ciclo de vida: DisposableEffect)
ejemplo mínimo: recoger un StateFlow y un Flow normal (con comentarios)
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

con el bug: sigue recogiendo en segundo plano
val marcador by marcadorEnVivo.collectAsState()
corregido: recoge solo mientras se ve
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.

ejemplo mínimo: recoger con el ciclo de vida (con comentarios)
// 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 StateFlow de 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 onEach y recordado con su remember (así se crean una sola vez). Cada valor que pase por ellos deja una línea en Logcat con la etiqueta Podometro: «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
app/src/main/java/com/sebastiannarvaez/marcador/ejercicios/podometro/Podometro.kt
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.

app
kotlin+java → com.sebastiannarvaez.marcador
feature
marcador
ui
Efectos.ktse modifica
PantallaMarcador.ktse modifica
Dos ficheros que ya tienes, tal como los ves en la vista Android; no hay nada nuevo que crear ni de Gradle.

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:

  1. Efectos.kt: selecciona PanelDelCronometro entera (desde su @Composable hasta 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ínea import androidx.compose.foundation.layout.Row, que deja de usarse. Android Studio también marcará en gris import androidx.compose.ui.Alignment: es un aviso (un nombre importado que ya nadie usa), no un error: bórralo.
  2. 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 de androidx).
  3. PantallaMarcador.kt, el estado. Dentro de PantallaMarcador, deja val partido = Ejemplo.rayoContraToros y sustituye todo lo que viene después, desde la línea var golesLocal hasta la línea TituloDeLaActividad(estado.marcador), ambas incluidas, por el bloque nuevo de abajo. Ese tramo incluye var pausas y 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:
antes: el tramo que se borra entero
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)
  1. PantallaMarcador.kt, MarcadorContent(…) y la constante. Sustituye la llamada a MarcadorContent(…) entera por la de abajo (cambian el onGol, el panel del cronómetro y una Row en extras), y añade una constante nueva a nivel de fichero, justo después de la función PantallaMarcador.

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 un minuto.
  • Marcador.despuesDe(evento, partido): función de :torneo que devuelve el marcador tras un evento (un gol suma; lo demás lo deja igual).
  • val x by if (c) { a } else { b }: by funciona con cualquier expresión que devuelva un State; aquí elige cuál recoger.
  • Button(enabled = …): con enabled = false el 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:

Efectos.kt: PanelDelCronometro con un solo botón
@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:

PantallaMarcador.kt: imports nuevos
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.update

El 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:

PantallaMarcador.kt: el estado nuevo, con un hueco
    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):

PantallaMarcador.kt: MarcadorContent, con un hueco para el interruptor
        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()
            },
        )
PantallaMarcador.kt: la constante, después de la función PantallaMarcador
private const val MS_POR_MINUTO = 250L

Lo 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:

  1. conCicloDeVida: un valor recordado que empieza en true y dice si se recoge teniendo en cuenta el ciclo de vida.
  2. flujo: marcadorEnVivo envuelto una sola vez (con remember) para que cada valor que pase deje una línea en Logcat, con el tag Recoleccion, con el marcador y la forma de recogerlo.
  3. marcadorDelPartido: ese flujo recogido con una función o con la otra, según conCicloDeVida.
  4. estado: el EstadoMarcador de 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:

app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/PantallaMarcador.kt
    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:

app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/PantallaMarcador.kt
                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):

app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/PantallaMarcador.kt
    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.
  • onEach y Log.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 onEach lee conCicloDeVida aunque se creó una sola vez. Está dentro de remember { … }, así que se crea la primera vez y no se rehace. Pero conCicloDeVida es una variable by: la lambda captura el State que hay detrás, no una copia de true, y cada vez que escribe en Logcat lo lee de nuevo. Por eso, al mover el interruptor, las líneas siguientes ya dicen collectAsState. Con un val activo = conCicloDeVida fuera de la lambda, se habría quedado con true para siempre.
  • El Switch y el if de by. Mover el interruptor cambia conCicloDeVida, Compose recompone PantallaMarcador y el if elige 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 Recoleccion

adb 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.

  1. 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.

  1. 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 un StateFlow entrega a quien empieza su valor actual). Después vacía Logcat (la papelera de la ventana Logcat, o adb 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:assembleGratisDebug termina en BUILD 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 en collectAsStateWithLifecycle no aparece ninguna línea mientras la app está en segundo plano, y con collectAsState sí.
  • 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. 1.Con la app en segundo plano (Home), ¿qué hace collectAsStateWithLifecycle?

  2. 2.Usas collectAsState en una pantalla de Android. ¿Cuál es su fallo principal?

  3. 3.Recoges un Flow normal (no un StateFlow) con collectAsStateWithLifecycle. ¿Qué necesitas dar además?

  4. 4.El partido está guardado con remember. Al girar el móvil vuelve a 0-0. ¿Por qué?

  5. 5.¿Por qué cambiar remember por rememberSaveable no arregla el partido en vivo?

Faltan 5 de 5