Écriture à l'envers

Compose exécute un frame en trois phases strictement ordonnées et séquentielles :

Trois phases de progression : 1. Composition, 2. Mise en page, 3. Dessin
Figure 1. Les trois phases d'un frame Compose
  1. Composition : exécute des fonctions @Composable pour créer et mettre à jour l'arborescence de l'UI.
  2. Mise en page : mesure les enfants, puis les place.
  3. Drawing : émet des commandes de dessin du canevas pour afficher les pixels à l'écran.

Chaque fois qu'un State Compose est lu au cours d'une phase, Compose enregistre automatiquement une dépendance entre cet état et la phase correspondante.


Qu'est-ce qu'une écriture "à l'envers" ?

Une écriture en arrière se produit chaque fois qu'un état est modifié dans une phase ultérieure (ou dans une portée en aval, c'est-à-dire une portée composable exécutée plus tard dans l'ordre au cours de la même passe de composition) que celle où il a été lu, ce qui oblige Compose à recomposer en planifiant l'exécution d'une phase ou d'un composable antérieur. Une écriture à l'envers est une boucle de recomposition non optimisée.

Exemple d'écriture différée inversée dans les phases de la boucle de recomposition
Figure 2 : Exemple d'écriture différée à travers les phases de la boucle de recomposition

Conséquences des écritures en arrière

Les écritures à l'envers ne sont pas nécessairement une mauvaise chose et ne déclenchent pas toujours des plantages, mais elles sont inefficaces et peuvent nuire aux performances de l'application de plusieurs façons :

  • Rendu de frames supplémentaires et frames abandonnés : une écriture en arrière oblige Compose à exécuter des passes de composition redondantes sur des frames consécutifs, ce qui gaspille les ressources du CPU et du GPU, et peut potentiellement provoquer des saccades.
  • Problèmes de correction de la première frame : si votre composant nécessite une écriture en arrière pour résoudre ses dimensions ou son état finaux, la première frame est rendue avec des données non valides, par défaut ou non définies (telles qu'une taille nulle ou un décalage incorrect). Cela provoque un scintillement ou un clignotement visible de la mise en page lorsque le deuxième frame s'affiche.
  • Boucles de recomposition infinies : si un changement d'état modifie la taille de la mise en page et que la taille de la mise en page réécrit en permanence une nouvelle valeur dans l'état, vous risquez de créer une boucle de frames infinie où l'écran se recompose constamment à chaque frame sans jamais se stabiliser.

Flux de transfert à travers les phases

Les changements d'état doivent toujours s'effectuer vers l'avant dans les phases :

Phase de lecture Écrire le contexte Acceptable ? Pourquoi ?
Mise en page (Modifier.offset { }) Composition Oui La composition met à jour l'état → la mise en page le lit plus tard dans le même frame sans recomposer.
Tirage (graphicsLayer { }, drawBehind { }) Composition Oui La composition met à jour l'état → Draw le lit dans la phase finale. La composition et la mise en page sont entièrement ignorées.
Match nul Disposition Oui La mise en page met à jour l'état, et le dessin le lit. Le flux est valide.
Composition Rappel d'événement (onClick, onValueChange) entraînant un changement d'état. Remarque : Les rappels de mise en page ne sont pas considérés comme des événements. Oui Un événement modifie l'état utilisé pour piloter la composition. Si l'événement se produit hors du cadre (pas dans "Composition", "Mise en page" ou "Dessin"), il est valide.
Composition Coroutine (LaunchedEffect) Oui, avec précaution Met à jour l'état de manière asynchrone en réponse au cycle de vie/aux événements. Les écritures à partir d'effets peuvent être valides, mais peuvent indiquer une superposition d'états inefficace. Elles doivent être évitées autant que possible.
Emplacement (dans la mise en page) Mesurer (dans la mise en page) Oui Dans Layout, il est acceptable de mettre à jour l'état, puis de le lire dans l'emplacement.
Mesurer (dans la mise en page) Emplacement (dans la mise en page) Non : écriture à l'envers L'écriture dans l'état de placement, qui est ensuite lue dans l'état de lecture, entraîne une boucle de remesure.
Composition Disposition (onSizeChanged, LayoutModifier) Non : Écrire à l'envers La mise en page invalide la composition → boucle de recomposition.
Composition Tirage (drawWithContent, Canvas) Non : écriture à l'envers Draw invalide la boucle Composition → Recomposition.

Combinaisons de phases : vers l'arrière ou vers l'avant

Vous trouverez ci-dessous des exemples d'écritures en arrière dans Compose et comment les résoudre.

Inversé : lecture dans Composition, écriture dans Mise en page

  • Ce qui se passe : la composition lit componentHeight pour déterminer l'UI à émettre. Plus tard dans le frame, la phase de mise en page mesure ou place les vues et écrit une nouvelle valeur dans componentHeight (par exemple, à l'aide de onSizeChanged, onGloballyPositioned ou LayoutModifier personnalisé).
  • Résultat : la modification de componentHeight dans la mise en page invalide la phase de composition qui vient d'être effectuée. Notez que onSizeChanged indique la taille une fois la passe de mesure de la mise en page terminée. Si la valeur d'état mise à jour se stabilise au prochain passage, la recomposition peut s'arrêter après un frame supplémentaire. Toutefois, si la nouvelle valeur continue de modifier la taille, cela entraîne une boucle de frames infinie. De plus, onGloballyPositioned s'exécute après la mise en page et le placement, ce qui rend les écritures d'état à l'intérieur encore plus susceptibles de boucles de recomposition et de réorganisation continues sur plusieurs frames consécutifs.

