Durante la composición de la interfaz de usuario puede ser necesario ejecutar acciones no relacionadas con la composición y que deben ejecutarse fuera del proceso de composición.

Introducción

Crea un proyecto compose-effects.

No tienes que añadir ningua dependència.

Proyecto de ayuda: https://gitlab.com/xtec/kotlin/compose/effects/

SideEffect

SideEffect es una función componible que ejecuta un bloque de código cada vez que su componente padre se recompone, y que se ejecuta de manera indepeniente al final del proceso de composición.

Por ejemplo, a continuación tienes un código que no utiliza un SideEffect, y que registra que la cosa va 🥳 cuando en realidad ha ido 👺:

@Composable
fun App() {
    log()
    Text("Hello Word!")
    throw Exception("Evil 👺")
}

fun log() {
    println("Everything Went Well 🥳")
}

Si ejectutas el código puedes ver que se imprime el log 🥳, aunque la composición ha sido 👺

> .\gradlew composeApp:run                                                                                           

Everything Went Well 🥳
Exception in thread "main" java.lang.Exception: Evil ?
        at dev.xtec.AppKt.App(App.kt:19)
        ...

Esto pasa porque el proceso de composició ha ejecutado la llamda a log() cuando tu le has dicho que había de ejecutarlo.

En cambio si utilitzas un SideEffect como en este ejemplo, la función componible sólo se ejecuta al final de la composición si la composición se ha podido realizar.

@Composable
fun App() {
    SideEffect {
        log()
    }
    Text("Hello Word!")
    throw Exception("Evil 👺")
}

Si ejecutas el código sigue yendo 👺, pero al menos no queda registrado que la composición ha ido 🥳:

> .\gradlew composeApp:run                                                                                           

Exception in thread "main" java.lang.Exception: Evil ?
        at dev.xtec.AppKt.App(App.kt:19)
        ...

Recomposición

Cuando se recompone un Composable, se vuelve a ejecutar todo el código dentro de la función Composable, incluidos los efectos secundarios.

También sabes de Recomposición, que sólo se recompone aquella parte de la interfaz afectada por la variable de estado que ha sido modificada.

Por tanto, sólo se ejecutan los SideEffects que formen parte de los componibles que se vuelven a recomponer al verse afectados por la modificación de la variable de estado.

Sin embargo, la interfaz de usuario sólo se actualizará con los cambios que se hayan realizado en el estado o propiedades del Composable.

Modifica el archivo App.kt:

@Composable
fun App() {
    val count = remember { mutableStateOf(0) }

    Column {

        Text("DOT DOT DOT 🥰 🥰 🥰")
        SideEffect {
            println("${count.value} ++++++++++")
        }

        Button(onClick = { count.value++ }) {
            SideEffect {
                println("${count.value} ##########")
            }
            Text("Count")
        }
    }
}

Si ejecutas el código puedes ver que los SideEffects sólo se ejecutan una vez:

> .\gradlew composeApp:run

0 ++++++++++
0 ##########
...

No importa las veces que aprietes el botón y la variable de estado count incremente su valor: a ningún composable le interesa su estado (probrecita, nadie le hace caso 😥) y ninguno se recompone.

Modifica el código para que el texto del botón muestre el valor de la variable de estado count:

@Composable
fun App() {
    val count = remember { mutableStateOf(0) }

    Column {

        Text("DOT DOT DOT 🥰 🥰 🥰")
        SideEffect {
            println("${count.value} ++++++++++")
        }

        Button(onClick = { count.value++ }) {
            SideEffect {
                println("${count.value} ##########")
            }
            Text("Count ${count.value} ♥️")
        }
    }
}

Ahora si aprietas el bóton, el botón se tiene que recomponer porque está atado al estado de la variable count al mostrar su valor:

> .\gradlew composeApp:run

0 ++++++++++
0 ##########
1 ##########
2 ##########
3 ##########
...

Y al recomponerse el Button también se ejecuta su SideEffect ... que bonita historia de amor 💞 🌚

Para que después digan que escribir código no es creativo y poético 😂

LaunchedEffect

