Un efecto secundario de Compose es un cambio en el estado de la app que ocurre fuera del alcance de una función de componibilidad. Debido al ciclo de vida de los elementos de componibilidad y sus propiedades, como las recomposiciones impredecibles, la ejecución de recomposiciones de elementos de componibilidad en diferentes órdenes o que se pueden descartar, lo ideal es que no tengan efectos secundarios.
Sin embargo, a veces se necesitan efectos secundarios, por ejemplo, para activar un evento único, como mostrar una barra de notificaciones o navegar a otra pantalla dada una condición de estado determinada. Estas acciones deben llamarse desde un entorno controlado que tenga en cuenta el ciclo de vida del elemento de componibilidad. En esta página, aprenderás sobre las diferentes APIs de efectos secundarios que brinda Jetpack Compose.
Casos de uso de los efectos y estados
Como se explica en la documentación Cómo pensar en Compose, los elementos componibles deben tener efectos secundarios. Cuando necesites realizar cambios en el estado de la app (como se describe en el documento Cómo administrar la documentación de estados), deberás usar las API de Effects para que esos efectos secundarios se ejecuten de manera predecible.
Debido a las diferentes posibilidades que ofrecen los efectos en Compose, pueden ser fácilmente sobrecargados. Asegúrate de que el trabajo que realices en ellos esté relacionado con la IU y no divida el flujo de datos unidireccional, como se explica en la documentación de administración de estados.
LaunchedEffect
: Ejecuta funciones de suspensión en el alcance de un elemento componible.
Para realizar tareas durante el ciclo de vida de un elemento componible y poder llamar a funciones de suspensión, usa el elemento LaunchedEffect
componible. Cuando LaunchedEffect
ingresa a Composition, inicia una corrutina con el bloque de código pasado como un parámetro. La corrutina se cancelará si LaunchedEffect
sale de la composición. Si se recompone LaunchedEffect
con diferentes claves (consulta a continuación la sección Cómo reiniciar efectos), la corrutina existente se cancelará y se iniciará la nueva función de suspensión en una corrutina nueva.
Por ejemplo, esta es una animación que hace que el valor alfa parpadee con una demora configurable:
// Allow the pulse rate to be configured, so it can be sped up if the user is running // out of time var pulseRateMs by remember { mutableStateOf(3000L) } val alpha = remember { Animatable(1f) } LaunchedEffect(pulseRateMs) { // Restart the effect when the pulse rate changes while (isActive) { delay(pulseRateMs) // Pulse the alpha every pulseRateMs to alert the user alpha.animateTo(0f) alpha.animateTo(1f) } }
En el código anterior, la animación usa la función de suspensión
delay
para esperar la cantidad de tiempo establecida. Luego, anima de forma secuencial el alfa a cero y viceversa con animateTo
.
Esto se repetirá durante toda la vida del elemento componible.
rememberCoroutineScope
: Obtén un alcance compatible con la composición para iniciar una corrutina fuera de un elemento componible
Dado que LaunchedEffect
es una función que admite composición, solo se puede usar dentro de otras funciones de ese tipo. Para iniciar una corrutina fuera de un elemento componible, pero con un alcance definido que se cancelará automáticamente una vez que salga de la composición, usa rememberCoroutineScope
.
Además, puedes usar rememberCoroutineScope
cada vez que necesites controlar el ciclo de vida de una o más corrutinas de forma manual; por ejemplo, para cancelar una animación cuando se produce un evento de usuario.
rememberCoroutineScope
es una función que admite composición que muestra un CoroutineScope
vinculado al punto de Composition al que se llama. El alcance se cancelará cuando la llamada salga de Composition.
Siguiendo el ejemplo anterior, puedes usar este código para mostrar una Snackbar
cuando el usuario presiona un Button
:
@Composable fun MoviesScreen(snackbarHostState: SnackbarHostState) { // Creates a CoroutineScope bound to the MoviesScreen's lifecycle val scope = rememberCoroutineScope() Scaffold( snackbarHost = { SnackbarHost(hostState = snackbarHostState) } ) { contentPadding -> Column(Modifier.padding(contentPadding)) { Button( onClick = { // Create a new coroutine in the event handler to show a snackbar scope.launch { snackbarHostState.showSnackbar("Something happened!") } } ) { Text("Press me") } } } }
rememberUpdatedState
: Haz referencia a un valor en un efecto que no se debe reiniciar si cambia el valor
LaunchedEffect
se reinicia cuando cambia uno de los parámetros clave. Sin embargo, en algunas situaciones, puede que quieras capturar un valor en tu efecto que, si cambia, no quieres que se reinicie. Para ello, debes usar rememberUpdatedState
a fin de crear una referencia a ese valor que se puede capturar y actualizar. Este enfoque es útil para efectos que contienen operaciones de larga duración que pueden ser costosas o imposibles de recrear y reiniciar.
Por ejemplo, supongamos que tu app tiene una LandingScreen
que desaparece después de un tiempo. Incluso si se vuelve a componer LandingScreen
, el efecto que espera unos segundos y notifica que ya transcurrió el tiempo no debería reiniciarse:
@Composable fun LandingScreen(onTimeout: () -> Unit) { // This will always refer to the latest onTimeout function that // LandingScreen was recomposed with val currentOnTimeout by rememberUpdatedState(onTimeout) // Create an effect that matches the lifecycle of LandingScreen. // If LandingScreen recomposes, the delay shouldn't start again. LaunchedEffect(true) { delay(SplashWaitTimeMillis) currentOnTimeout() } /* Landing screen content */ }
Para crear un efecto que coincida con el ciclo de vida del sitio de llamada, se pasa como parámetro una constante inmutable, como Unit
o true
. En el código anterior, se usa LaunchedEffect(true)
. Para asegurarte de que la lambda onTimeout
siempre contenga el valor más reciente con el que se volvió a componer LandingScreen
, onTimeout
debe unirse a la función rememberUpdatedState
.
El objeto State
que se muestra, currentOnTimeout
en el código, se debe usar en el efecto.
DisposableEffect
: Efectos que requieren limpieza
Para los efectos secundarios que se deben limpiar después de que cambian las claves o si el elemento componible deja Composition, usa DisposableEffect
.
Si cambian las claves DisposableEffect
, el elemento componible debe descartar (eliminar) su efecto actual y restablecerlo llamando otra vez al efecto.
Por ejemplo, puede que quieras enviar eventos de estadísticas en función de eventos Lifecycle
mediante un LifecycleObserver
.
Para escuchar esos eventos en Compose, utiliza un DisposableEffect
a fin de registrar y dejar de registrar el observador cuando sea necesario.
@Composable fun HomeScreen( lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current, onStart: () -> Unit, // Send the 'started' analytics event onStop: () -> Unit // Send the 'stopped' analytics event ) { // Safely update the current lambdas when a new one is provided val currentOnStart by rememberUpdatedState(onStart) val currentOnStop by rememberUpdatedState(onStop) // If `lifecycleOwner` changes, dispose and reset the effect DisposableEffect(lifecycleOwner) { // Create an observer that triggers our remembered callbacks // for sending analytics events val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_START) { currentOnStart() } else if (event == Lifecycle.Event.ON_STOP) { currentOnStop() } } // Add the observer to the lifecycle lifecycleOwner.lifecycle.addObserver(observer) // When the effect leaves the Composition, remove the observer onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } } /* Home screen content */ }
En el código anterior, el efecto agregará el observer
al lifecycleOwner
. Si cambia lifecycleOwner
, el efecto se descarta y se reinicia con el nuevo lifecycleOwner
.
Un DisposableEffect
debe incluir una cláusula onDispose
como sentencia final en su bloque de código. De lo contrario, el IDE mostrará un error de tiempo de compilación.
SideEffect
: Publica el estado de Compose en código no componible
Para compartir el estado de Compose con objetos que no administra la composición, usa el elemento componible SideEffect
. El uso de un SideEffect
garantiza que el efecto se ejecute después de cada recomposición correcta. Por otro lado, no es correcto realizar un efecto antes de que se garantice una recomposición correcta, que es el caso cuando se escribe el efecto directamente en un elemento componible.
Como ejemplo, tu biblioteca de estadísticas podría permitirte segmentar tu población de usuarios adjuntando metadatos personalizados (en este ejemplo, "propiedades del usuario") a todos los eventos de estadísticas posteriores. Para comunicar el tipo de usuario actual a la biblioteca de estadísticas, usa SideEffect
a fin de actualizar su valor.
@Composable fun rememberFirebaseAnalytics(user: User): FirebaseAnalytics { val analytics: FirebaseAnalytics = remember { FirebaseAnalytics() } // On every successful composition, update FirebaseAnalytics with // the userType from the current User, ensuring that future analytics // events have this metadata attached SideEffect { analytics.setUserProperty("userType", user.userType) } return analytics }
produceState
: Convierte un estado que no sea de Compose en uno que sí lo sea
produceState
lanza una corrutina orientada al objeto Composition que puede enviar valores a un State
que se muestra. Úsalo para convertir el estado que no sea de Compose en uno que sí lo sea, por ejemplo, llevando el estado externo controlado por suscripciones, como Flow
, LiveData
o RxJava
, a Composition.
El productor se lanza cuando produceState
ingresa a Composition y se cancela cuando sale de allí. El State
que se muestra se combina; si estableces el mismo valor, no se activará una recomposición.
Por más que produceState
cree una corrutina, también se puede usar para observar fuentes de datos que no están suspendidas. Para quitar la suscripción a esa fuente, usa la función awaitDispose
.
En el siguiente ejemplo, se muestra cómo usar produceState
para cargar una imagen desde la red. La función que admite composición loadNetworkImage
muestra un State
que se puede usar en otros elementos componibles.
@Composable fun loadNetworkImage( url: String, imageRepository: ImageRepository = ImageRepository() ): State<Result<Image>> { // Creates a State<T> with Result.Loading as initial value // If either `url` or `imageRepository` changes, the running producer // will cancel and will be re-launched with the new inputs. return produceState<Result<Image>>(initialValue = Result.Loading, url, imageRepository) { // In a coroutine, can make suspend calls val image = imageRepository.load(url) // Update State with either an Error or Success result. // This will trigger a recomposition where this State is read value = if (image == null) { Result.Error } else { Result.Success(image) } } }
derivedStateOf
: Convierte uno o varios objetos de estado en otro estado.
En Compose, la recomposición ocurre cada vez que cambia un objeto de estado observado o una entrada componible. Es posible que un objeto de estado o una entrada cambien con mayor frecuencia de la que la IU realmente necesita actualizar, lo que genera una recomposición innecesaria.
Debes usar la función derivedStateOf
cuando las entradas de un elemento componible cambian con más frecuencia de la que necesitas para volver a componerlas. Esto suele ocurrir cuando algo cambia con frecuencia, como una posición de desplazamiento, pero el elemento componible solo debe reaccionar a él una vez que cruce un umbral determinado. derivedStateOf
crea un nuevo objeto de estado de Compose que puedes observar y que solo se actualiza tanto como lo necesites. De esta manera, actúa de manera similar al operador distinctUntilChanged()
de flujos de Kotlin.
Uso correcto
En el siguiente fragmento, se muestra un caso de uso adecuado para derivedStateOf
:
@Composable // When the messages parameter changes, the MessageList // composable recomposes. derivedStateOf does not // affect this recomposition. fun MessageList(messages: List<Message>) { Box { val listState = rememberLazyListState() LazyColumn(state = listState) { // ... } // Show the button if the first visible item is past // the first item. We use a remembered derived state to // minimize unnecessary compositions val showButton by remember { derivedStateOf { listState.firstVisibleItemIndex > 0 } } AnimatedVisibility(visible = showButton) { ScrollToTopButton() } } }
En este fragmento, firstVisibleItemIndex
cambia cada vez que cambia el primer elemento visible. A medida que te desplazas, el valor se convierte en 0
, 1
, 2
, 3
, 4
, 5
, etc.
Sin embargo, la recomposición solo debe ocurrir si el valor es mayor que 0
.
Esta discrepancia en la frecuencia de actualización significa que este es un buen caso de uso para derivedStateOf
.
Uso incorrecto
Un error común es suponer que, cuando combinas dos objetos de estado de Compose, debes usar derivedStateOf
porque estás "derivando el estado". Sin embargo, esto es solo una sobrecarga y no es obligatorio, como se muestra en el siguiente fragmento:
// DO NOT USE. Incorrect usage of derivedStateOf. var firstName by remember { mutableStateOf("") } var lastName by remember { mutableStateOf("") } val fullNameBad by remember { derivedStateOf { "$firstName $lastName" } } // This is bad!!! val fullNameCorrect = "$firstName $lastName" // This is correct
En este fragmento, fullName
debe actualizarse con la misma frecuencia que firstName
y lastName
. Por lo tanto, no se produce una recomposición excesiva y no es necesario usar derivedStateOf
.
snapshotFlow
: Convierte el estado de Compose en flujos
Usa snapshotFlow
para convertir objetos State<T>
en un flujo frío. snapshotFlow
ejecuta su bloque cuando se recopila y emite el resultado de los objetos State
que se leen en él. Cuando cambia uno de los objetos State
leídos dentro del bloque snapshotFlow
, el flujo emitirá el nuevo valor a su colector si este no es igual al valor emitido con anterioridad (este comportamiento es similar al de Flow.distinctUntilChanged
).
En el siguiente ejemplo, se muestra un efecto secundario que registra cuándo el usuario se desplaza más allá del primer elemento de una lista de estadísticas:
val listState = rememberLazyListState()
LazyColumn(state = listState) {
// ...
}
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex }
.map { index -> index > 0 }
.distinctUntilChanged()
.filter { it == true }
.collect {
MyAnalyticsService.sendScrolledPastFirstItemEvent()
}
}
En el código anterior, listState.firstVisibleItemIndex
se convierte en un flujo que puede beneficiarse de la potencia de los operadores de flujo.
Cómo reiniciar efectos
Algunos efectos de Compose, como LaunchedEffect
, produceState
o DisposableEffect
, toman una cantidad variable de argumentos, claves, que se usan para cancelar el efecto de ejecución y comenzar uno nuevo con las nuevas claves.
El formato típico de estas API es el siguiente:
EffectName(restartIfThisKeyChanges, orThisKey, orThisKey, ...) { block }
Debido a las sutilezas de este comportamiento, pueden generarse problemas si los parámetros usados para reiniciar el efecto no son los adecuados:
- Reiniciar los efectos con menor frecuencia de la debida podría generar errores en tu app.
- Reiniciarlos en exceso podría hacer que su uso sea ineficiente.
Como regla general, las variables inmutables y mutables que se usan en el bloque de código de efecto deben agregarse como parámetros para el efecto componible. Además de esos, se pueden agregar más parámetros para forzar el reinicio del efecto. Si el cambio de una variable no debería causar el efecto del reinicio, la variable debe unirse en rememberUpdatedState
. Si la variable no cambia porque se une a un remember
sin claves, no necesitas pasar la variable como una clave para el efecto.
En el código DisposableEffect
que se muestra arriba, el efecto toma como parámetro el lifecycleOwner
que se usa en su bloque, ya que cualquier cambio en él provocaría que se reinicie el efecto.
@Composable
fun HomeScreen(
lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
onStart: () -> Unit, // Send the 'started' analytics event
onStop: () -> Unit // Send the 'stopped' analytics event
) {
// These values never change in Composition
val currentOnStart by rememberUpdatedState(onStart)
val currentOnStop by rememberUpdatedState(onStop)
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
/* ... */
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
}
currentOnStart
y currentOnStop
no son necesarios como claves DisposableEffect
, ya que su valor nunca cambia en Composition debido al uso de rememberUpdatedState
. Si no pasas lifecycleOwner
como parámetro y este elemento cambia, se recompone HomeScreen
, pero no se descarta ni se reinicia DisposableEffect
. Eso genera problemas porque se usa un lifecycleOwner
incorrecto a partir de ese momento.
Constantes como claves
Puedes usar una constante como true
como clave de efecto para que siga el ciclo de vida del sitio de llamadas. Hay casos de uso válidos para eso, como el ejemplo de LaunchedEffect
que se muestra arriba. Sin embargo, antes de hacer eso, piensa dos veces y asegúrate de que sea lo que necesitas.
Recomendaciones para ti
- Nota: El texto del vínculo se muestra cuando JavaScript está desactivado
- El estado y Jetpack Compose
- Kotlin para Jetpack Compose
- Cómo usar objetos View en Compose