// ❌ BAD: Read in Composition, Written in Layout (onSizeChanged)
@Composable
fun BadAspectRatioImage(painter: Painter) {
    var calculatedHeight by remember { mutableStateOf(0.dp) }
    val density = LocalDensity.current

    // State read during COMPOSITION:
    Image(
        painter = painter,
        contentDescription = "Dynamic Image",
        modifier = Modifier
            .fillMaxWidth()
            .height(calculatedHeight)
            .onSizeChanged { size ->
                // State write during LAYOUT phase!
                // Triggers backwards write and recomposition pass
                val aspectRatio = 16f / 9f
                val widthDp = with(density) { size.width.toDp() }
                calculatedHeight = widthDp / aspectRatio
            }
    )
}

// ✅ GOOD: Measure and calculate aspect ratio height in Phase 2 (Layout) without recomposition
@Composable
fun GoodAspectRatioImage(
    painter: Painter,
    aspectRatio: Float = 16f / 9f,
    modifier: Modifier = Modifier
) {
    Layout(
        content = {
            Image(
                painter = painter,
                contentDescription = "Dynamic Image"
            )
        },
        modifier = modifier
    ) { measurables, constraints ->
        val width = constraints.maxWidth
        val height = (width / aspectRatio).toInt() // Illustrative, you can use Modifier.aspectRatio()
        val imageConstraints = constraints.copy(
            minWidth = width,
            maxWidth = width,
            minHeight = height,
            maxHeight = height
        )
        val placeable = measurables.first().measure(imageConstraints)
        layout(width, height) {
            placeable.placeRelative(0, 0)
        }
    }
}

Inversé : lecture dans Composition, écriture dans Dessin

  • Ce qui se passe : l'état est lu dans le corps composable (phase de composition), mais muté à l'intérieur de Modifier.drawWithContent, Modifier.drawBehind ou Canvas (phase de dessin).
  • Résultat : la phase de dessin modifie l'état → la composition est invalidée → boucle sans fin.

// ❌ BAD: Read in Composition, Written in Draw ()
@Composable
fun BadBackwardsWriteDraw() {
    var componentHeight by remember { mutableStateOf(0.dp) }
    // State read during COMPOSITION:
    Text(
        text = "Height is: $componentHeight",
        modifier = Modifier.drawBehind {
            // State write during the DRAW phase!
            // Invalidates Composition -> triggers recomposition loop!
            componentHeight = size.height.dp
        }
    )
}

