Rückwärtsschreiben

Compose führt einen Frame in drei streng geordneten, vorwärts gerichteten Phasen aus:

Drei Phasen: 1. Komposition, 2. Layout, 3. Zeichnung
Abbildung 1. Die drei Phasen eines Compose-Frames
  1. Zusammensetzung: Führt @Composable-Funktionen aus, um den UI-Baum zu erstellen und zu aktualisieren.
  2. Layout: Die untergeordneten Elemente werden gemessen und dann platziert.
  3. Zeichnen: Gibt Befehle zum Zeichnen auf der Zeichenfläche aus, um Pixel auf dem Bildschirm zu rendern.

Immer wenn ein Compose-State in einer Phase gelesen wird, wird automatisch eine Abhängigkeit zwischen diesem Status und der entsprechenden Phase aufgezeichnet.


Was macht einen Schreibvorgang „rückwärts“?

Ein Rückwärtsschreiben tritt immer dann auf, wenn ein Status in einer späteren Phase geändert wird (oder in einem Downstream-Bereich, d. h. einem zusammensetzbaren Bereich, der später im selben Kompositionsdurchlauf ausgeführt wird) als dort, wo er gelesen wurde. Dadurch wird Compose gezwungen, die Komposition neu zu erstellen, indem eine frühere Phase oder ein früheres Composable neu geplant wird. Ein Rückwärtsschreiben ist eine nicht optimierte Neuzusammensetzungsschleife.

Beispiel für das Rückwärtsschreiben durch die Phasen des Recomposition-Zyklus
Abbildung 2. Beispiel für das Rückwärtsschreiben durch die Phasen des Recomposition-Loops

Folgen von Rückwärtsschreibvorgängen

Rückwärtsschreiben sind nicht unbedingt schlecht und führen nicht immer zu Abstürzen. Sie sind jedoch ineffizient und können die App-Leistung auf verschiedene Weise beeinträchtigen:

  • Zusätzliches Rendern von Frames und verworfene Frames: Durch einen Rückwärtsschreibvorgang werden in Compose redundante Kompositionsdurchläufe für aufeinanderfolgende Frames ausgeführt. Dadurch werden CPU- und GPU-Ressourcen verschwendet und es kann zu Rucklern kommen.
  • Probleme mit dem ersten Frame: Wenn für die Komponente ein Rückwärtsschreiben erforderlich ist, um die endgültigen Abmessungen oder den endgültigen Status zu ermitteln, wird der erste Frame mit ungültigen, standardmäßigen oder nicht festgelegten Daten gerendert, z. B. mit der Größe 0 oder einem falschen Offset. Dies führt zu sichtbaren visuellen Pop-ups oder Layout-Blinken, wenn der zweite Frame gerendert wird.
  • Endlosschleifen bei der Neuzusammenstellung: Wenn eine Zustandsänderung die Layoutgröße ändert und die Layoutgröße kontinuierlich einen neuen Wert in den Zustand zurückschreibt, kann es zu einer Endlosschleife kommen, in der der Bildschirm bei jedem Frame neu zusammengestellt wird, ohne jemals stabil zu werden.

Vorwärts durch Phasen fließen

Statusänderungen sollten immer vorwärts durch die Phasen fließen:

Lesen Kontext schreiben Zulässig? Warum
Layout (Modifier.offset { }) Zusammensetzung Ja Durch die Komposition wird der Status aktualisiert → Das Layout liest ihn später im selben Frame, ohne dass eine neue Komposition erfolgt.
Ziehung (graphicsLayer { }, drawBehind { }) Zusammensetzung Ja Bei der Komposition wird der Status aktualisiert → „Draw“ liest ihn in der letzten Phase. Bildaufbau und Layout werden vollständig übersprungen.
Ziehung Layout Ja Das Layout aktualisiert den Status und „draw“ liest ihn.
Zusammensetzung Event-Callback (onClick, onValueChange) löst Änderung des Fahrstatus aus. Hinweis: Layout-Callbacks werden nicht als Ereignisse gezählt. Ja Ein Ereignis ändert den Status, der für die Komposition verwendet wird. Wenn das Ereignis außerhalb des Bildausschnitts stattfindet (nicht in „Komposition“, „Layout“ oder „Zeichnen“), ist das gültig.
Zusammensetzung Koroutine (LaunchedEffect) Ja – mit Vorsicht Aktualisiert den Status asynchron als Reaktion auf Lebenszyklus-/Ereignisse. Schreibvorgänge von Effekten können gültig sein, weisen aber möglicherweise auf eine ineffiziente Statusverschachtelung hin. Sie sollten nach Möglichkeit vermieden werden.
Placement (In Layout) Messen (im Layout) Ja Im Layout ist es zulässig, den Status zu aktualisieren und dann im Placement zu lesen.
Messen (im Layout) Placement (In Layout) Nein – Rückwärtsschreiben Wenn Sie in einem Placement in den Status schreiben, der sich dann im Lesestatus befindet, führt das zu einer erneuten Messung.
Zusammensetzung Layout (onSizeChanged, LayoutModifier) Nein – Rückwärts schreiben Das Layout macht die Komposition ungültig → Schleife für die Neukomposition.
Zusammensetzung Ziehung (drawWithContent, Canvas) Nein – Rückwärtsschreiben Durch „Draw“ wird die Komposition ungültig → Recomposition-Schleife.

Phasenkombinationen: rückwärts vs. vorwärts

Im Folgenden finden Sie Beispiele für Rückschreibevorgänge in Compose und wie Sie sie beheben können.

Rückwärts: Lesen in Composition, Schreiben in Layout

  • Was passiert?: Bei der Komposition wird componentHeight gelesen, um zu ermitteln, welche Benutzeroberfläche ausgegeben werden soll. Später im Frame werden in der Layout-Phase Ansichten gemessen oder platziert und ein neuer Wert in componentHeight geschrieben (z. B. mit onSizeChanged, onGloballyPositioned oder benutzerdefinierten LayoutModifier).
  • Ergebnis: Wenn Sie componentHeight im Layout ändern, wird die gerade abgeschlossene Kompositionsphase ungültig. Die Größe wird in onSizeChanged erst nach Abschluss der Layoutmessung angegeben. Wenn sich der aktualisierte Statuswert beim nächsten Durchlauf stabilisiert, kann die Neukomposition nach einem zusätzlichen Frame beendet werden. Wenn sich die Größe jedoch durch den neuen Wert weiter ändert, entsteht eine Endlosschleife. Außerdem wird onGloballyPositioned nach Layout und Platzierung ausgeführt, wodurch Status-Schreibvorgänge darin noch anfälliger für kontinuierliche Recomposition- und Relayout-Schleifen über aufeinanderfolgende Frames hinweg sind.

// ❌ 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)
        }
    }
}

Rückwärts: Lesen in „Komposition“, Schreiben in „Zeichnung“

  • Was passiert: Der Status wird im Composable-Body (Kompositionsphase) gelesen, aber in Modifier.drawWithContent, Modifier.drawBehind oder Canvas (Zeichnungsphase) geändert.
  • Ergebnis: Die Draw-Phase ändert den Status → die Komposition wird ungültig → Endlosschleife.

// ❌ 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ückwärts: Lesen in Composition, Schreiben in Composition (gleiche Phase)

  • Was passiert: count wird in einer zusammensetzbaren Funktion gelesen und direkt in einem anderen Content-Slot einer anderen zusammensetzbaren Funktion geändert count, nachdem es gelesen wurde.
  • Ergebnis: Das Snapshot-System erfasst den Lese- und den nachfolgenden Schreibvorgang im selben Kompositionspass, wodurch der aktuelle Bereich sofort ungültig wird.

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


