Raisonnement dans Compose

Jetpack Compose est un kit d'UI déclaratif moderne pour Android. Il facilite l'écriture et la gestion de l'interface utilisateur de votre application grâce à une API déclarative qui vous permet d'afficher l'interface utilisateur de votre application sans avoir à modifier impérativement ses vues. Ce concept, qui nécessite quelques explications, a des répercussions importantes sur la conception de votre application.

Le paradigme de programmation déclaratif

Une hiérarchie des vues Android est représentée depuis toujours comme une arborescence de widgets d'interface utilisateur. Lorsque l'état de l'application change pour différentes raisons, telles que les interactions utilisateur, la hiérarchie de l'interface utilisateur doit être mise à jour pour afficher les données actuelles. La méthode la plus courante de mise à jour de l'interface utilisateur consiste à parcourir l'arborescence à l'aide de fonctions telles que findViewById(), et à modifier les nœuds en appelant des méthodes comme button.setText(String), container.addChild(View) ou img.setImageBitmap(Bitmap). Ces méthodes modifient l'état interne du widget.

La manipulation manuelle des vues augmente la probabilité d'erreurs. Si des données spécifiques sont affichées à plusieurs endroits, il est également facile d'oublier de mettre à jour l'une des vues qui les affichent. Vous courez aussi plus facilement le risque de créer des états illégaux lorsque deux mises à jour entrent en conflit de manière inattendue. Par exemple, une mise à jour peut tenter de définir la valeur d'un nœud qui vient d'être supprimé de l'interface utilisateur. En général, la complexité liée à la gestion logicielle augmente parallèlement au nombre de vues à mettre à jour.

Au cours des dernières années, l'ensemble du secteur a commencé à adopter un modèle d'interface utilisateur déclaratif. Cette approche simplifie considérablement l'ingénierie associée à la création et à la mise à jour des interfaces utilisateur. Cette technique consiste à générer entièrement l'intégralité de l'écran à partir de zéro, puis à appliquer uniquement les modifications nécessaires. Elle évite ainsi la mise à jour manuelle d'une hiérarchie des vues avec état. Compose est un framework d’interface utilisateur déclaratif.

L'un des défis liés à la régénération de l'intégralité de l'écran est que ce processus nécessite beaucoup de temps, de puissance de calcul et d'utilisation de la batterie, ce qui peut se révéler coûteux. Pour pallier ce problème, Compose choisit intelligemment les parties de l'interface utilisateur qui doivent être redessinées à tout moment, ce qui a des répercussions sur la conception des composants d'UI, comme indiqué dans la section Recomposition.

Une fonction modulable simple

Avec Compose, vous pouvez créer votre interface utilisateur en définissant un ensemble de fonctions modulables qui collectent des données et émettent des éléments d'interface utilisateur. Un exemple simple est un widget Greeting, qui accepte une chaîne (String) et émet un widget Text qui affiche un message d'accueil.

Capture d'écran d'un téléphone affichant le texte

Figure 1 : Fonction modulable simple à laquelle sont transmises les données qu'elle utilise pour afficher un widget Text à l'écran.

Voici quelques points importants à retenir concernant cette fonction :

  • Elle est annotée avec @Composable. Toutes les fonctions modulables doivent comporter cette annotation. Celle-ci informe le compilateur Compose qu'elle est destinée à convertir les données en UI.

  • Elle reçoit des données. Les fonctions modulables peuvent accepter des paramètres, ce qui permet à la logique de l'application de décrire l'UI. Dans ce cas, le widget accepte une réponse String afin de pouvoir accueillir les utilisateurs par leur nom.

  • Elle affiche du texte dans l'interface utilisateur. Pour ce faire, elle appelle la fonction modulable Text(), qui crée l'élément d'UI sous forme de texte. Les fonctions modulables émettent une hiérarchie d'interface utilisateur en appelant d'autres fonctions modulables.

  • Elle ne renvoie aucune information. Les fonctions Compose qui émettent l'interface utilisateur n'ont pas besoin de renvoyer quoi que ce soit, car elles décrivent l'état d'écran souhaité au lieu de construire des widgets d'interface utilisateur.

  • Elle est rapide, idempotente et dépourvue d'effets secondaires.

    • La fonction se comporte de la même manière lorsqu'elle est appelée plusieurs fois avec le même argument. Elle n'utilise pas d'autres valeurs, telles que des variables globales ou des appels à random().
    • La fonction décrit l'interface utilisateur sans aucun effet secondaire, tel que la modification de propriétés ou de variables globales.

    En général, toutes les fonctions modulables doivent être écrites avec ces propriétés, pour les raisons décrites dans la section Recomposition.

Adoption du paradigme déclaratif