En arrière : lire dans la phase de composition, écrire dans la phase de composition (même phase)

  • Ce qui se passe : lecture de count dans la fonction composable et modification de count directement dans l'emplacement de contenu d'un autre composable après la lecture.
  • Résultat : Le système d'instantanés enregistre la lecture et l'écriture ultérieure dans la même passe de composition, ce qui invalide immédiatement le champ d'application actuel.

// ❌ BAD: Direct write in Composable body after read
@Composable
fun BadCounter() {
    var count by remember { mutableIntStateOf(0) }
    Text("Count: $count") // State read in Composition
    Button(onClick = {}) {
        count++ // State write in Composition (Backwards write!)
    }
}
// Acceptable - but error-prone as someone may add a read before the write : Direct write in Composable body before read
@Composable
fun OkCounter() {
    var count by remember { mutableIntStateOf(0) }
    Button(onClick = {}) {
        count++ // State  write in Composition
    }
    Text("Count: $count") // State read in Composition
}


Règles clés pour éviter les rétroécritures

  1. N'écrivez pas dans l'état dans onGloballyPositioned, onSizeChanged ou LayoutModifier si cet état est lu dans Composition, car cela entraîne le problème de correction de la première frame.
    • Si les coordonnées ou les tailles de mise en page ne sont nécessaires que pour le dessin personnalisé, lisez-les directement dans la phase de dessin ou de mise en page (par exemple, à l'aide de Modifier.drawWithCache ou Modifier.layout).
    • Pour le dimensionnement au niveau de la fenêtre (WindowWidthSizeClass) : hissez la taille de la fenêtre au niveau de la fenêtre. La composition se ramifie en fonction des classes de taille de fenêtre avant la mesure locale.
    • Conserver une composition uniforme : utilisez une seule mise en page personnalisée ou des composants tels que FlowRow ou LazyVerticalGrid qui ajustent la mesure et le placement pendant la phase 2 sans recomposer ni modifier l'état utilisé dans la composition.
    • Utiliser la sous-composition : utilisez BoxWithConstraints ou SubcomposeLayout lorsque les composables enfants doivent se ramifier en fonction de la largeur ou de la hauteur locales. Attention : La sous-composition a un coût en termes de performances et peut généralement être évitée.
    • En dernier recours : autorisez le premier frame à être incorrect, en stockant la taille dans onSizeChanged pour déclencher une deuxième recomposition du frame. Cela provoque des sauts de mise en page visibles, des saccades et des risques de boucles infinies.
  2. Ne modifiez pas l'état après sa première lecture dans la composition :
    • Bien que vous puissiez écrire en toute sécurité dans des objets MutableState lors de la composition en dehors d'un SideEffect, veillez tout particulièrement à ne pas écrire dans un état que vous avez peut-être lu précédemment dans la composition. Il est recommandé d'utiliser rememberUpdatedState lorsque ce besoin d'écriture d'état dans la composition se présente. Écrire dans un état pendant la composition d'une autre manière est généralement le signe d'un effet manquant ou d'un état ou d'un composable mal conçus. N'oubliez pas que la composition est optimiste et s'exécute toujours avec la dernière valeur d'un état. Il est donc possible que vous ne voyiez pas tous les changements d'état dans les recompositions. Les mises à jour de l'UI ne doivent pas être utilisées pour gérer des événements ponctuels. Il est donc rare que la valeur d'un état doive être mise à jour à la suite d'une recomposition.
    • Évitez de modifier les états observés en dehors de la composition (par exemple, les champs ViewModel ou les indicateurs isVisible). Les écritures d'état qui affectent la composition appartiennent aux lambdas d'événement (onClick), aux coroutines (LaunchedEffect) ou aux effets secondaires (SideEffect). rememberUpdatedState est une exception, car il est conçu pour modifier l'état qui n'est utilisé que dans le corps @Composable.
  3. Différez les lectures d'état jusqu'à la dernière phase possible :
    • La lecture des états dans Draw (Modifier.graphicsLayer { alpha = ... }) ou Layout (Modifier.offset { IntOffset(...) }) garantit que les modifications n'invalident que les phases 2 ou 3, en ignorant complètement la phase 1 (Composition).