L'objectiu dels efectes secundaris és permetre l'execució d'operacions no relacionades amb la IU que canvien l'estat de l'aplicació fora d'una funció Composable d'una manera controlada i previsible.

Introducció

Els efectes secundaris, com ara actualitzar una base de dades o obtenir dades remotes, s'han de mantenir separats de la lògica de representació de la interfície d'usuari per millorar el rendiment i el manteniment del codi.

Compose ofereix diverses funcions composables, com ara SideEffect, LaunchedEffect i DisposableEffect, que et permeten gestionar els efectes secundaris de manera eficaç, desacoblant-los de la lògica de representació de la interfície d'usuari i executant-los en un àmbit de corrutina independent.

Crea un projecte compose-side-effects.

Tens un projecte d'ajuda a https://gitlab.com/xtec/kotlin/compose/side-effects/

SideEffect

SideEffect és una funció Composable que ens permet executar un efecte secundari quan el seu Composable pare es recompon.

  • Registre i anàlisi
  • Realitzar una inicialització única, com ara configurar una connexió a un dispositiu Bluetooth, carregar dades d'un fitxer o inicialitzar una biblioteca.

Un efecte secundari és una operació que no afecta directament la interfície d'usuari, com ara el registre, l'anàlisi o l'actualització de l'estat extern. Aquesta funció és útil per executar operacions que no depenen de l'estat o de les propietats del Composable.

Quan es recompon un Composable, es torna a executar tot el codi dins de la funció Composable, inclosos els efectes secundaris. Tanmateix, la interfície d'usuari només s'actualitzarà amb els canvis que s'hagin fet a l'estat o les propietats del Composable.

Per utilitzar SideEffect, hem d'anomenar-lo dins d'una funció Composable i passar una lambda que contingui l'efecte secundari que volem executar.

Modifica el fitxer App.kt:

@Composable
fun App() {
    MaterialTheme {
        Counter()
    }
}

@Composable
fun Counter() {
    // Definim una variable d'estat
    val count = remember { mutableStateOf(0)}

    // Utilitzem un efecte secundari per registrar el valor actual de count
    SideEffect {
        println("Count is ${count.value}")
    }

    Column {
        Button(onClick = {count.value++}) {
            Text("Increase count")
        }
        // Cada cop que es modifica l'estat, es modifica el text i es torna a executar una recomposició
        Text("Counter ${count.value}")
    }
}

En aquest exemple, la funció SideEffect registra el valor actual de la variable d'estat count sempre que la funció Counter es recompon. Això és útil per depurar i supervisar el comportament del Composable.

Tingues en compte que l'efecte secundari només s'activa quan es recompon la funció composable actual i no per a cap funció composable imbricada. Això vol dir que si tens una funció Composable que crida a una altra funció Composable, el SideEffect de la funció Composable externa no s'activarà quan es recompon la funció Composable interna.

Per entendre això, modifica el codi:

@Composable
fun Counter() {
    // Definim una variable d'estat
    val count = remember { mutableStateOf(0) }

    // Utilitzem un efecte secundari per registrar el valor actual de count
    SideEffect {
        println("Count is ${count.value}")
    }

    Column {
        Button(onClick = { count.value++ }) {
            // Aquesta recomposició no activa l'efecte secundari extern quan l'usuari apreta el butó
            Text("Increase Count ${count.value}")
        }
        // Text("Counter ${count.value}")
    }
}

En aquest codi, quan s'inicia l'aplicació per primera vegada, es compon la funció Composable Counter i el SideEffect registra el valor inicial de count a la consola.

Quan fas clic al Buttone, el Text es recompon amb el nou valor de count, però això no torna a activar el SideEffect perqué el Counter no es recompon.

Ara, afegim un efecte secundari intern per veure com funciona:

@Composable
fun Counter() {
    // Definim una variable d'estat
    val count = remember { mutableStateOf(0) }

    // Utilitzem un efecte secundari per registrar el valor actual de count
    SideEffect {
        println("Outer Count is ${count.value}")
    }

    Column {
        Button(onClick = { count.value++ }) {
            SideEffect {
                println("Inner Count is ${count.value}")
            }
            // Aquesta recomposició no activa l'efecte secundari extern quan l'usuari apreta el butó
            Text("Increase Count ${count.value}")
        }
    }
}

Amb aquest codi, si es fa clic al botó, la sortida serà aquesta:

Outer Count is 0
Inner Count is 0
Inner Count is 1
Inner Count is 2
Inner Count is 3

Segur que ara saps el motiu 😀

LaunchedEffect

LaunchedEffect és una funció Composable que executa un efecte secundari en un àmbit de corrutina independent. Aquesta funció és útil per executar operacions que poden trigar molt de temps, com ara obtenir dades a través de la xarxa o animacions, sense bloquejar el fil de la IU.

  • Obtenció de dades d'una xarxa
  • Realització de processament d'imatges
  • Actualització d'una base de dades

A continuació tens un exemple:

@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 aquest exemple, la funció LaunchedEffect executa una trucada de xarxa per obtenir dades d'una API quan la variable d'estat isLoading té el valor true. La funció s'executa en un àmbit de corrutina independent, permetent que la interfície d'usuari segueixi responent mentre es realitza l'operació.

La funció LaunchedEffect pren dos paràmetres: key, que s'estableix a isLoading, i block que és una lambda que defineix l'efecte secundari que s'ha d'executar.

En aquest cas, el block lambda invoca la funció fetchData(), que simula una trucada de xarxa suspenen la corrutina durant 2 segons. Un cop s'obtenen les dades, actualitza la variable d'estat data i es posa isLoading a false, amagant l'indicador de càrrega i mostrant les dades obtingudes.

Quina és la lògica darrere del paràmetre `key`?