Nombreux sont les kits d'outils d'interface utilisateur impératifs orientés objet, qui impliquent l'initialisation d'une arborescence de widgets, souvent via le gonflement d'un fichier de mise en page XML. Chaque widget conserve son propre état interne et expose des méthodes getter et setter qui permettent à la logique d'application d'interagir avec lui.

Dans l'approche déclarative de Compose, les widgets sont sans état et n'exposent pas de fonctions setter ni getter. Ils ne sont pas exposés en tant qu'objets. Pour mettre à jour l'UI, vous appelez la même fonction modulable avec différents arguments. Vous pouvez ainsi fournir facilement un état dans les modèles architecturaux tels qu'un ViewModel, comme décrit dans le Guide de l'architecture des applications. Vos composables doivent ensuite transformer l'état actuel de l'application en UI à chaque mise à jour des données observables.

Illustration du flux de données dans une interface utilisateur Compose, allant des objets de haut niveau aux enfants

Figure 2. La logique d'application fournit des données à la fonction modulable de niveau supérieur. Cette fonction utilise les données pour décrire l'UI en appelant d'autres composables et transmet les données appropriées à ces composables, ainsi qu'aux éléments de niveau inférieur dans la hiérarchie.

Lorsque l'utilisateur interagit avec l'UI, celle-ci déclenche des événements tels qu'onClick. Ces événements doivent notifier la logique de l'application, qui peut ainsi modifier l'état de l'application. Lorsque l'état change, les fonctions modulables sont appelées à nouveau avec les nouvelles données. Les éléments de l'interface utilisateur sont alors redessinés. Ce processus est appelé recomposition.

Illustration de la manière dont les éléments de l'UI réagissent aux interactions, en déclenchant des événements gérés par la logique de l'application.

Figure 3. L'utilisateur a interagi avec un élément d'UI, ce qui a déclenché un événement. La logique de l'application répond à l'événement, puis les fonctions modulables sont automatiquement appelées avec de nouveaux paramètres, si nécessaire.

Contenu dynamique

Comme les fonctions modulables sont écrites en Kotlin et pas en XML, elles peuvent être aussi dynamiques que n'importe quel autre code Kotlin. Supposons que vous souhaitiez créer une UI qui accueille une liste d'utilisateurs :

@Composable
fun Greeting(names: List<String>) {
    for (name in names) {
        Text("Hello $name")
    }
}

Cette fonction génère une liste de noms, ainsi qu'un message d'accueil pour chaque utilisateur. Les fonctions modulables peuvent être assez sophistiquées. Vous pouvez utiliser des instructions if pour décider si vous souhaitez afficher un élément d'interface utilisateur particulier. Vous pouvez également utiliser des boucles ou appeler des fonctions d'assistance. Vous bénéficiez de la flexibilité totale du langage sous-jacent. Cette puissance et cette flexibilité font partie des principaux avantages de Jetpack Compose.

Recomposition

Dans un modèle d'interface utilisateur impératif, vous devez appeler un setter au niveau du widget pour modifier son état interne. Dans Compose, vous rappelez la fonction modulable avec les nouvelles données. Cette action entraîne la recomposition de la fonction : les widgets émis par la fonction sont redessinés, si nécessaire, avec les nouvelles données. Le framework Compose ne peut recomposer intelligemment que les composants qui ont changé.

Prenons l'exemple de cette fonction modulable qui affiche un bouton :

@Composable
fun ClickCounter(clicks: Int, onClick: () -> Unit) {
    Button(onClick = onClick) {
        Text("I've been clicked $clicks times")
    }
}

Chaque fois que l'utilisateur clique sur le bouton, la valeur clicks est mise à jour. Compose appelle à nouveau le lambda avec la fonction Text pour afficher la nouvelle valeur. Ce processus est appelé recomposition. Les autres fonctions qui ne dépendent pas de cette valeur ne sont pas recomposées.

Comme nous l'avons vu, la recomposition de l'ensemble de l'arborescence de l'interface utilisateur peut être coûteuse, car elle consomme de la puissance de calcul et de la batterie. Avec cette recomposition intelligente, Compose résout ce problème.

La recomposition consiste à appeler à nouveau les fonctions modulables lorsque les entrées changent. Elle n'a lieu que dans ce cas de figure. Lorsque Compose effectue la recomposition avec de nouvelles entrées, il n'appelle que les fonctions ou les lambdas qui ont pu changer, et ignore le reste. En ignorant toutes les fonctions ou tous les lambdas dont les paramètres n'ont pas changé, Compose recompose l'interface efficacement.

