Efectos en Compose: LaunchedEffect
Al terminar: Entender qué es un efecto secundario y lanzar trabajo atado a un composable con LaunchedEffect y rememberUpdatedState.
Al terminar esta lección, Marcador tendrá un cronómetro de partido (un minuto cada 250 milisegundos) hecho con LaunchedEffect y un aviso cada 15 minutos que anota el marcador del momento, gracias a rememberUpdatedState.
Importa porque una función @Composable puede ejecutarse muchas veces y hasta empezar a ejecutarse y abandonarse a medias (más abajo, en «Por qué existen los efectos», se explica qué significa). Todo lo que no sea «dibujar» (arrancar una corrutina, tocar algo de fuera de Compose) necesita un sitio con reglas de entrada y de salida. Ese sitio son los efectos.
Compose te da seis herramientas de efectos: LaunchedEffect, rememberUpdatedState, rememberCoroutineScope, derivedStateOf, SideEffect y DisposableEffect. Cada una aparece en su lección. Hoy: qué es un efecto secundario, LaunchedEffect y rememberUpdatedState. Después, rememberCoroutineScope, derivedStateOf y SideEffect en Más efectos: scope, derivedStateOf y SideEffect, y DisposableEffect en Efectos y ciclo de vida: DisposableEffect.
Por qué existen los efectos
Un efecto secundario es algo que una función hace además de calcular su resultado: escribir en Logcat, cambiar una variable de fuera, lanzar una corrutina. Un composable debería limitarse a describir la pantalla; lo demás va en un efecto.
La composición (Composition), como viste en Estado en Compose, es el árbol de composables que Compose mantiene en memoria: la estructura de elementos anidados que describe la pantalla. Un composable entra en la composición cuando aparece por primera vez, se recompone cada vez que cambia lo que lee y sale cuando deja de emitirse (por ejemplo, al cambiar de pestaña).
Un efecto es trabajo atado a esas tres fases: empieza al entrar, puede reiniciarse cuando cambia algo y se detiene al salir.
Ejecutarse a medias. Compose puede empezar a ejecutar un composable y abandonar esa pasada antes de terminar, por ejemplo porque el estado cambió mientras tanto, y empezar otra desde cero. Solo la pasada que llega hasta el final es la que se usa para dibujar (en esta guía la llamamos pasada completada). Por eso el cuerpo de un composable puede correr varias veces, o correr y quedarse en nada, y lo que hagas dentro no debe tener consecuencias fuera de la pantalla: un aviso lanzado en una pasada abandonada sería un aviso que nunca debió salir.
Si metieras una corrutina suelta en el cuerpo del composable, arrancaría una nueva en cada recomposición. Con el reloj recomponiendo la pantalla cada minuto, tendrías decenas de relojes a la vez.
LaunchedEffect: una corrutina con las claves de Compose
LaunchedEffect(key) { … } recibe una o más claves (keys), parecidas a la clave de remember(torneo) de Recomposición y a las de las filas de una lista: «vuelve a empezar cuando esto cambie». La regla es una sola:
| Situación | Qué hace Compose |
|---|---|
| El composable entra en la composición | Lanza la corrutina |
| Se recompone y las claves no cambian | No hace nada: la corrutina sigue |
| Se recompone y una clave cambia | Cancela la corrutina y lanza otra desde cero |
| El composable sale de la composición | Cancela la corrutina |
Una clave que sobra reinicia el trabajo sin motivo. Una que falta deja el efecto con datos viejos. LaunchedEffect(Unit) (una clave que nunca cambia) significa «solo una vez».
@Composable
fun Reloj(corriendo: Boolean) {
LaunchedEffect(corriendo) { // clave: corriendo; si cambia, cancela y vuelve a lanzar
if (!corriendo) return@LaunchedEffect // parado: sale del bloque del efecto, no de Reloj
while (true) { // repite mientras la corrutina no se cancele
delay(250) // suspende; si la clave cambia, esto se cancela
Log.d("Reloj", "tic") // escribe «tic» en Logcat (la ventana de registros de Android Studio) cada cuarto de segundo
}
}
}return@LaunchedEffect es el mismo return@ con etiqueta que return@forEach de Lambdas y funciones de orden superior: sale solo del bloque del efecto, porque un return a secas intentaría salir de Reloj, y el compilador no lo permite dentro de esa lambda.
Al pasar corriendo de true a false, la clave cambia: Compose cancela la corrutina (el delay lanza CancellationException) y lanza una nueva, que sale enseguida por el return. Al volver a true, arranca otra.
Ese Log.d escribe en Logcat, la ventana de Android Studio donde la app apunta mensajes (la usaste en El ciclo de vida de la Activity). En un composable de Android se usa Log.d, no println.
Ahora el mismo mecanismo con algo que se ve en pantalla, en una app inventada: un temporizador de cocina con un botón.
@Composable
fun TemporizadorDeCocina() {
var segundos by remember { mutableIntStateOf(0) } // lo que se ve
var encendido by remember { mutableStateOf(false) } // la clave del efecto
LaunchedEffect(encendido) { // si cambia encendido: cancela y vuelve a lanzar
if (!encendido) return@LaunchedEffect // apagado: el efecto termina sin hacer nada
while (true) {
delay(1000) // espera un segundo; se cancela si cambia la clave
segundos++ // cambia el estado: la pantalla se redibuja
}
}
Column {
Text("Olla: $segundos s")
Button(onClick = { encendido = !encendido }) { Text(if (encendido) "Parar" else "Encender") }
}
}Lo que ves en pantalla: al abrir, Olla: 0 s y el botón Encender. Tras pulsarlo, Olla: 1 s, Olla: 2 s… y el botón dice Parar. Al pulsar Parar, el número se queda quieto.
Fíjate en la clave: con LaunchedEffect(Unit) en lugar de LaunchedEffect(encendido), pulsar Encender no haría nada. El efecto arrancaría una sola vez, al entrar, con encendido en false, saldría por el return@ y no se lanzaría de nuevo, porque Unit nunca cambia.
Qué ve una lambda: el valor de ahora o una copia
El bloque de un efecto es una lambda que vive mucho tiempo: arranca en una composición y sigue corriendo durante muchas más. Lo que lea de fuera depende de cómo lo lea. Hay dos casos:
- Una variable declarada con
by(var goles by remember { mutableIntStateOf(0) }) guarda su valor dentro de unState. Cada vez que el código escribegoles, Kotlin le pregunta alState: se lee el valor actual. - Un
valnormal es una copia del valor que tenía en el momento de crearse. Nada la actualiza después.
La diferencia se ve con Kotlin normal, sin Compose. Una pizarra es una «caja» cuyo contenido se puede cambiar; una copia del texto, en cambio, se queda como estaba:
class Pizarra(var texto: String) // una «caja»: su contenido se puede cambiar
fun main() {
val pizarra = Pizarra("pan: 1 €") // como una variable `by`: un objeto que se consulta
val copia = pizarra.texto // como un `val`: copia el texto de AHORA
val verCopia = { println("copia: $copia") } // esta lambda se queda con la copia
val verPizarra = { println("pizarra: ${pizarra.texto}") } // esta mira dentro de la caja cada vez
pizarra.texto = "pan: 2 €" // el precio cambia después
verCopia() // copia: pan: 1 €
verPizarra() // pizarra: pan: 2 €
}copia: pan: 1 €
pizarra: pan: 2 €Una variable by remember { … } es la pizarra: Compose guarda la caja y tú lees lo de dentro. Una lambda que nació en una composición conserva las copias de esa composición. Recuérdalo en el error típico de más abajo.
rememberUpdatedState: que el efecto vea el último valor
La «referencia» de Google es, en la práctica, una caja, como la pizarra de arriba: el efecto se queda con la caja, y lo que hay dentro se actualiza.
El reloj de Marcador es un composable RelojDelPartido(corriendo, minutoActual, msPorMinuto, alMinuto). No pone nada en pantalla: un composable puede no emitir nada y existir solo para alojar un efecto. Recibe una lambda alMinuto a la que llama cada minuto. Así la escribe su padre (la pantalla del marcador; el código completo viene en el paso de abajo):
RelojDelPartido(corriendo, minutoActual = minuto, msPorMinuto = 250) { nuevoMinuto -> // esta lambda se crea de nuevo en cada recomposición
ultimoAviso = "minuto $nuevoMinuto con ${estado.marcador}"
}El bloque { nuevoMinuto -> … } que sigue a los paréntesis es el último parámetro, alMinuto (Kotlin deja escribir la lambda final fuera de los paréntesis). Lee estado, el marcador, que es un val nuevo en cada composición; por eso el padre crea esta lambda de nuevo en cada recomposición: captura un estado distinto cada vez. Tienes dos malas opciones:
- Ponerla como clave: cada recomposición del padre cancelaría y reiniciaría el reloj.
- No ponerla y usarla tal cual: el efecto se queda con la lambda de la composición en que arrancó.
rememberUpdatedState(alMinuto) da una caja que siempre contiene la última lambda, sin ser clave.
Error típico: usar la lambda capturada
LaunchedEffect(corriendo) {
// ...
delay(msPorMinuto)
minuto++
alMinuto(minuto) // la lambda que había cuando arrancó el efecto
}val avisar by rememberUpdatedState(alMinuto)
LaunchedEffect(corriendo) {
// ...
delay(msPorMinuto)
minuto++
avisar(minuto) // apunta a la lambda más reciente
}Falla por lo que acabas de ver. La lambda alMinuto de la pantalla (la del ejemplo de arriba) escribe minuto y ultimoAviso, que son variables con by y siempre funcionan. Pero también lee estado, el marcador, que es un val creado en cada composición: una copia. La copia que capturó el efecto es la de cuando pulsaste «Empezar»: 0-0. Lo reproduces más abajo.
Reto · Ejercicio: el recordatorio de agua
Hoy LaunchedEffect y rememberUpdatedState se prueban de verdad, en la vista previa interactiva (la que presentó Estado en Compose: ahí sí corre delay).
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.recordatorio_de_agua. Luego clic derecho sobre esa carpeta → New → Kotlin Class/File → File → RecordatorioDeAgua.
Escribe dos composables en RecordatorioDeAgua.kt:
Recordatorio: no dibuja nada. Recibe si está activo y la función que avisa. Mientras esté activo, cada 2 segundos llama a esa función, siempre a la más reciente, sin reiniciarse cuando cambia. Si deja de estar activo, se para.PantallaDeAgua: un contador de vasos («Vasos: N»), un botón «Un vaso más», otro que alterna «Activar avisos» y «Parar avisos», y la línea «Último aviso: …», que empieza en «ninguno». Guarda en un valor normal, nuevo en cada pasada, el resumen «llevas N vasos». Pásale alRecordatoriouna función que copie ese resumen en el último aviso.
Añade una vista previa con PantallaDeAgua. En el modo interactivo:
- Pulsa «Activar avisos» y espera: a los 2 segundos debe decir «Último aviso: llevas 0 vasos».
- Pulsa «Un vaso más» dos veces y espera otra vez: «llevas 2 vasos», sin que el aviso se reinicie.
- Ahora rómpelo a propósito: dentro del efecto, cambia la llamada a la función de la caja por la llamada directa a la función recibida, y repite los pasos 1 y 2. El aviso se queda en «llevas 0 vasos» aunque subas los vasos: es la función de cuando arrancó el efecto, una copia. Devuélvelo a como estaba.
Pistas, solo con lo que ya viste hoy: la clave del efecto es si está activo (así se para y se reanuda); si no está activo, el bloque termina enseguida con return@LaunchedEffect; dentro, un bucle while (true) con delay(2000) y la llamada a la función, como el bucle del reloj de arriba; y el resumen es un val común y corriente declarado en el cuerpo de PantallaDeAgua (no un estado), como el val de «Qué ve una lambda»: por eso es una copia nueva en cada pasada.
Ver solución
package com.sebastiannarvaez.marcador.ejercicios.recordatorio_de_agua
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
// No dibuja nada: solo aloja el efecto. Mientras esté activo, avisa cada dos segundos.
@Composable
fun Recordatorio(activo: Boolean, alAvisar: () -> Unit) {
val avisar by rememberUpdatedState(alAvisar) // la caja con la última lambda
LaunchedEffect(activo) { // se reinicia solo si cambia activo
if (!activo) return@LaunchedEffect
while (true) {
delay(2000)
avisar() // siempre la lambda más reciente
}
}
}
@Composable
fun PantallaDeAgua(modifier: Modifier = Modifier) {
var vasos by remember { mutableIntStateOf(0) }
var activo by remember { mutableStateOf(false) }
var ultimoAviso by remember { mutableStateOf("ninguno") }
val resumen = "llevas $vasos vasos" // un val nuevo en cada pasada: una copia
Recordatorio(activo) { ultimoAviso = resumen }
Column(modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Vasos: $vasos")
Button(onClick = { vasos++ }) { Text("Un vaso más") }
Button(onClick = { activo = !activo }) { Text(if (activo) "Parar avisos" else "Activar avisos") }
Text("Último aviso: $ultimoAviso")
}
}
@Preview(showBackground = true)
@Composable
private fun PantallaDeAguaPreview() {
MaterialTheme { PantallaDeAgua() }
}Con la llamada directa, el efecto se queda con la función del momento en que arrancó (la de «llevas 0 vasos»). La caja de rememberUpdatedState es lo que le deja ver siempre la última, sin reiniciarse.
Paso · el cronómetro y el aviso cada 15 minutos
Esta lección no instala nada. LaunchedEffect y rememberUpdatedState ya vienen con las librerías de Compose que tienes.
Objetivo. Un cronómetro con «Empezar / Pausa / Reanudar / Reiniciar» que cuenta un minuto cada 250 ms y, cada 15 minutos, anota el marcador del momento.
Crea Efectos.kt
En la vista Android, clic derecho sobre ui (en app → kotlin+java → com.sebastiannarvaez.marcador → feature → marcador), la carpeta donde está PantallaMarcador.kt, → New → Kotlin Class/File → File → Efectos.
Efectos.kt tendrá hoy dos composables y una constante: RelojDelPartido (el de esta lección), PanelDelCronometro (solo dibuja el minuto y los botones, con piezas que ya conoces de Estado en Compose y State hoisting) y MINUTOS_DEL_PARTIDO. En las lecciones siguientes se escriben en este mismo fichero los demás efectos.
Dónde va cada trozo. En PantallaMarcador.kt, el import de mutableStateOf va con los demás de androidx.compose.runtime. El bloque de estado y efecto va justo después de la línea val estado = EstadoMarcador(…), antes de Scaffold(. La llamada a PanelDelCronometro(…) va dentro de extras = { … }, antes de ComparacionDeEstado(), que sigue igual.
Piezas nuevas de este paso:
LaunchedEffect(key) { … }yrememberUpdatedState(x): arriba.const val: una constante conocida al compilar, como las de object, companion y vararg.%: el resto de la división, como en Colecciones (nuevoMinuto % 15 == 0es «múltiplo de 15»).;: separa dos órdenes escritas en la misma línea.corriendo = false; minuto = 0equivale a ponerlas en dos líneas.
Intenta escribirlo tú, en este orden (Efectos.kt se abre solo con la línea package; los import los propone Android Studio con Option+Enter):
- Una constante con los 90 minutos del partido,
MINUTOS_DEL_PARTIDO. RelojDelPartido, un composable que no dibuja nada: mientras le digan que corre, desde el minuto en que se quedó, espera los milisegundos de cada minuto, suma uno y avisa del minuto nuevo, hasta llegar a 90. Al pausar se para, y avisa siempre con la última función que le pasaron.PanelDelCronometro, que solo dibuja lo que recibe: el minuto en grande («Minuto 12'»), un botón que dice «Empezar», «Pausa» o «Reanudar» según el caso, otro «Reiniciar», y una línea: «Último aviso (cada 15'): …».- En
PantallaMarcador, el estado que sobrevive a la rotación (minuto, si corre, y el último aviso, que empieza en «ninguno»), el reloj a 250 ms por minuto, que cada 15 minutos guarda el aviso «minuto 15 con 1-0» con el marcador del momento y se para al final, y el panel dentro de los extras.
Debes ver el minuto subir de 250 en 250 ms y el aviso actualizarse cada 15 minutos.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
Imports que Efectos.kt necesita hoy (Android Studio los propone solo; esta lista es para comprobar):
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delayconst val MINUTOS_DEL_PARTIDO = 90@Composable
fun RelojDelPartido(
corriendo: Boolean,
minutoActual: Int,
msPorMinuto: Long,
alMinuto: (Int) -> Unit,
) {
val avisar by rememberUpdatedState(alMinuto)
LaunchedEffect(corriendo) {
if (!corriendo) return@LaunchedEffect
var minuto = minutoActual
while (minuto < MINUTOS_DEL_PARTIDO) {
delay(msPorMinuto)
minuto++
avisar(minuto)
}
}
}minutoActual se lee solo al arrancar, a propósito: es el minuto desde el que se reanuda, y después el bucle lleva su propia cuenta en minuto. Por eso no es clave ni pasa por rememberUpdatedState.
El panel del cronómetro, debajo de RelojDelPartido (no guarda nada: recibe todo y avisa con lambdas, como MarcadorContent):
@Composable
fun PanelDelCronometro(
minuto: Int,
corriendo: Boolean,
ultimoAviso: String,
onAlternar: () -> Unit,
onReiniciar: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Minuto $minuto'", style = MaterialTheme.typography.headlineMedium)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
Button(onClick = onAlternar) { Text(if (corriendo) "Pausa" else if (minuto == 0) "Empezar" else "Reanudar") }
Button(onClick = onReiniciar) { Text("Reiniciar") }
}
Text("Último aviso (cada 15'): $ultimoAviso")
}
}import androidx.compose.runtime.mutableStateOf var minuto by rememberSaveable { mutableIntStateOf(0) }
var corriendo by rememberSaveable { mutableStateOf(false) }
var ultimoAviso by rememberSaveable { mutableStateOf("ninguno") }
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
} PanelDelCronometro(
minuto, corriendo, ultimoAviso,
onAlternar = { corriendo = !corriendo },
onReiniciar = { corriendo = false; minuto = 0; ultimoAviso = "ninguno" },
)MINUTOS_DEL_PARTIDO existe también en :torneo (otro módulo, con el mismo valor, 90). Aquí declaras la tuya en feature.marcador.ui porque es la que usa la pantalla: así PantallaMarcador, que vive en el mismo paquete, la encuentra sin ningún import, y esta parte de la app no depende de un detalle interno del módulo del torneo. Si al escribir el nombre Android Studio te propone importar la de torneo con Option+Enter, no la aceptes: los dos nombres conviven sin chocar y las lecciones siguientes usan la de ui.
Ejecútalo y provoca el efecto
Ejecuta la app con Run (selector app, variante gratisDebug activa en la ventana Build Variants, como en Gradle: flavors y variantes). Si no tienes el emulador arrancado, cómo hacerlo está en La Activity.
Pulsa Empezar: el minuto sube cada cuarto de segundo. Pausa cancela la corrutina; Reanudar lanza otra desde el minuto en que estabas.
Si cambias a «Partidos» y vuelves, el cronómetro y los goles estarán a 0: la pantalla del marcador salió de la composición, y lo guardado con remember o con rememberSaveable se pierde al salir, como viste en Pestañas con NavigationBar. No es un fallo de los efectos.
Provoca el error típico del marcador viejo
- En
RelojDelPartido(enEfectos.kt), cambiaavisar(minuto)poralMinuto(minuto). - Ejecuta, pulsa Empezar y enseguida Gol Rayo FC dos veces y Gol Toros una. El marcador marca 2-1.
- Espera al minuto 30 (unos ocho segundos). Verás algo como:
Último aviso (cada 15'): minuto 30 con 0-0El aviso sigue diciendo 0-0 con el marcador en 2-1. Vuelve a poner avisar(minuto), ejecuta de nuevo y repite los mismos pasos: al llegar al minuto 30, el aviso dice minuto 30 con 2-1.
Punto de control
- La app compila y se ejecuta con Run sin errores en rojo.
- «Empezar» hace subir el minuto; «Pausa» lo detiene y «Reanudar» sigue donde estaba.
- Con
avisar(minuto), el aviso de cada 15 minutos lleva el marcador del momento (por ejemplo, «minuto 30 con 2-1»).
Comprueba lo que entendiste
4 preguntas · necesitas 3 para completar la lección
1.¿Por qué una corrutina no se lanza suelta en el cuerpo de un composable?
2.Tienes
LaunchedEffect(corriendo) { … }con undelaydentro.corriendopasa detrueafalse. ¿Qué ocurre?3.Sin
rememberUpdatedState, tu cronómetro avisa «minuto 30 con 0-0» aunque el marcador va 2-1. ¿Por qué?4.Compose puede «abandonar a medias» la ejecución de un composable. ¿Qué consecuencia tiene para lo que escribes dentro?