Écriture à l'envers

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

Trois phases successives : 1. Composition, 2. Mise en page, 3. Dessin
Figure 1. Les trois phases d'un frame Compose
  1. Composition : exécute les fonctions @Composable pour créer et mettre à jour l'arborescence de l'UI.
  2. Mise en page : mesure les enfants, puis les positionne.
  3. Dessin : émet des commandes de dessin de 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 qui rend une écriture "rétrograde" ?

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

Exemple d'écriture inversée dans les phases de la boucle de recomposition
Figure 2 : Exemple d'écriture inversée via les phases de la boucle de recomposition

Conséquences des rétroécritures

Les écritures inversées 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 manières :

  • Rendu d'images supplémentaires et images supprimées : une écriture rétrograde force Compose à exécuter des passes de composition redondantes sur des images consécutives, ce qui gaspille les ressources du processeur et du GPU, et peut entraîner des à-coups.
  • Problèmes d'exactitude de la première image : si votre composant nécessite une écriture rétroactive pour résoudre ses dimensions ou son état finaux, la première image est affichée avec des données non valides, par défaut ou non définies (par exemple, une taille nulle ou un décalage incorrect). Cela entraîne un pop-up visuel visible ou un clignotement de la mise en page lors de l'affichage de la deuxième image.
  • 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 continuellement une nouvelle valeur dans l'état, vous risquez de créer une boucle de frames infinie dans laquelle l'écran se recompose constamment à chaque frame sans jamais se stabiliser.

Flux de transfert entre les phases

Les changements d'état doivent toujours se faire dans l'ordre des phases :

Phase de lecture Write Context 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 recomposition.
Match nul (graphicsLayer { }, drawBehind { }) Composition Oui La composition met à jour l'état → Draw le lit lors de la phase finale. La composition et la mise en page sont complètement ignorées.
Match nul Disposition Oui La mise en page met à jour l'état, et le dessin le lit, ce qui est un flux valide.
Composition Rappel d'événement (onClick, onValueChange) entraînant un changement d'état. Remarque : Les rappels de mise en page ne sont pas comptabilisé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 frame (pas dans Composition, Layout ou Draw), cela est valide.
Composition Coroutine (LaunchedEffect) Oui, avec prudence 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. Cela doit être évité autant que possible.
Emplacement (dans la mise en page) Mesure (dans la mise en page) Oui Dans Layout, il est acceptable de mettre à jour l'état, puis de le lire dans le placement.
Mesurer (dans la mise en page) Emplacement (dans la mise en page) Non, écriture inversée L'écriture dans l'état de placement, qui est ensuite lue dans l'état de lecture, entraîne une boucle de remesure.
Composition Mise en page (onSizeChanged, LayoutModifier) Non : écriture à l'envers La mise en page invalide la boucle Composition → Recomposition.
Composition Match nul (drawWithContent, Canvas) Non : écriture inversée Le dessin invalide la boucle Composition → Recomposition.

Combinaisons de phases : arrière et avant

Voici des exemples d'écritures inversées dans Compose et comment les résoudre.

En arrière : lecture dans Composition, écriture dans Layout

  • Que se passe-t-il ? La composition lit componentHeight pour déterminer l'UI à émettre. Plus tard dans l'image, 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 d'un LayoutModifier personnalisé).
  • Résultat : la modification de componentHeight dans la phase de mise en page invalide la phase de composition qui vient de se terminer. Notez que onSizeChanged indique la taille une fois la mesure de la mise en page terminée. Si la valeur d'état mise à jour se stabilise lors du passage suivant, la recomposition peut s'arrêter après une 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 l'emplacement, ce qui rend les écritures d'état encore plus susceptibles de provoquer des boucles de recomposition et de mise en page continues sur des 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)
        }
    }
}

En arrière : lecture dans la composition, écriture dans le dessin

  • Que se passe-t-il ? L'état est lu dans le corps du composable (phase de composition), mais modifié dans Modifier.drawWithContent, Modifier.drawBehind ou Canvas (phase de dessin).
  • Résultat : la phase de dessin modifie l'état → Composition 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
        }
    )
}

Rétrograde : lecture dans la composition, écriture dans la composition (même phase)

  • Que se passe-t-il : lecture de count dans la fonction composable et modification de count directement dans l'emplacement de contenu d'un autre composable après l'avoir lu.
  • Résultat : le système d'instantanés enregistre la lecture et l'écriture suivante 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, onPlaced, onLayoutRectChanged ou LayoutModifier si cet état est lu dans la composition, car cela entraîne un problème d'exactitude de la première image.
    • Si les coordonnées ou les tailles de mise en page ne sont nécessaires que pour un 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 l'observation de la taille au niveau de la fenêtre. La composition se divise en classes de taille de fenêtre avant que la mesure locale ne se produise.
    • Maintenez l'uniformité de la composition : utilisez une seule mise en page personnalisée ou des composants tels que FlowRow ou LazyVerticalGrid qui ajustent la mesure et l'emplacement lors de 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 locale. À utiliser avec précaution : la sous-composition entraîne un coût en termes de performances et peut généralement être évitée.
    • En dernier recours : autorisez la première image à être incorrecte, en stockant la taille dans onSizeChanged pour déclencher une deuxième recomposition d'image. Cela entraîne des problèmes de mise en page visibles, des à-coups 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 pendant la composition en dehors d'un SideEffect, veillez à ne pas écrire dans un état que vous avez peut-être déjà lu dans la composition. Il est recommandé d'utiliser rememberUpdatedState lorsque vous devez écrire un état dans la composition. É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çu. 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 de @Composable.
  3. Différez les lectures d'état à 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 la phase 2 ou 3, en ignorant complètement la phase 1 (Composition).