Effets secondaires dans Compose

Un effet secondaire est un changement d'état de l'application qui se produit en dehors du champ d'application d'une fonction modulable. En raison du cycle de vie et des propriétés des composables (les recompositions imprévisibles, l'exécution de recompositions de composables dans différents ordres ou les recompositions pouvant être supprimées), les composables devraient idéalement ne pas avoir d'effets secondaires.

Cependant, des effets secondaires sont parfois nécessaires, par exemple pour déclencher un événement ponctuel, comme afficher un snackbar ou accéder à un autre écran en fonction d'une certaine condition d'état. Ces actions doivent être appelées à partir d'un environnement contrôlé qui prend en compte le cycle de vie du composable. Sur cette page, vous découvrirez les différentes API d'effets secondaires proposées par Jetpack Compose.

Cas d'utilisation d'état et d'effet

Comme indiqué dans la documentation Raisonnement dans Compose, les composables ne devraient pas avoir d'effets secondaires. Lorsque vous devez modifier l'état de l'application (comme décrit dans la documentation Gestion de l'état), vous devez utiliser les API Effect pour exécuter ces effets secondaires de manière prévisible.

Les différentes possibilités offertes par les effets dans Compose peuvent facilement entraîner une utilisation excessive. Assurez-vous que votre travail est lié à l'interface utilisateur et qu'il ne brise pas le flux de données unidirectionnel, comme expliqué dans la documentation Gestion de l'état.

LaunchedEffect: exécute des fonctions de suspension dans le champ d'application d'un composable.

Pour appeler des fonctions de suspension en toute sécurité depuis un composable, utilisez le composable LaunchedEffect. Lorsque LaunchedEffect entre dans la composition, il lance une coroutine avec le bloc de code transmis en tant que paramètre. La coroutine est annulée si LaunchedEffect quitte la composition. Si LaunchedEffect est recomposé avec des clés différentes (voir la section Redémarrage des effets ci-dessous), la coroutine existante est annulée et la nouvelle fonction de suspension est lancée dans une nouvelle coroutine.

Par exemple, l'affichage d'un Snackbar dans un Scaffold est effectué à l'aide de la fonction SnackbarHostState.showSnackbar, qui est une fonction de suspension.

@Composable
fun MyScreen(
    state: UiState<List<Movie>>,
    snackbarHostState: SnackbarHostState
) {

    // If the UI state contains an error, show snackbar
    if (state.hasError) {

        // `LaunchedEffect` will cancel and re-launch if
        // `scaffoldState.snackbarHostState` changes
        LaunchedEffect(snackbarHostState) {
            // Show snackbar using a coroutine, when the coroutine is cancelled the
            // snackbar will automatically dismiss. This coroutine will cancel whenever
            // `state.hasError` is false, and only start when `state.hasError` is true
            // (due to the above if-check), or if `scaffoldState.snackbarHostState` changes.
            snackbarHostState.showSnackbar(
                message = "Error message",
                actionLabel = "Retry message"
            )
        }
    }

    Scaffold(
        snackbarHost = {
            SnackbarHost(hostState = snackbarHostState)
        }
    ) { contentPadding ->
        // ...
    }
}

Dans le code ci-dessus, une coroutine est déclenchée si l'état contient une erreur et est annulée dans le cas contraire. Comme le site d'appel LaunchedEffect se trouve dans une instruction if, lorsque cette instruction est fausse, si LaunchedEffect était dans la composition, il est supprimé et la coroutine est annulée.

rememberCoroutineScope: obtenez un champ d'application compatible avec la composition pour lancer une coroutine en dehors d'un composable.

Comme LaunchedEffect est une fonction modulable, il ne peut être utilisé que dans d'autres fonctions modulables. Pour lancer une coroutine en dehors d'un composable, mais avec une portée limitée de sorte qu'elle soit automatiquement annulée une fois qu'elle a quitté la composition, utilisez rememberCoroutineScope. Utilisez également rememberCoroutineScope chaque fois que vous devez contrôler manuellement le cycle de vie d'une ou plusieurs coroutines, par exemple pour annuler une animation lors d'un événement utilisateur.

rememberCoroutineScope est une fonction modulable qui renvoie un CoroutineScope lié au point de la composition où il est appelé. Le champ d'application est annulé lorsque l'appel quitte la composition.

Comme dans l'exemple précédent, vous pouvez utiliser ce code pour afficher un Snackbar lorsque l'utilisateur appuie sur 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: référence une valeur dans un effet qui ne doit pas redémarrer si la valeur change.