Ne dépendez jamais des effets secondaires de l'exécution de fonctions modulables, car la recomposition d'une fonction pourrait être ignorée. Dans ce cas, les utilisateurs pourraient constater un comportement étrange et imprévisible dans votre application. Un effet secondaire désigne toute modification visible par le reste de votre application. Par exemple, ces actions comportent toutes de dangereux effets secondaires :

  • Écrire dans une propriété d'un objet partagé
  • Mettre à jour un objet observable dans ViewModel
  • Mettre à jour des préférences partagées

Les fonctions modulables peuvent être réexécutées aussi souvent que chaque frame, par exemple lorsqu'une animation est en cours de rendu. Elles doivent être rapides pour éviter tout à-coup pendant les animations. Si vous devez effectuer des opérations coûteuses telles que la lecture des préférences partagées, procédez dans une coroutine d'arrière-plan et transmettez le résultat à la fonction modulable en tant que paramètre.

Par exemple, ce code crée un composable pour mettre à jour une valeur dans SharedPreferences. Ce composable ne doit pas lire ni écrire à partir des préférences partagées. Au lieu de cela, ce code déplace la lecture et l'écriture vers un ViewModel dans une coroutine d'arrière-plan. La logique de l'application transmet la valeur actuelle avec un rappel afin de déclencher une mise à jour.

@Composable
fun SharedPrefsToggle(
    text: String,
    value: Boolean,
    onValueChanged: (Boolean) -> Unit
) {
    Row {
        Text(text)
        Checkbox(checked = value, onCheckedChange = onValueChanged)
    }
}

Ce document aborde un certain nombre de points à prendre en compte lorsque vous utilisez Compose:

  • Les fonctions modulables peuvent s'exécuter dans n'importe quel ordre.
  • Les fonctions modulables peuvent s'exécuter en parallèle.
  • La recomposition ignore autant de fonctions modulables et de lambdas que possible.
  • La recomposition est optimiste et peut être annulée.
  • Une fonction modulable peut être exécutée très fréquemment, aussi souvent que chaque frame d'une animation.

Les sections suivantes expliquent comment créer des fonctions modulables compatibles avec la recomposition. Dans tous les cas, veillez à ce que les fonctions modulables restent rapides, idempotentes et dénuées d'effets secondaires.

Les fonctions modulables peuvent s'exécuter dans n'importe quel ordre

Si vous examinez le code d'une fonction modulable, vous pourriez déduire que le code est exécuté dans l'ordre dans lequel il apparaît. Ce n'est pas forcément le cas. Si une fonction modulable contient des appels à d'autres fonctions modulables, celles-ci peuvent s'exécuter dans n'importe quel ordre. Compose a la possibilité de reconnaître que certains éléments de l'interface utilisateur ont une priorité plus élevée que d'autres. Il les trace donc en premier.

Supposons que vous utilisiez ce code pour dessiner trois écrans dans une mise en page d'onglets :

@Composable
fun ButtonRow() {
    MyFancyNavigation {
        StartScreen()
        MiddleScreen()
        EndScreen()
    }
}

Les appels à StartScreen, MiddleScreen et EndScreen peuvent avoir lieu dans n'importe quel ordre. Autrement dit, vous ne pouvez pas, par exemple, demander à StartScreen() de définir une variable globale (un effet secondaire) et faire en sorte que MiddleScreen() profite de cette modification. Chacune de ces fonctions doit être autonome.

Les fonctions modulables peuvent s'exécuter en parallèle

Compose peut optimiser la recomposition en exécutant des fonctions modulables en parallèle. Il exploite ainsi plusieurs cœurs et exécute les fonctions modulables qui ne sont pas à l'écran avec une priorité inférieure.

Cette optimisation signifie qu'une fonction modulable peut s'exécuter dans un pool de threads en arrière-plan. Si une fonction modulable appelle une fonction au niveau d'un ViewModel, Compose peut appeler cette fonction à partir de plusieurs threads en même temps.

Pour vous assurer que votre application se comporte correctement, aucune des fonctions modulables ne devrait avoir d'effet secondaire. Déclenchez plutôt des effets secondaires à partir de rappels comme onClick, qui s'exécutent toujours sur le thread UI.

Lorsqu'une fonction modulable est appelée, l'appel peut se produire sur un autre thread que l'appelant. Il est donc conseillé d'éviter tout code qui modifie les variables dans un lambda modulable, non seulement parce qu'il n'est pas thread-safe, mais aussi parce qu'il s'agit d'un effet secondaire inadmissible de ce type de lambda.

Voici un exemple illustrant un composable qui affiche une liste et le nombre d'éléments qu'elle contient :

@Composable
fun ListComposable(myList: List<String>) {
    Row(horizontalArrangement = Arrangement.SpaceBetween) {
        Column {
            for (item in myList) {
                Text("Item: $item")
            }
        }
        Text("Count: ${myList.size}")
    }
}

