Saltar al contenido
K
Android: la plataformaPaso a paso35 min de lectura · 5 min de máquina Intermedio

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.

HerramientaQué 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
ejemplo mínimo: recoger un flujo solo mientras la pantalla se ve (con comentarios)
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.

ejemplo mínimo: un reloj que se para al ocultar la pantalla (con comentarios)
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 empezada

Si 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 s

La 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
app/src/main/java/com/sebastiannarvaez/marcador/ejercicios/cuenta_atras/CuentaAtras.kt
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 …"):

MainActivity.kt: la línea temporal del ejercicio
com.sebastiannarvaez.marcador.ejercicios.cuenta_atras.empezarCuentaAtras(this) // EJERCICIO: quitar al terminar

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

app → kotlin+java → com.sebastiannarvaez.marcador
MainActivity.ktse modifica
Un solo fichero cambia en este paso, tal como lo ves en la vista Android.

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 (Lifecycle y repeatOnLifecycle junto a lifecycleScope; PartidoEnVivo junto a los de torneo).
  • Las tres propiedades nuevas, justo debajo de resultadoDelCalculo y antes de calcularBloqueando.
  • El bloque lifecycleScope.launch { repeatOnLifecycle … }, en onCreate, justo después de anotar("onCreate …").
  • Los dos elementos nuevos (Button y Text), dentro de la Column, tras Text(resultadoDelCalculo). El resto no cambia.

Piezas nuevas de este paso:

  • lifecycleScope, repeatOnLifecycle(Lifecycle.State.STARTED): ver arriba. El try { … } 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; repeatOnLifecycle no 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 un String (el marcador) y un Boolean (si el partido ha empezado). Para un Int seguirías usando mutableIntStateOf, 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:

  1. 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.
  2. 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.
  3. 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):

app/src/main/java/com/sebastiannarvaez/marcador/MainActivity.kt
import androidx.lifecycle.Lifecycle
app/src/main/java/com/sebastiannarvaez/marcador/MainActivity.kt
import androidx.lifecycle.repeatOnLifecycle
app/src/main/java/com/sebastiannarvaez/marcador/MainActivity.kt
import com.sebastiannarvaez.marcador.torneo.PartidoEnVivo

Las tres propiedades, entre resultadoDelCalculo y calcularBloqueando:

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

app/src/main/java/com/sebastiannarvaez/marcador/MainActivity.kt
        // 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")
                }
            }
        }
app/src/main/java/com/sebastiannarvaez/marcador/MainActivity.kt
                    Text(resultadoDelCalculo)
                    Button(
                        onClick = {
                            partidoEmpezado = true
                            partidoEnVivo.iniciar(lifecycleScope)
                        },
                        enabled = !partidoEmpezado,
                    ) {
                        Text("Empezar partido")
                    }
                    Text("Marcador en vivo: $marcadorEnVivo")

repeatOnLifecycle en acción

  1. 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.
  2. Vacía Logcat (adb logcat -c), pulsa Home y vuelve. Con adb logcat -d -s Ciclo (o el filtro tag:Ciclo de 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 onResume

La 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:Ciclo muestra recogida PARADA (entre onPause y onStop) y recogida ARRANCADA (tras onStart).
  • MainActivity.kt no contiene la palabra ejercicios.
  • 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. 1.Recoges un StateFlow dentro de repeatOnLifecycle(Lifecycle.State.STARTED). Pulsas Home. ¿Qué ocurre con la recogida?

  2. 2.En Logcat, recogida PARADA sale entre onPause y onStop. ¿Por qué ahí?

  3. 3.¿Para qué sirve enabled = !partidoEmpezado en el botón «Empezar partido»?

  4. 4.Dentro de repeatOnLifecycle escribes try { flujo.collect { … } } finally { Log.d("Prueba", "cancelada") }. ¿Cuándo se escribe cancelada?

  5. 5.¿Qué diferencia hay entre lifecycleScope.launch { flujo.collect { … } } y ponerle repeatOnLifecycle(STARTED) por dentro?

  6. 6.Con el partido en marcha pulsas Home, esperas unos segundos y vuelves. ¿Qué marcador ves?

Faltan 6 de 6