Phases de Jetpack Compose

Comme la plupart des autres kits d'outils de l'interface utilisateur, Compose affiche un frame à l'aide de plusieurs phases distinctes. Par exemple, le système de vues Android comporte trois phases principales : la mesure, la mise en page et le dessin. Compose est très similaire, mais inclut une phase supplémentaire clé appelée composition au début.

La documentation Compose décrit la composition dans Raisonnement dans Compose et États et Jetpack Compose.

Les trois phases d'un frame

Compose compte trois phases principales :

  1. Composition : quels éléments d'interface utilisateur afficher. Compose exécute des fonctions modulables et crée une description de votre UI.
  2. Mise en page : positionner les éléments d'interface utilisateur. Cette phase comprend deux étapes : la mesure et le positionnement. Les éléments de mise en page se mesurent et se placent eux-mêmes ainsi que tous les éléments enfants en coordonnées 2D, pour chaque nœud de l'arborescence de mise en page.
  3. Dessin : comment effectuer le rendu de l'interface utilisateur. Les éléments de l'interface utilisateur apparaissent dans un canevas, habituellement l'écran d'un d'appareil.
Les trois phases dans lesquelles Compose transforme les données en UI (dans l'ordre : données, composition, mise en page, dessin, UI).
Figure 1. Les trois phases dans lesquelles Compose transforme les données en UI.

L'ordre de ces phases est généralement le même, ce qui permet aux données de circuler dans un sens (de la composition à la mise en page, en passant par le dessin) afin de générer un frame (également appelé flux de données unidirectionnel). BoxWithConstraints, LazyColumn et LazyRow constituent des exceptions notables, où la composition des enfants dépend de la phase de mise en page du parent.

Conceptuellement, chacune de ces phases se produit pour chaque frame. Toutefois, pour optimiser les performances, Compose évite de répéter une tâche qui calculerait les mêmes résultats à partir des mêmes entrées dans toutes ces phases. Compose ignore l'exécution d'une fonction composable s'il peut réutiliser un ancien résultat, et l'UI Compose ne recommence pas la mise en page ni le dessin de toute l'arborescence s'il n'y a pas lieu de le faire. Il ne fournit que les efforts minimums nécessaires pour mettre à jour l'UI. Cette optimisation est possible, car Compose suit les lectures d'état au cours des différentes phases.

Comprendre les phases

Cette section décrit plus en détail comment les trois phases de Compose sont exécutées pour les composables.

Composition

Lors de la phase de composition, le runtime Compose exécute des fonctions composables et génère une structure arborescente qui représente votre UI. Cet arbre UI se compose de nœuds de mise en page qui contiennent toutes les informations nécessaires pour les phases suivantes, comme le montre la vidéo suivante :

Figure 2. Arborescence représentant votre UI, créée lors de la phase de composition.

Une sous-section de l'arborescence du code et de l'UI se présente comme suit :

Extrait de code avec cinq composables et l'arborescence de l'UI résultante, avec des nœuds enfants qui se ramifient à partir de leurs nœuds parents.
Figure 3. Sous-section d'un arbre d'UI avec le code correspondant.

Dans ces exemples, chaque fonction composable du code correspond à un seul nœud de mise en page dans l'arborescence de l'UI. Dans des exemples plus complexes, les composables peuvent contenir une logique et un flux de contrôle, et produire un arbre différent selon les états.

Mise en page

Dans la phase de mise en page, Compose utilise l'arborescence de l'UI produite lors de la phase de composition comme entrée. La collection de nœuds de mise en page contient toutes les informations nécessaires pour déterminer la taille et l'emplacement de chaque nœud dans l'espace 2D.

Figure 4. Mesure et emplacement de chaque nœud de mise en page dans l'arborescence de l'UI pendant la phase de mise en page.

Pendant la phase de mise en page, l'arborescence est parcourue à l'aide de l'algorithme en trois étapes suivant :

  1. Mesurer les enfants : un nœud mesure ses enfants, le cas échéant.
  2. Déterminer sa propre taille : sur la base de ces mesures, un nœud détermine sa propre taille.
  3. Placer les enfants : chaque nœud enfant est placé par rapport à la position du nœud.

À la fin de cette phase, chaque nœud de mise en page possède les éléments suivants :

  • Largeur et hauteur attribuées
  • Coordonnées X et Y où il doit être dessiné

Rappelons l'arborescence de l'UI de la section précédente :

Extrait de code avec cinq composables et l'arborescence de l'UI résultante, avec des nœuds enfants qui se ramifient à partir de leurs nœuds parents

Pour cet arbre, l'algorithme fonctionne comme suit :

  1. Row mesure ses enfants, Image et Column.
  2. La Image est mesurée. Il n'a pas d'enfants. Il décide donc de sa propre taille et la renvoie à Row.
  3. La Column est ensuite mesurée. Il mesure d'abord ses propres enfants (deux composables Text).
  4. La première Text est mesurée. Il n'a pas d'enfants, il décide donc de sa propre taille et la renvoie à Column.
    1. La deuxième Text est mesurée. Il n'a pas d'enfants, il décide donc de sa propre taille et la renvoie à Column.
  5. Le Column utilise les mesures des enfants pour déterminer sa propre taille. Il utilise la largeur maximale des enfants et la somme de la hauteur de ses enfants.
  6. Column place ses enfants par rapport à lui-même, en les mettant les uns en dessous des autres verticalement.
  7. Le Row utilise les mesures des enfants pour déterminer sa propre taille. Il utilise la hauteur maximale des enfants et la somme de leurs largeurs. Il place ensuite ses enfants.

Notez que chaque nœud n'a été visité qu'une seule fois. Le runtime Compose ne nécessite qu'un seul passage dans l'arborescence de l'UI pour mesurer et placer tous les nœuds, ce qui améliore les performances. Lorsque le nombre de nœuds dans l'arborescence augmente, le temps passé à la parcourir augmente de manière linéaire. En revanche, si chaque nœud est visité plusieurs fois, le temps de parcours augmente de manière exponentielle.

Dessin

Lors de la phase de dessin, l'arborescence est à nouveau parcourue de haut en bas, et chaque nœud se dessine à son tour sur l'écran.

Figure 5. La phase de dessin dessine les pixels à l'écran.

En reprenant l'exemple précédent, le contenu de l'arborescence est dessiné de la manière suivante :

  1. Row dessine tout contenu qu'il peut avoir, comme une couleur d'arrière-plan.
  2. Le Image se dessine.
  3. Le Column se dessine.
  4. Les premier et deuxième Text se dessinent respectivement.

Figure 6. Arborescence de l'UI et sa représentation dessinée.

Lectures d'état

Lorsque vous lisez le value d'un snapshot state au cours de l'une des phases listées précédemment, Compose suit automatiquement les actions effectuées lors de la lecture du value. Ce suivi permet à Compose de réexécuter le lecteur lorsque le value de l'état change. Il constitue la base de l'observabilité de l'état dans Compose.

Vous créez généralement un état à l'aide de mutableStateOf(), puis vous y accédez de l'une des deux manières suivantes : directement via la propriété value ou via un délégué de propriété Kotlin. Pour en savoir plus à ce sujet, consultez la section État dans les composables. Pour les besoins de ce guide, une "lecture d'état" fait référence à l'une ou l'autre de ces méthodes d'accès équivalentes.

// State read without property delegate.
val paddingState: MutableState<Dp> = remember { mutableStateOf(8.dp) }
Text(
    text = "Hello",
    modifier = Modifier.padding(paddingState.value)
)

// State read with property delegate.
var padding: Dp by remember { mutableStateOf(8.dp) }
Text(
    text = "Hello",
    modifier = Modifier.padding(padding)
)

En arrière-plan du délégué de propriété, les fonctions "getter" et "setter" permettent d'accéder à la valeur (value) de l'état et de la mettre à jour. Ces fonctions getter et setter ne sont appelées que lorsque vous référencez la propriété en tant que valeur, et non lorsqu'elle est créée. C'est pourquoi les deux méthodes décrites précédemment sont équivalentes.

Chaque bloc de code pouvant être réexécuté lorsqu'un état de lecture est modifié correspond à un champ d'application de redémarrage. Compose effectue le suivi des changements d'état value et redémarre les champs d'application en plusieurs phases.

Lectures d'état par phases

Comme indiqué précédemment, Compose se déroule en trois phases principales, lesquelles permettent de suivre les états lus dans chacune d'elles. Cela permet à Compose de notifier uniquement les phases spécifiques pour lesquelles des tâches sont nécessaires pour chaque élément d'UI concerné.

Les sections suivantes décrivent chaque phase et ce qui se passe lorsqu'une valeur d'état y est lue.

Phase 1 : Composition

Les lectures d'état dans une fonction @Composable ou un bloc lambda concernent la composition et potentiellement les phases suivantes. Lorsque la valeur value de l'état change, le recomposeur programme de nouveau l'exécution de toutes les fonctions composables qui lisent la valeur value de cet état. Notez que l'environnement d'exécution peut ignorer une partie ou la totalité des fonctions modulables si les entrées n'ont pas changé. Pour en savoir plus, consultez la section Ignorer si les entrées n'ont pas changé.

Selon le résultat de la composition, l'interface utilisateur de Compose exécute les phases de mise en page et de dessin. Elle peut ignorer ces phases si le contenu reste le même, et que la taille et la mise en page ne changent pas.

var padding by remember { mutableStateOf(8.dp) }
Text(
    text = "Hello",
    // The `padding` state is read in the composition phase
    // when the modifier is constructed.
    // Changes in `padding` will invoke recomposition.
    modifier = Modifier.padding(padding)
)

Phase 2 : Mise en page

La phase de mise en page comprend deux étapes : la mesure et le positionnement. L'étape de mesure exécute le lambda de mesure transmis au composable Layout, à la méthode MeasureScope.measure de l'interface LayoutModifier, entre autres. L'étape de placement exécute le bloc de placement de la fonction layout, le bloc lambda de Modifier.offset { … } et des fonctions similaires.

Les lectures d'état lors de chacune de ces étapes concernent la mise en page et, potentiellement, la phase de dessin. Lorsque l'état value change, l'interface utilisateur de Compose planifie la phase de mise en page. Elle exécute également la phase de dessin si la taille ou la position a changé.

var offsetX by remember { mutableStateOf(8.dp) }
Text(
    text = "Hello",
    modifier = Modifier.offset {
        // The `offsetX` state is read in the placement step
        // of the layout phase when the offset is calculated.
        // Changes in `offsetX` restart the layout.
        IntOffset(offsetX.roundToPx(), 0)
    }
)

Phase 3 : Dessin

Les lectures d'état pendant le dessin concernent la phase de dessin. Canvas(), Modifier.drawBehind et Modifier.drawWithContent sont des exemples courants. Lorsque la valeur value de l'état change, l'interface utilisateur de Compose n'exécute que la phase de dessin.

var color by remember { mutableStateOf(Color.Red) }
Canvas(modifier = modifier) {
    // The `color` state is read in the drawing phase
    // when the canvas is rendered.
    // Changes in `color` restart the drawing.
    drawRect(color)
}

Schéma montrant qu&#39;une lecture d&#39;état pendant la phase de dessin ne déclenche que la réexécution de la phase de dessin.

Optimiser les lectures d'état

Comme Compose effectue le suivi des lectures d'état localisé, vous pouvez réduire la quantité de travail effectuée en lisant chaque état dans une phase appropriée.

Prenons l'exemple suivant : Cet exemple comporte un Image() qui utilise le modificateur de décalage pour décaler sa position finale de mise en page, ce qui entraîne un effet de parallaxe lorsque l'utilisateur fait défiler l'écran.

Box {
    val listState = rememberLazyListState()

    Image(
        // ...
        // Non-optimal implementation!
        Modifier.offset(
            with(LocalDensity.current) {
                // State read of firstVisibleItemScrollOffset in composition
                (listState.firstVisibleItemScrollOffset / 2).toDp()
            }
        )
    )

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

Ce code fonctionne, mais génère des performances non optimales. Il lit le value de l'état firstVisibleItemScrollOffset et le transmet à la fonction Modifier.offset(offset: Dp). À mesure que l'utilisateur fait défiler l'écran, la valeur value de firstVisibleItemScrollOffset change. Comme vous l'avez appris, Compose suit toutes les lectures d'état afin de pouvoir relancer (rappeler) le code de lecture qui, dans cet exemple, correspond au contenu de Box.

Il s'agit d'un exemple de lecture d'un état pendant la phase de composition. Ce n'est pas nécessairement une mauvaise chose. En réalité, c'est là la base de la recomposition : permettre les modifications de données pour émettre une nouvelle UI.

Point clé : Cet exemple n'est pas optimal, car chaque événement de défilement entraîne la réévaluation, la mesure, la mise en page et le dessin de l'intégralité du contenu composable. La phase Compose est déclenchée à chaque défilement, même si le contenu affiché ne change pas. Seule sa position change. Vous pouvez optimiser la lecture de l'état pour ne déclencher que la phase de mise en page.

Décalage avec lambda

Une autre version du modificateur de décalage est disponible : Modifier.offset(offset: Density.() -> IntOffset).

Cette version utilise un paramètre lambda, où le décalage obtenu est renvoyé par le bloc lambda. Mettez à jour le code pour l'utiliser :

Box {
    val listState = rememberLazyListState()

    Image(
        // ...
        Modifier.offset {
            // State read of firstVisibleItemScrollOffset in Layout
            IntOffset(x = 0, y = listState.firstVisibleItemScrollOffset / 2)
        }
    )

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

Pourquoi est-il plus performant ? Le bloc lambda que vous fournissez au modificateur est appelé pendant la phase de mise en page (plus spécifiquement lors de l'étape de positionnement pendant la phase de mise en page). Autrement dit, l'état firstVisibleItemScrollOffset n'est plus lu pendant la composition. Étant donné que Compose suit la lecture de l'état, cette modification signifie que si la valeur value de firstVisibleItemScrollOffset change, Compose doit uniquement redémarrer les phases de mise en page et de dessin.

Certes, il est souvent absolument nécessaire de lire les états dans la phase de composition. Toutefois, il reste possible de réduire le nombre de recompositions à leur minimum en filtrant les changements d'état. Pour en savoir plus à ce sujet, consultez derivedStateOf : convertir un ou plusieurs objets d'état en un autre état.

Recomposition en boucle (dépendance de phase cyclique)

Ce guide mentionnait précédemment que les phases de Compose sont toujours appelées dans le même ordre et qu'il n'existe aucun moyen de revenir en arrière dans le même frame. Toutefois, cela n'empêche pas les applications de réaliser des compositions en boucle sur différents frames. Prenons l'exemple suivant :

Box {
    var imageHeightPx by remember { mutableStateOf(0) }

    Image(
        painter = painterResource(R.drawable.rectangle),
        contentDescription = "I'm above the text",
        modifier = Modifier
            .fillMaxWidth()
            .onSizeChanged { size ->
                // Don't do this
                imageHeightPx = size.height
            }
    )

    Text(
        text = "I'm below the image",
        modifier = Modifier.padding(
            top = with(LocalDensity.current) { imageHeightPx.toDp() }
        )
    )
}

Cet exemple implémente une colonne verticale, avec l'image en haut, puis le texte en dessous. Il utilise Modifier.onSizeChanged() pour obtenir la taille résolue de l'image, puis utilise Modifier.padding() au niveau du texte pour le décaler vers le bas. La conversion non naturelle de Px en Dp indique déjà que le code présente un problème.

Le problème avec cet exemple est que le code ne parvient pas à la mise en page "finale" dans un seul frame. Le code repose sur l'affichage de plusieurs frames, ce qui entraîne un travail inutile et l'instabilité de l'interface utilisateur à l'écran.

Composition de la première image

Lors de la phase de composition du premier frame, imageHeightPx est initialement 0. Par conséquent, le code fournit le texte avec Modifier.padding(top = 0). La phase de mise en page suivante appelle le rappel du modificateur onSizeChanged, qui met à jour imageHeightPx avec la hauteur réelle de l'image. Compose planifie ensuite une recomposition pour le frame suivant. Toutefois, au cours de la phase de dessin actuelle, le texte est affiché avec une marge intérieure de 0, car la valeur imageHeightPx mise à jour n'est pas encore reflétée.

Composition de la deuxième image

Compose lance le deuxième frame, déclenché par le changement de valeur de imageHeightPx. Dans la phase de composition de ce frame, l'état est lu dans le bloc de contenu Box. Le texte est désormais fourni avec une marge intérieure qui correspond précisément à la hauteur de l'image. Lors de la phase de mise en page, imageHeightPx est à nouveau défini. Toutefois, aucune recomposition supplémentaire n'est planifiée, car la valeur reste cohérente.

Diagramme montrant une boucle de recomposition dans laquelle un changement de taille lors de la phase de mise en page déclenche une recomposition, qui provoque ensuite une nouvelle mise en page.

Cet exemple peut sembler artificiel, mais faites attention à ce schéma général :

  • Modifier.onSizeChanged(), onGloballyPositioned() ou autres opérations de mise en page
  • Mise à jour d'un état
  • Utilisez cet état comme entrée d'un modificateur de mise en page (padding(), height() ou similaire).
  • Répétition potentielle

Pour résoudre le problème ci-dessus, la solution consiste à utiliser les primitives de mise en page appropriées. L'exemple précédent peut être implémenté avec un Column(), mais vous pouvez disposer d'un exemple plus complexe nécessitant une personnalisation et, par conséquent, une mise en page personnalisée. Pour en savoir plus, consultez le guide Mises en page personnalisées.

Le principe général ici est d'avoir une source unique et fiable pour tous les éléments d'interface utilisateur qui doivent être mesurés et placés les uns par rapport aux autres. Si vous utilisez une primitive de mise en page appropriée ou si vous créez une mise en page personnalisée, le parent partagé minimal sert de source fiable pouvant coordonner la relation entre divers éléments. L'introduction d'un état dynamique enfreint ce principe.