LaunchedEffect redémarre lorsque l'un des paramètres clés est modifié. Toutefois, dans certaines situations, vous souhaiterez peut-être capturer une valeur dans votre effet que vous ne souhaitez pas redémarrer si elle change. Pour ce faire, vous devez utiliser rememberUpdatedState pour créer une référence à cette valeur, qui peut être capturée et mise à jour. Cette approche est utile pour les effets qui contiennent des opérations de longue durée dont la recréation et le redémarrage peuvent être coûteux ou interdits.

Par exemple, imaginons que votre application dispose d'un LandingScreen qui disparaît au bout d'un certain temps. Même si LandingScreen est recomposé, l'effet qui attend un certain temps et indique que le temps s'est écoulé ne doit pas être redémarré :

@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 */
}

Pour créer un effet correspondant au cycle de vie du site d'appel, une constante qui ne change jamais, comme Unit ou true est transmise en tant que paramètre. Dans le code ci-dessus, LaunchedEffect(true) est utilisé. Pour que le lambda onTimeout contienne toujours la dernière valeur utilisée pour recomposer LandingScreen, onTimeout doit être encapsulé avec la fonction rememberUpdatedState. Le State renvoyé, currentOnTimeout dans le code, doit être utilisé dans l'effet.

DisposableEffect: effets nécessitant un nettoyage

Pour les effets secondaires qui doivent être nettoyés après le changement de clés ou si le composable quitte la composition, utilisez DisposableEffect. Si les clés DisposableEffect changent, le composable doit supprimer (procéder au nettoyage de) l'effet actuel et le réinitialiser en appelant à nouveau l'effet.

Par exemple, vous pouvez envoyer des événements d'analyse basés sur des événements Lifecycle à l'aide d'un LifecycleObserver. Pour écouter ces événements dans Compose, utilisez un DisposableEffect afin d'enregistrer et d'annuler l'enregistrement de l'observateur si nécessaire.

@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 */
}

Dans le code ci-dessus, l'effet ajoute le observer au lifecycleOwner. Si lifecycleOwner change, l'effet est supprimé et redémarré avec le nouveau lifecycleOwner.

Un DisposableEffect doit inclure une clause onDispose comme instruction finale dans son bloc de code. Sinon, l'IDE affiche une erreur de temps de compilation.

SideEffect: publier l'état de Compose dans du code autre que Compose

Pour partager l'état de Compose avec des objets non gérés par Compose, utilisez le composable SideEffect. L'utilisation d'un SideEffect garantit que l'effet s'exécute après chaque recomposition réussie. En revanche, il est incorrect d'effectuer un effet avant que la recomposition réussie ne soit garantie, ce qui est le cas lorsque vous écrivez l'effet directement dans un composable.

Par exemple, votre bibliothèque d'analyse peut vous permettre de segmenter votre base d'utilisateurs en associant des métadonnées personnalisées ("propriétés utilisateur" dans cet exemple) à tous les événements d'analyse suivants. Pour communiquer le type d'utilisateur de l'utilisateur actuel à votre bibliothèque d'analyse, utilisez SideEffect afin de mettre à jour sa valeur.

@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: convertit un état autre que Compose en un état Compose.

produceState lance une coroutine limitée à la composition qui peut transférer des valeurs dans un State renvoyé. Utilisez-le pour convertir l'état autre que Compose en un état Compose, par exemple en intégrant un état externe basé sur un abonnement, tel que Flow, LiveData ou RxJava dans la composition.

Le producteur est lancé lorsque produceState entre dans la composition. Il est annulé lorsqu'il quitte la composition. Le State renvoyé est combiné. Définir la même valeur ne déclenche pas de recomposition.

Bien que produceState crée une coroutine, il peut également être utilisé pour observer des sources de données sans suspension. Pour supprimer l'abonnement à cette source, utilisez la fonction awaitDispose.

L'exemple suivant montre comment utiliser produceState pour charger une image à partir du réseau. La fonction modulable loadNetworkImage renvoie un State utilisable dans d'autres composables.

@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: convertit un ou plusieurs objets d'état en un autre état.

Dans Compose, une recomposition a lieu chaque fois qu'un objet d'état observé ou que l'entrée d'un composable change. Un objet d'état ou une entrée peuvent changer plus souvent que l'interface utilisateur n'a besoin d'être mise à jour, conduisant à une recomposition inutile.

Vous devez utiliser la fonction derivedStateOf lorsque les entrées d'un composable changent plus souvent qu'il n'est nécessaire de recomposer. Cela se produit souvent lorsque quelque chose change fréquemment, comme une position de défilement, mais que le composable ne doit réagir qu'une fois qu'il a franchi un certain seuil. derivedStateOf crée un nouvel objet d'état Compose que vous pouvez observer et qui ne se met à jour qu'en fonction de vos besoins. Ainsi, il agit de la même manière que l'opérateur de flux Kotlin distinctUntilChanged().