El paràmetre key en LaunchedEffect s'utilitza per identificar la instància LaunchedEffect i evitar que es recomponi innecessàriament.

Quan es recompon un Composable, Compose determina si s'ha de tornar a dibuixar. Si l'estat o les propietats d'un Composable han canviat, o si un Composable ha invocat invalidate, Compose torna a dibuixar el Composable. Redibuixar un Composable pot ser una operació costosa, sobretot si el Composable conté operacions de llarga durada o efectes secundaris que no cal que es tornin a executar cada vegada que es recompon el Composable.

En proporcionar un paràmetre key a LaunchedEffect, pots especificar un valor que identifiqui de manera única la instància LaunchedEffect. Si el valor del paràmetre key canvia, Compose considerarà la instància LaunchedEffect com una instància nova i tornarà a executar l'efecte secundari. Si el valor del paràmetre key continua sent el mateix, Compose saltarà l'execució de l'efecte secundari i reutilitzarà el resultat anterior, evitant recomposicions innecessàries.

També podeu utilitzar diverses "keys" per a LaunchedEffect:

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

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

DisposableEffect

DisposableEffect és una funció Composable que executa un efecte secundari quan es representa per primera vegada el Composable principal i elimina l'efecte quan el Composable s'elimina de la jerarquia d'IU.

Aquesta funció és útil per gestionar els recursos que s'han de netejar quan un Composable ja no s'utilitza, com ara:

  • Afegir i eliminar oients d'esdeveniments ("event listeners")
  • Iniciar i aturar animacions
  • Enllaçar i desvincular recursos de sensors com ara Camera, LocationManager, etc
  • Gestió de connexions de bases de dades

A contiunació tens un exemple:

@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 aquest codi, fem servir DisposableEffect per llançar una corrutina que augmenta el valor de l'estat elapsedTimecada segon. També fem servir DisposableEffect per garantir que la corrutina es cancel·li i els recursos utilitzats per la corrutina es netegen quan el Composable ja no s'utilitza.

A la funció de neteja del DisposableEffect, cancel·lem la corrutina utilitzant el mètode cancel() de la instància Job emmagatzemada a job.

La funció onDispose es crida quan el Composable s'elimina de la jerarquia de la interfície d'usuari i proporciona una manera de netejar qualsevol recurs utilitzat per Composable. En aquest cas, fem servir onDisposeper cancel·lar la corrutina i assegurar-nos que es neteja tots els recursos utilitzats per la corrutina.

Per comprovar com funcion DisposableEffect, executa aquest codi per veure el resultat:

@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 afegit un nou Composable RunTimerScreen que permet a l'usuari canviar la visibilitat de TimerScreen. Quan l'usuari fa clic al botó "Hide the timer", el Composable TimerScreen s'elimina de la jerarquia de la interfície d'usuari i la corrutina es cancel·la i es neteja.

Si elimines la invocaió a job.cancel() de la funció onDispose la corrutina continuarà executant-se fins i tot quan el Composable TimerScreen ja no estigui en ús, cosa que pot provocar fuites i altres problemes de rendiment.

Mitjançant l'ús conjutn de DisposableEffect i CoroutineScope t'assegures que la corrutina llançada per CoroutineScope es cancel·la i es netegen els recursos quan el TimerScreen ja no està en ús.

TODO

Modifica el fitxer App.kt:

@Composable
@Preview
fun App() {
    MaterialTheme {
        MyScreen()
    }
}

@Composable
fun MyScreen() {

    var count by remember { mutableStateOf(0) }

    Column {
        Text("Count: $count")

        Button(onClick = {
            // Launch a coroutine to increment the count
            CoroutineScope(Dispatchers.Default).launch {
                count++
            }
        }) {
            Text("Increment")
        }
    }
}

En aquest exemple crees un àmbit de corrutina (CoroutineScope) dins de l'expressió lambda onClick del component Button, i llences una corrutina (launch) utilitzant aquest àmbit per incrementar la variable count.

Com que no estas realitzant cap efecte secundari que afecti la IU, com ara modificar una variable d'estat mutable, pots llançar la corrutina fora d'una funció @Composable.

A continuació utilitza una corrutina per simular la càrrega de dades i actualitzar la interfície d'usuari quan es carreguen les dades.

@Composable
fun MyScreen() {
    var isLoading by remember { mutableStateOf(false) }
    var data by remember { mutableStateOf("") }

    Column {
        if (isLoading) {
            // Show a progress indicator while loading data
            CircularProgressIndicator()
        } else {
            // Show the loaded data
            Text(data)
        }

        Button(onClick = {
            isLoading = true
            CoroutineScope(Dispatchers.Default).launch {
                // Simulate loading data
                delay(2_000)

                // Update data on the main thread
                withContext(Dispatchers.Main) {
                    data = "Loaded data"
                    isLoading = false
                }
            }
        }) {
            Text("Load Data")
        }
    }
}

Quan l'usuari fa clic al botó "Load Data", establim la variable isLoading com a true i iniciem una corrutina mitjançant un nou CoroutineScope.

Dins de la corrutina, simulem la càrrega de dades retardant 2 segons mitjançant delay(2000). Després del retard, actualitzem la variable de dades al fil principal mitjançant withContext(Dispatchers.Main) i establim la variable isLoading a false.

En llançar la corrutina fora de la funció @Composable i actualitzar la interfície d'usuari dins de la corrutina mitjançant withContext(Dispatchers.Main), podem realitzar una tasca de llarga durada sense bloquejar la interfície d'usuari i actualitzar la interfície d'usuari quan s'ha completat la tasca.

Pots cridar una funció de suspensió des d'una funció Composable a Kotlin utilitzant un creador de corrutines com ara launch, async o withContext.

TODO