Lifecycle y repeatOnLifecycle
Al terminar: Recoger el partido en vivo solo mientras la pantalla se ve, con Lifecycle y repeatOnLifecycle.
Al terminar esta lección, la pantalla de Marcador tendrá un botón «Empezar partido» y una línea «Marcador en vivo» que se va actualizando sola, y que deja de pintarse cuando ocultas la app y se reanuda cuando vuelves. El partido sigue jugándose; lo que se para es pintar el marcador. Lo verás en Logcat: recogida PARADA al pulsar Home y recogida ARRANCADA al volver.
Importa porque una pantalla que sigue trabajando mientras nadie la ve gasta batería y datos para nada. Android te da una herramienta para que el trabajo siga el ritmo de la pantalla: repeatOnLifecycle. Temas: Lifecycle y sus estados; repeatOnLifecycle; recoger un StateFlow de forma segura; try/finally para ver una cancelación; apagar un botón con enabled.
Lifecycle: en qué estado está la pantalla
Lifecycle es el objeto que sabe en qué estado está una pantalla, y LifecycleOwner es quien tiene uno: la Activity lo es. Los estados posibles son INITIALIZED, CREATED, STARTED, RESUMED y DESTROYED. Traducido a los callbacks que viste en El ciclo de vida de la Activity: se llega a STARTED con onStart (la pantalla es visible), a RESUMED con onResume, y se baja de STARTED justo antes de que el sistema llame a onStop, es decir, al ocultarla.
En el código, el estado se escribe Lifecycle.State.STARTED: State es un enum (una lista cerrada de valores) y está declarado dentro de la clase Lifecycle, como una carpeta dentro de otra; por eso se escribe con el nombre de la clase delante.
repeatOnLifecycle: trabajar solo mientras la pantalla se ve
STOPPED no es uno de los estados de la lista de arriba: es la forma de hablar de Google para «la Activity ya no está visible», lo que ocurre a partir de onStop. En el código, lo que importa es esto: el bloque se cancela cuando la Activity baja de STARTED, y se vuelve a lanzar desde cero cuando sube otra vez.
| Herramienta | Qué hace |
|---|---|
lifecycleScope.launch { … } | Corrutina hija de la Activity; se cancela en onDestroy |
repeatOnLifecycle(STARTED) { … } | Ejecuta el bloque mientras la pantalla es visible; lo cancela al ocultarse y lo relanza al volver |
lifecycleScope.launch { // corrutina atada a la Activity; muere en onDestroy
repeatOnLifecycle(Lifecycle.State.STARTED) { // arranca el bloque al verse y lo cancela al ocultarse
marcador.collect { texto = it.toString() } // cada valor nuevo del flujo, a la pantalla
}
}Un ejemplo completo, ajeno a Marcador
Una pantalla con un reloj que cuenta segundos y un botón que solo se puede pulsar una vez. No es el código de Marcador; sirve para ver cada pieza trabajando antes de usarla en el proyecto.
class RelojActivity : ComponentActivity() {
private var hora by mutableIntStateOf(0) // como en la rotación: un valor observable para un Int
private var pedido by mutableStateOf(false) // mutableStateOf: lo mismo para cualquier otro tipo; aquí, un Boolean
// Un flujo que emite 0, 1, 2… uno por segundo, sin fin
private val reloj = flow {
var segundo = 0
while (true) {
emit(segundo++) // emit envía el valor de ahora (0 la primera vez) y DESPUÉS suma uno
delay(1000) // espera un segundo sin bloquear el hilo
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
Log.d("Prueba", "recogida empezada") // se escribe cada vez que el bloque ARRANCA
try {
reloj.collect { hora = it } // se queda aquí mientras la pantalla se ve
} finally {
Log.d("Prueba", "recogida cancelada") // finally se ejecuta también cuando se cancela
}
}
}
setContent {
Column {
Text("Segundos: $hora") // se repinta cada vez que `hora` cambia
Button(
onClick = { pedido = true }, // al pulsar, pedido pasa a true
enabled = !pedido, // con enabled = false se ve gris y no responde
) {
Text("Pedir una vez")
}
}
}
}
}Lo que se ve en pantalla: un texto «Segundos: 0» que sube cada segundo (Segundos: 1, Segundos: 2…) y un botón «Pedir una vez». Al pulsar el botón se pone gris y ya no responde. Si pulsas Home, el texto se queda quieto; cuando vuelves a la app, reanuda la cuenta desde cero, porque el flujo se lanza de nuevo.
Y esto es lo que sale en Logcat (con el filtro tag:Prueba) al abrir la app, pulsar Home y volver:
D Prueba : recogida empezada
D Prueba : recogida cancelada
D Prueba : recogida empezadaSi quitaras repeatOnLifecycle(Lifecycle.State.STARTED) { … } y dejaras dentro del launch el try/finally, los Log.d y el reloj.collect { … }, al pulsar Home no saldría ninguna línea nueva: ese collect solo se cancelaría en onDestroy, así que el reloj seguiría contando con la pantalla oculta, gastando batería para pintar algo que nadie ve. Justo lo que repeatOnLifecycle evita.
Ejercicio · una cuenta atrás que se para al ocultar la pantalla
Los ejercicios de este módulo viven en la carpeta ejercicios que creaste en Rotación y muerte del proceso. Crea una subcarpeta nueva dentro de ejercicios: clic derecho sobre com.sebastiannarvaez.marcador → New → Package → escribe .ejercicios.cuenta_atras al final. Al tener ya dos subcarpetas, la vista Android muestra ejercicios con una hija por ejercicio. Después, clic derecho sobre cuenta_atras → New → Kotlin Class/File → File → CuentaAtras. Se ejecuta igual que aquel: una línea temporal en MainActivity.kt con la ruta completa y Logcat con tag:Ejercicio.
Reto · Ejercicio: la cuenta atrás del descanso
Escribe una cuenta atrás del descanso del partido: un flujo que emite 10, 9, 8… hasta 0, uno por segundo. Después, una función llamada empezarCuentaAtras que recibe la pantalla (un parámetro de tipo ComponentActivity) y recoge ese flujo solo mientras la pantalla se ve. Al arrancar la recogida escribe «descanso: empieza»; por cada valor, «quedan N s»; y cuando la recogida acaba o se cancela, «descanso: parado». Todo en Logcat, con la etiqueta Ejercicio.
Añade, en MainActivity.kt, justo debajo de anotar("onCreate …"), una línea temporal que llame a tu función pasándole this (la propia Activity), con la ruta completa y sin import, y con el comentario // EJERCICIO: quitar al terminar. Una función suelta (fuera de un object) tiene como ruta el paquete, un punto y su nombre: paquete.empezarCuentaAtras(this). Ejecuta con Run y pulsa Home hacia el 8 (si te pilla otro número, es normal). Espera unos segundos y vuelve a la app. Debes ver:
D Ejercicio: descanso: empieza
D Ejercicio: quedan 10 s
D Ejercicio: quedan 9 s
D Ejercicio: quedan 8 s
D Ejercicio: descanso: parado
D Ejercicio: descanso: empieza
D Ejercicio: quedan 10 sLa cuenta no sigue con la pantalla oculta, y al volver empieza de nuevo desde 10. Si la dejas llegar a 0, sale «descanso: parado» un segundo después.
Pistas, todo ya visto: flow { … } con emit y delay (Flow frío); for (s in 10 downTo 0) (Bucles y tests); lifecycleScope.launch, repeatOnLifecycle(Lifecycle.State.STARTED) y try/finally (arriba). Fuera de la Activity se escriben con la pantalla delante: pantalla.lifecycleScope y pantalla.repeatOnLifecycle.
Ver solución
package com.sebastiannarvaez.marcador.ejercicios.cuenta_atras
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch
// La cuenta atrás del descanso: emite 10, 9, 8… 0, uno por segundo.
private val descanso = flow {
for (segundo in 10 downTo 0) {
emit(segundo)
delay(1000)
}
}
// Recoge la cuenta atrás solo mientras la pantalla se ve.
fun empezarCuentaAtras(pantalla: ComponentActivity) {
pantalla.lifecycleScope.launch { // atada a la pantalla: muere en onDestroy
pantalla.repeatOnLifecycle(Lifecycle.State.STARTED) { // arranca al verse, se cancela al ocultarse
Log.d("Ejercicio", "descanso: empieza")
try {
descanso.collect { Log.d("Ejercicio", "quedan $it s") }
} finally {
Log.d("Ejercicio", "descanso: parado") // también cuando se cancela
}
}
}
}La línea temporal de MainActivity.kt, justo debajo de anotar("onCreate …"):
com.sebastiannarvaez.marcador.ejercicios.cuenta_atras.empezarCuentaAtras(this) // EJERCICIO: quitar al terminarAntes del paso, borra la línea temporal (la que acaba en // EJERCICIO: quitar al terminar). En MainActivity.kt, Cmd+F (Ctrl+F) con la palabra ejercicios no debe dar ningún resultado; si Android Studio añadió un import, bórralo también. El fichero del ejercicio se queda en su carpeta.
Paso · El partido en vivo, recogido solo con la pantalla a la vista
Objetivo. Añadir a MainActivity un botón «Empezar partido» y una línea con el marcador en vivo, recogida con repeatOnLifecycle. Al terminar, el marcador cambia solo y, con Home, la recogida se para y al volver se reanuda.
Abre MainActivity.kt con doble clic. Dónde va cada trozo (obligatorio; el código está debajo):
- Los imports nuevos van con los demás (
LifecycleyrepeatOnLifecyclejunto alifecycleScope;PartidoEnVivojunto a los detorneo). - Las tres propiedades nuevas, justo debajo de
resultadoDelCalculoy antes decalcularBloqueando. - El bloque
lifecycleScope.launch { repeatOnLifecycle … }, enonCreate, justo después deanotar("onCreate …"). - Los dos elementos nuevos (
ButtonyText), dentro de laColumn, trasText(resultadoDelCalculo). El resto no cambia.
Piezas nuevas de este paso:
lifecycleScope,repeatOnLifecycle(Lifecycle.State.STARTED): ver arriba. Eltry { … } finally { … }dentro escribe «PARADA» cuando la recogida se cancela.partidoEnVivo.iniciar(lifecycleScope): arranca el partido (lo escribiste en StateFlow y SharedFlow) en el alcance de la Activity. Ese partido sigue jugándose aunque ocultes la app, hasta que la Activity se destruye;repeatOnLifecycleno lo pausa, solo deja de recoger su marcador para pintarlo.enabled = !partidoEmpezado: el botón está activo mientras el partido no haya empezado.mutableStateOf("…"): ya lo usaste en la lección anterior; aquí guarda unString(el marcador) y unBoolean(si el partido ha empezado). Para unIntseguirías usandomutableIntStateOf, como en el reloj de arriba.
Si pulsas dos veces «Empezar partido», PartidoEnVivo.iniciar lanza IllegalStateException («El partido ya empezó»): por eso el botón se deshabilita tras el primer toque. Y si giras el móvil con el partido en marcha, la Activity se recrea y el partido empieza de cero (marcador 0-0 y el botón activo otra vez), porque vive en una propiedad de la Activity antigua: es la misma pérdida de estado de Rotación y muerte del proceso, y aquí no se arregla; se resuelve de verdad con el ViewModel, en ViewModel: qué sobrevive.
Intenta escribirlo tú, en este orden:
- Tres propiedades: el texto del marcador en vivo (empieza en «0-0»), si el partido ha empezado (empieza en falso) y el propio partido en vivo, con el partido de ejemplo y 60 milisegundos por minuto.
- En
onCreate, la recogida del marcador del partido en vivo, que solo corre mientras la pantalla se ve y anota en Logcat «recogida ARRANCADA» al empezar y «recogida PARADA» al pararse. - En la pantalla, tras el texto del resultado del cálculo: un botón «Empezar partido» que arranca el partido en vivo y se queda apagado tras el primer toque, y el texto «Marcador en vivo: 0-0», que se va actualizando.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
Los imports nuevos de MainActivity.kt (tres bloques, porque entre ellos hay imports que ya tenías):
import androidx.lifecycle.Lifecycleimport androidx.lifecycle.repeatOnLifecycleimport com.sebastiannarvaez.marcador.torneo.PartidoEnVivoLas tres propiedades, entre resultadoDelCalculo y calcularBloqueando:
private var marcadorEnVivo by mutableStateOf("0-0")
// El partido solo se puede iniciar una vez (`iniciar` lanza IllegalStateException: «El
// partido ya empezó»): el boton se deshabilita tras el primer toque.
private var partidoEmpezado by mutableStateOf(false)
private val partidoEnVivo = PartidoEnVivo(Ejemplo.rayoContraToros, msPorMinuto = 60)La recogida en onCreate, y los elementos nuevos de la pantalla:
// repeatOnLifecycle(STARTED): el bloque se ARRANCA cada vez que la Activity
// llega a STARTED (visible) y se CANCELA cuando baja de STARTED (onStop).
// Asi no se sigue pintando algo que nadie ve. Prueba: pulsa Home y mira
// Logcat: «recogida PARADA». Al volver: «recogida ARRANCADA».
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
anotar("recogida ARRANCADA")
try {
partidoEnVivo.marcador.collect { marcadorEnVivo = it.toString() }
} finally {
anotar("recogida PARADA")
}
}
} Text(resultadoDelCalculo)
Button(
onClick = {
partidoEmpezado = true
partidoEnVivo.iniciar(lifecycleScope)
},
enabled = !partidoEmpezado,
) {
Text("Empezar partido")
}
Text("Marcador en vivo: $marcadorEnVivo")repeatOnLifecycle en acción
- Con el emulador arrancado y el selector de configuración en
app, pulsa Run. Toca Empezar partido: el marcador en vivo cambia solo (1-0,2-1…) y el botón se apaga. Si quieres ver las dos lecciones a la vez, toca antes Calcular (en segundo plano): el marcador sigue cambiando mientras el cálculo corre, porque el hilo principal está libre. - Vacía Logcat (
adb logcat -c), pulsa Home y vuelve. Conadb logcat -d -s Ciclo(o el filtrotag:Ciclode la ventana Logcat) verás la recogida parar y reanudarse, con líneas como estas:
D Ciclo : MainActivity@c764bd7 onPause
D Ciclo : MainActivity@c764bd7 recogida PARADA
D Ciclo : MainActivity@c764bd7 onStop
D Ciclo : MainActivity@c764bd7 onSaveInstanceState
D Ciclo : MainActivity@c764bd7 onRestart
D Ciclo : MainActivity@c764bd7 onStart
D Ciclo : MainActivity@c764bd7 recogida ARRANCADA
D Ciclo : MainActivity@c764bd7 onResumeLa recogida se cancela cuando la Activity baja de STARTED, justo antes de onStop (por eso recogida PARADA sale entre onPause y onStop), y se relanza tras onStart, que es cuando se vuelve a STARTED. Así no se sigue pintando algo que nadie ve. Si esperas unos segundos con la app oculta y vuelves, el marcador habrá saltado de golpe a un resultado más avanzado: el partido no se paró, solo dejaste de mirarlo.
Punto de control
- Run reinstala la app sin errores y la pantalla tiene, tras el texto del resultado, el botón «Empezar partido» y la línea «Marcador en vivo: 0-0».
- Al tocar «Empezar partido», el botón se apaga y el marcador en vivo cambia solo.
- Tras Home y volver, Logcat con
tag:Ciclomuestrarecogida PARADA(entreonPauseyonStop) yrecogida ARRANCADA(trasonStart). MainActivity.ktno contiene la palabraejercicios.- Los dos botones de cálculo de la lección anterior siguen funcionando: el de segundo plano acaba mostrando
victoria local: 60.0%(60,0%en español).
Comprueba lo que entendiste
6 preguntas · necesitas 5 para completar la lección
1.Recoges un
StateFlowdentro derepeatOnLifecycle(Lifecycle.State.STARTED). Pulsas Home. ¿Qué ocurre con la recogida?2.En Logcat,
recogida PARADAsale entreonPauseyonStop. ¿Por qué ahí?3.¿Para qué sirve
enabled = !partidoEmpezadoen el botón «Empezar partido»?4.Dentro de
repeatOnLifecycleescribestry { flujo.collect { … } } finally { Log.d("Prueba", "cancelada") }. ¿Cuándo se escribecancelada?5.¿Qué diferencia hay entre
lifecycleScope.launch { flujo.collect { … } }y ponerlerepeatOnLifecycle(STARTED)por dentro?6.Con el partido en marcha pulsas Home, esperas unos segundos y vuelves. ¿Qué marcador ves?