Utilisation correcte

L'extrait de code suivant montre un cas d'utilisation approprié de 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()
        }
    }
}

Dans cet extrait de code, firstVisibleItemIndex change dès que le premier élément visible change. En avançant dans le code, la valeur devient 0, 1, 2, 3, 4, 5, etc. Cependant, la recomposition ne doit avoir lieu que si la valeur est supérieure à 0. Ce décalage dans la fréquence de mise à jour montre qu'il s'agit d'un bon cas d'utilisation pour derivedStateOf.

Utilisation incorrecte

Une erreur courante est d'assumer que lorsque vous combinez deux objets d'état Compose vous devez utiliser derivedStateOf car vous "modifiez l'état". Cependant, ceci ne représente qu'un surcoût et n'est pas nécessaire, comme le montre l'extrait de code suivant :

// 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

Dans cet extrait, fullName doit se mettre autant à jour que firstName et lastName. Ainsi, nous ne sommes pas en présence d'une recomposition excessive. Utiliser derivedStateOf n'est donc pas nécessaire.

snapshotFlow: convertit l'état de Compose en flux.

Utilisez snapshotFlow pour convertir des objets State<T> en flux froid. snapshotFlow exécute son bloc lorsqu'il est collecté et émet le résultat des objets State qui y sont lus. Lorsque l'un des objets State lus à l'intérieur du bloc snapshotFlow est modifié, le flux émet la nouvelle valeur pour son collecteur si la nouvelle valeur n'est pas égale à à la valeur précédemment émise (ce comportement est semblable à celui de Flow.distinctUntilChanged).

L'exemple suivant illustre un effet secondaire enregistré lorsque l'utilisateur dépasse le premier élément en faisant défiler une liste à des fins d'analyse :

val listState = rememberLazyListState()

LazyColumn(state = listState) {
    // ...
}

LaunchedEffect(listState) {
    snapshotFlow { listState.firstVisibleItemIndex }
        .map { index -> index > 0 }
        .distinctUntilChanged()
        .filter { it == true }
        .collect {
            MyAnalyticsService.sendScrolledPastFirstItemEvent()
        }
}

Dans le code ci-dessus, listState.firstVisibleItemIndex est converti en flux pouvant bénéficier de la puissance des opérateurs de flux.

Redémarrage des effets

Certains effets dans Compose, comme LaunchedEffect, produceState ou DisposableEffect, utilisent un nombre variable d'arguments ou de clés, qui permettent d'annuler l'effet en cours d'exécution et d'en démarrer un autre avec les nouvelles clés.

En général, ces API se présentent sous la forme suivante :

EffectName(restartIfThisKeyChanges, orThisKey, orThisKey, ...) { block }

La subtilité de ce comportement peut provoquer des problèmes si les paramètres utilisés pour redémarrer l'effet ne sont pas corrects :

  • Redémarrer les effets moins souvent que nécessaire peut provoquer des bugs dans votre application.
  • Redémarrer les effets plus souvent que nécessaire peut s'avérer inefficace.

En règle générale, les variables modifiables et immuables utilisées dans le bloc d'effet du code doivent être ajoutées en tant que paramètres au composable d'effet. Outre ces variables, vous pouvez ajouter d'autres paramètres pour forcer le redémarrage de l'effet. Si la modification d'une variable ne doit pas entraîner le redémarrage de l'effet, la variable doit être encapsulée dans rememberUpdatedState. Si la variable ne change jamais, car elle est encapsulée dans un remember sans clé, vous n'avez pas besoin de transmettre la variable en tant que clé à l'effet.

Dans le code DisposableEffect présenté ci-dessus, l'effet prend comme paramètre le lifecycleOwner utilisé dans son bloc, car toute modification doit entraîner le redémarrage de l'effet.

@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 et currentOnStop ne sont pas nécessaires en tant que clés DisposableEffect, car leur valeur ne change jamais dans la composition en raison de l'utilisation de rememberUpdatedState. Si vous ne transmettez pas lifecycleOwner en tant que paramètre et qu'il change, HomeScreen se recompose, mais DisposableEffect n'est pas supprimé ni redémarré. Cela provoque des problèmes, car le mauvais lifecycleOwner est utilisé à partir de ce moment-là.

Constantes comme clés

Vous pouvez utiliser une constante telle que true comme clé d'effet pour la faire suivre le cycle de vie du site d'appel. Il existe des cas d'utilisation valides, tels que l'exemple de LaunchedEffect présenté ci-dessus. Cependant, avant de commencer, réfléchissez bien et vérifiez que c'est bien ce dont vous avez besoin.