Aunque las historias de SideEffect pueden ser interesantes y románticas, muchas otras se tienen que ejecutar en otra corrutina independiente porque el proceso principal no puede esperar a que los SideEffects terminen lo que que quiere que hagan los SideEffect, que en la mayoría de los casos es esperar muchismo, muchisimo tiempo en tiempo de ordenador sin hacer absolutamente nada.

Las aplicaciones mutiplataforma se caracterizan por el uso intesivo de operaciones E/S como el acceso a un fichero, una base de datos o un servicio remoto, que básicamente consiste en esperar 🥱.

Por tanto, las operaciones E/S se se tienen que ejecutar de manera asíncrona en un ámbito de corrutina independiente para que la UI no se "congele" mientras se espera el resultado.

Modifica el código:

@Composable
fun App() {
    val text = io("Hello", 3000)
    Text(text)
}

 suspend fun <T>io(data:T, time: Long): T {
     delay(time)
     return data
 }

Este código no se puede ejecutar porque no compila:

> .\gradlew composeApp:run 

... App.kt:17:16 Suspend function 'suspend fun  io(data: T, time: Long): T' should be called only from a coroutine or another suspend function.
...

Como era de esperar, Kotlin detecta que estás llamando una función "suspend" que bloquea la corutina que renderiza la UI, y no lo permite.

Para estos casos está LaunchedEffect, una función componible que ejecuta un efecto secundario en un ámbito de corrutina independiente.

@Composable
fun App() {
    val text = "♥️ Best Love Song ? ♥️"
    LaunchedEffect(true) {
        val song = io("You Make My Dreams", 3000)
    }
    Text(text)
}

suspend fun <T> io(data: T, time: Long): T {
    delay(time)
    return data
}

Ahora el código se ejecuta, se compone una UI con el texto "♥️ Best Love Song ? ♥️", se ejecuta una operación E/S que devuelve "You Make My Dreams" y la imprime por pantalla:

> .\gradlew composeApp:run 

You Make My Dreams
...

De funcionar, funciona, no congela la UI, pero como utilizamos el resultado en la UI 🧐?

Con un variable de estado 😀:

@Composable
fun App() {
    var text by remember { mutableStateOf("♥️ Best Love Song ? ♥️") }
    LaunchedEffect(true) {
        val song = io("You Make My Dreams", 5000)
        text = song
    }
    Text(text)
}

Ahora cuando la operación io devuelve la canción después de 5 segundos, modificamos la variable de estado text en la corutina independiente, y como se ha modificado una variable de estado, la UI se vuelve a recomponer.

TODO

A continuación tienes un ejemplo:

@Composable
fun FetchComposable() {
    var isLoading by remember { mutableStateOf(false) }
    var data by remember { mutableStateOf(listOf<String>()) }

    // Definim un LaunchEffect per realitzar una operació asícnrona que tardarà molt de temps en completar-se
    // LaunchEffect es cancelarà i és tornarà a executar si `isLoading` canvia.
    LaunchedEffect(isLoading) {
        if (isLoading) {
            // Realitza una tasca que tarda molt de temps en executar-se, com obtenir dades desde una xarxa
            val newData = fetchData()
            // Actualitzem l'estat amb les noves dades
            data = newData
            isLoading = false
        }
    }

    Column {
        Button(onClick = { isLoading =true}) {
            Text("Fetch Data")
        }
        if (isLoading) {
            // Mostra un indicador de progress
            CircularProgressIndicator()
        } else {
            // Mostra les dades
            LazyColumn {
                items(data) { item ->
                    Text(item)
                }
            }
        }
    }
}

En este ejemplo, la función LaunchedEffect ejecuta una llamada de red para obtener datos de una API cuando la variable de estado isLoading tiene el valor true. La función se ejecuta en un ámbito de corrutina independiente, permitiendo que la interfaz de usuario siga respondiendo mientras se realiza la operación.

La función LaunchedEffect toma dos parámetros: key, que se establece en isLoading, y block que es una lambda que define el efecto secundario a ejecutar.

En este caso, el bloque lambda invoca la función fetchData(), que simula una llamada de red que suspende la corrutina durante 2 segundos. Una vez que se obtienen los datos, actualiza la variable de estado data y se pone isLoading a false, escondiendo el indicador de carga y mostrando los datos obtenidos.

¿Cuál es la lógica detrás del parámetro key?

El parámetro key en LaunchedEffect se utiliza para identificar la instancia LaunchedEffect y evitar que se recomponga innecesariamente.