Ce code ne génère aucun effet secondaire et transforme la liste d'entrée en UI. Ce code est idéal pour afficher une liste de petite envergure. Toutefois, si la fonction écrit les données dans une variable locale, le code suivant n'est ni thread-safe, ni correct :

@Composable
@Deprecated("Example with bug")
fun ListWithBug(myList: List<String>) {
    var items = 0

    Row(horizontalArrangement = Arrangement.SpaceBetween) {
        Column {
            for (item in myList) {
                Text("Item: $item")
                items++ // Avoid! Side-effect of the column recomposing.
            }
        }
        Text("Count: $items")
    }
}

Dans cet exemple, items est modifié à chaque recomposition. Il peut tout autant s'agir de chaque frame d'une animation que de la mise à jour de la liste. Dans tous les cas, l'interface utilisateur n'affiche pas le bon nombre. De ce fait, les écritures de ce type ne sont pas compatibles avec Compose. En interdisant ces écritures, nous permettons au framework de modifier les threads afin d'exécuter les lambdas composables.

La recomposition ignore autant de fonctions modulables et de lambdas que possible

Lorsque certaines parties de l'interface utilisateur ne sont non valides, Compose s'efforce de recomposer uniquement les portions à mettre à jour. Il peut donc ignorer la réexécution du composable d'un seul bouton sans exécuter aucun des composables au-dessus ou en dessous de celui-ci dans l'arborescence de l'UI.

Chaque fonction modulable et chaque lambda peuvent se recomposer seuls. Voici un exemple qui montre comment la recomposition peut ignorer certains éléments lors du rendu d'une liste :

/**
 * Display a list of names the user can click with a header
 */
@Composable
fun NamePicker(
    header: String,
    names: List<String>,
    onNameClicked: (String) -> Unit
) {
    Column {
        // this will recompose when [header] changes, but not when [names] changes
        Text(header, style = MaterialTheme.typography.bodyLarge)
        Divider()

        // LazyColumn is the Compose version of a RecyclerView.
        // The lambda passed to items() is similar to a RecyclerView.ViewHolder.
        LazyColumn {
            items(names) { name ->
                // When an item's [name] updates, the adapter for that item
                // will recompose. This will not recompose when [header] changes
                NamePickerItem(name, onNameClicked)
            }
        }
    }
}

/**
 * Display a single name the user can click.
 */
@Composable
private fun NamePickerItem(name: String, onClicked: (String) -> Unit) {
    Text(name, Modifier.clickable(onClick = { onClicked(name) }))
}

Chacun de ces champs d'application peut être la seule cible à exécuter lors d'une recomposition. Compose peut passer au lambda Column sans exécuter ses parents lorsque header change. Lors de l'exécution de Column, Compose peut également choisir d'ignorer les éléments de LazyColumn si l'élément names n'a pas changé.

Là aussi, l'exécution de l'ensemble des lambdas et des fonctions modulables ne doit pas avoir d'effet secondaire. Lorsque vous avez besoin d'un effet secondaire, déclenchez-le à partir d'un rappel.

La recomposition est optimiste

La recomposition commence chaque fois que Compose pense que les paramètres d'un composable ont pu changer. On dit qu'elle est optimiste, car Compose anticipe la recomposition avant que les paramètres ne changent de nouveau. Si un paramètre change avant la fin de la recomposition, Compose peut annuler la recomposition et la redémarrer avec le nouveau paramètre.

Lorsque la recomposition est annulée, Compose supprime l'arborescence d'interface utilisateur de la recomposition. En cas d'effets secondaires qui dépendent de l'interface utilisateur affichée, ils sont appliqués même si la composition est annulée. Ce comportement peut entraîner un état d'application incohérent.

Assurez-vous que l'ensemble des lambdas et des fonctions modulables sont idempotents et sans effets secondaires, afin de pouvoir gérer la recomposition optimiste.

Les fonctions modulables peuvent s'exécuter très fréquemment

Dans certains cas, une fonction modulable peut s'exécuter pour chaque frame d'une animation d'interface utilisateur. Si elle effectue des opérations coûteuses telles que la lecture depuis le stockage de l'appareil, elle peut entraîner des à-coups dans l'interface utilisateur.

Par exemple, si votre widget tente de lire les paramètres de l'appareil, il risque de lire ces paramètres des centaines de fois par seconde, avec des effets désastreux sur les performances de votre application.

Si votre fonction modulable a besoin de données, elle doit définir des paramètres correspondants. Vous pouvez ensuite transférer les tâches coûteuses vers un autre thread, en dehors de la composition, et transmettre les données à Compose à l'aide de mutableStateOf ou de LiveData.

En savoir plus

Pour en savoir plus sur votre approche de Compose et des fonctions modulables, consultez les ressources supplémentaires suivantes.

Vidéos