Wichtige Regeln zur Vermeidung von Rückschreibvorgängen

  1. Schreiben Sie nicht in den Status in onGloballyPositioned, onSizeChanged oder LayoutModifier, wenn dieser Status in der Komposition gelesen wird, da dies zu dem Problem mit dem ersten Frame führt.
    • Wenn Layoutkoordinaten oder ‑größen nur für das benutzerdefinierte Zeichnen benötigt werden, lesen Sie sie direkt in der Draw- oder Layout-Phase (z. B. mit Modifier.drawWithCache oder Modifier.layout).
    • Für die Größenanpassung auf Fensterebene (WindowWidthSizeClass): Heben Sie die Beobachtung der Hebegröße auf die Fensterebene an. Die Komposition wird auf Fenstergrößenklassen aufgeteilt, bevor die lokale Messung erfolgt.
    • Komposition einheitlich halten:Verwenden Sie ein einzelnes benutzerdefiniertes Layout oder Komponenten wie FlowRow oder LazyVerticalGrid, mit denen sich Messung und Platzierung in Phase 2 anpassen lassen, ohne dass die Komposition neu zusammengesetzt oder der in der Komposition verwendete Status geändert wird.
    • Unterkomposition verwenden:Verwenden Sie BoxWithConstraints oder SubcomposeLayout, wenn untergeordnete Composables basierend auf der lokalen Breite oder Höhe verzweigen müssen. Vorsicht: Die Unterkomposition ist mit Leistungseinbußen verbunden und kann in der Regel vermieden werden.
    • Als letzte Option:Lassen Sie zu, dass der erste Frame falsch ist, und speichern Sie die Größe in onSizeChanged, um eine zweite Frame-Neuzusammenstellung auszulösen. Dies führt zu sichtbaren Layout-Pop-ups, Ruckeln und dem Risiko von Endlosschleifen.
  2. Zustand nach dem ersten Lesen in der Komposition nicht ändern:
    • Obwohl Sie während der Komposition außerhalb eines SideEffect sicher in MutableState-Objekte schreiben können, sollten Sie besonders darauf achten, dass Sie nicht in einen Zustand schreiben, den Sie zuvor in der Komposition gelesen haben. Es wird empfohlen, rememberUpdatedState zu verwenden, wenn ein Zustand in der Komposition geschrieben werden muss. Wenn Sie während der Komposition auf andere Weise in einen Status schreiben, ist das in der Regel ein Zeichen für einen fehlenden Effekt oder einen unangemessen gestalteten Status oder eine unangemessen gestaltete komponierbare Funktion. Denken Sie daran, dass die Komposition optimistisch ist und immer mit dem neuesten Wert eines Status ausgeführt wird. Daher werden möglicherweise nicht alle Statusänderungen in Re-Kompositionen angezeigt. UI-Aktualisierungen sollten nicht zum Verarbeiten einmaliger Ereignisse verwendet werden. Daher ist es ungewöhnlich, dass der Wert eines Status aufgrund einer Neukomposition aktualisiert werden muss.
    • Vermeiden Sie es, Status zu ändern, die außerhalb der Komposition beobachtet werden (z. B. ViewModel-Felder oder isVisible-Flags). Zustandsänderungen, die sich auf die Komposition auswirken, gehören in Ereignis-Lambdas (onClick), Coroutinen (LaunchedEffect) oder Nebeneffekte (SideEffect). rememberUpdatedState ist eine Ausnahme, da sie dazu dient, den Zustand zu ändern, der nur im @Composable-Body verwendet wird.
  3. Statuslesevorgänge auf die spätestmögliche Phase verschieben:
    • Wenn Sie den Status in Draw (Modifier.graphicsLayer { alpha = ... }) oder Layout (Modifier.offset { IntOffset(...) }) lesen, werden durch Änderungen nur Phase 2 oder 3 ungültig. Phase 1 (Zusammensetzung) wird vollständig übersprungen.