Cuando se recompone un Composable, Compose determina si debe volver a dibujarse. Si el estado o las propiedades de un Composable han cambiado, o si un Composable ha invocado invalidate, Compose vuelve a dibujar el Composable. Redibujar un Composable puede ser una operación costosa, sobre todo si el Composable contiene operaciones de larga duración o efectos secundarios que no es necesario que se vuelvan a ejecutar cada vez que se recompone el Composable.

Al proporcionar un parámetro key a LaunchedEffect, puedes especificar un valor que identifique de forma única la instancia LaunchedEffect. Si el valor del parámetro key cambia, Compose considerará la instancia LaunchedEffect como una nueva instancia y volverá a ejecutar el efecto secundario. Si el valor del parámetro key sigue siendo el mismo, Compose saltará la ejecución del efecto secundario y reutilizará el resultado anterior, evitando recomposiciones innecesarias.

También puede utilizar varias "keys" para LaunchedEffect:

// Utilitzeu un UUID aleatori com a clau per a LaunchedEffect 
val key = remember { UUID.randomUUID().toString() } 

LaunchedEffect(key, isLoading) { 
  .... 
}

DisposableEffect

DisposableEffect es una función Composable que ejecuta un efecto secundario cuando se representa por primera vez el Composable principal y elimina el efecto cuando el Composable se elimina de la jerarquía de IU.

Esta función es útil para gestionar los recursos que deben limpiarse cuando un Composable ya no se utiliza, tales como:

  • Añadir y eliminar oyentes de eventos ("event listeners")
  • Iniciar y detener animaciones
  • Enlazar y desvincular recursos de sensores tales como Camera, LocationManager, etc
  • Gestión de conexiones de bases de datos

A continuación tienes un ejemplo:

@Composable
fun TimerScreen() {

    var elapsedTime by remember { mutableStateOf(0) }

    DisposableEffect(Unit) {
        val scope = CoroutineScope(Dispatchers.Default)
        val job = scope.launch {
            while(true) {
                delay(1_000)
                elapsedTime +=1
                println("Timer is still working $elapsedTime")
            }
        }

        onDispose {
            job.cancel()
        }
    }

    Text(
        text = "Elapsed Time: $elapsedTime",
        modifier = Modifier.padding(16.dp),
        fontSize = 24.sp
    )
}

En este código, utilizamos DisposableEffectpara lanzar una corrutina que aumenta el valor del estado elapsedTime cada segundo. También utilizamos DisposableEffect para garantizar que la corrutina se cancele y los recursos utilizados por la corrutina se limpian cuando el Composable ya no se utiliza.

En la función de limpieza del DisposableEffect, cancelamos la corrutina utilizando el método cancel() de la instancia Job almacenada en job.

La función onDisposese se llama cuando el Composable se elimina de la jerarquía de la interfaz de usuario y proporciona una forma de limpiar cualquier recurso utilizado por Composable. En este caso, utilizamos onDispose para cancelar la corrutina y asegurarnos de que se limpia todos los recursos utilizados por la corrutina.

Para comprobar cómo funciona DisposableEffect, ejecuta este código para ver el resultado:

@Composable
fun RunTimerScreen() {
    var isVisible by remember { mutableStateOf(true) }

    Column(
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Bottom
    ) {
        Spacer(modifier = Modifier.height(10.dp))
        Button(onClick = { isVisible = false }) {
            Text("Hide the timer")
        }
        if (isVisible)
            TimerScreen()
    }
}

Has añadido un nuevo Composable RunTimerScreen que permite al usuario cambiar la visibilidad de TimerScreen. Cuando el usuario hace clic en el botón "Hide the timer", el Composable TimerScreense elimina de la jerarquía de la interfaz de usuario y la corrutina se cancela y limpia.

Si eliminas la invocación a job.cancel() de la función onDispose la corrutina continuará ejecutándose incluso cuando el Composable TimerScreen ya no esté en uso, lo que puede provocar escapes y otros problemas de rendimiento.

Mediante el uso conjunto de DisposableEffect y CoroutineScope te aseguras que la corrutina lanzada por CoroutineScope se cancela y se limpian los recursos cuando el TimerScreen ya no está en uso.

TODO