Rückwärtsschreiben

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

Drei Phasen: 1. Komposition, 2. Layout, 3. Zeichnung
Abbildung 1. Die drei Phasen eines Compose-Frames
  1. Komposition: Führt @Composable-Funktionen aus, um den UI-Baum zu erstellen und zu aktualisieren.
  2. Layout: Misst untergeordnete Elemente und platziert sie dann.
  3. Zeichnen: Gibt Zeichenbefehle für die Zeichenfläche aus, um Pixel auf dem Bildschirm zu rendern.

Wenn ein Compose-State in einer beliebigen Phase gelesen wird, zeichnet Compose automatisch eine Abhängigkeit zwischen diesem Status und der entsprechenden Phase auf.


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

Ein rückwärts gerichteter Schreibvorgang tritt immer dann auf, wenn ein Status in einer späteren Phase (oder in einem nachgelagerten Bereich, d. h. einem zusammensetzbaren Bereich, der später in der Reihenfolge innerhalb desselben Zusammensetzungsdurchlaufs ausgeführt wird) geändert wird, als er gelesen wurde. Dadurch wird Compose gezwungen, eine Neuzusammensetzung durchzuführen, indem eine frühere Phase oder ein früheres Compose-Element noch einmal ausgeführt wird. Ein Rückwärtsschreiben ist eine nicht optimierte Neuzusammensetzungsschleife.

Beispiel für das Rückwärtsschreiben durch die Phasen der Neuzusammensetzungsschleife
Abbildung 2. Beispiel für das Rückwärtsschreiben durch die Phasen der Neuzusammensetzungsschleife

Folgen von Rückwärtsschreibvorgängen

Rückwärtsschreibvorgänge sind nicht unbedingt etwas Schlechtes und führen nicht immer zu Abstürzen, aber sie sind ineffizient und können die App-Leistung auf verschiedene Weise beeinträchtigen:

  • Zusätzliches Frame-Rendering und ausgelassene Frames: Ein Rückwärtsschreibvorgang zwingt Compose, redundante Kompositionsdurchläufe über aufeinanderfolgende Frames hinweg auszuführen. Dadurch werden CPU- und GPU-Ressourcen verschwendet und es kann zu Rucklern kommen.
  • Probleme mit der Korrektheit des ersten Frames: Wenn für die Komponente ein Rückwärtsschreiben erforderlich ist, um die endgültigen Abmessungen oder den endgültigen Zustand zu ermitteln, wird der erste Frame mit ungültigen, Standard- oder unbestimmten Daten gerendert (z. B. Größe 0 oder falscher Offset). Dies führt zu sichtbarem visuellem Popping oder Layoutflackern, wenn der zweite Frame gerendert wird.
  • Unendliche Neuzusammensetzungsschleifen: Wenn eine Statusänderung die Layoutgröße ändert und die Layoutgröße kontinuierlich einen neuen Wert in den Status zurückschreibt, besteht die Gefahr, dass eine unendliche Frame-Schleife entsteht, in der der Bildschirm ständig jeden Frame neu zusammensetzt, ohne sich jemals zu stabilisieren.

Vorwärtsfluss durch Phasen

Statusänderungen sollten immer vorwärts durch die Phasen erfolgen:

Lesephase Kontext schreiben Zulässig? Warum
Layout (Modifier.offset { }) Zusammensetzung Ja Die Komposition aktualisiert den Status → Das Layout liest ihn später im selben Frame, ohne dass eine Neukomposition erforderlich ist.
Zeichnen (graphicsLayer { }, drawBehind { }) Zusammensetzung Ja Die Komposition aktualisiert den Status → „Draw“ liest ihn in der letzten Phase. Bildaufbau und Layout werden vollständig übersprungen.
Zeichnen Layout Ja Das Layout aktualisiert den Status und die Zeichnung liest ihn. Das ist ein gültiger Ablauf.
Zusammensetzung Ereignis-Callback (onClick, onValueChange) löst Statusänderung aus. Hinweis: Layout-Callbacks zählen nicht als Ereignisse. Ja Ein Ereignis ändert den Status, der für die Zusammensetzung verwendet wird. Wenn das Ereignis außerhalb des Frames auftritt (nicht in der Komposition, im Layout oder beim Zeichnen), ist dies gültig.
Zusammensetzung Koroutine (LaunchedEffect) Ja, aber mit Vorsicht Aktualisiert den Status asynchron als Reaktion auf Lifecycle-Events. Schreibvorgänge aus Effekten können gültig sein, aber auf eine ineffiziente Statusebenenstruktur hinweisen. Sie sollten nach Möglichkeit vermieden werden.
Placement (im Layout) Messen (im Layout) Ja In Layout ist es akzeptabel, den Status zu aktualisieren und dann in Placement zu lesen.
Messen (im Layout) Placement (im Layout) Nein – rückwärts schreiben Das Schreiben in den Status in der Platzierung, der sich dann weiter im Lesestatus befindet, führt zu einer Neumessungsschleife.
Zusammensetzung Layout (onSizeChanged, LayoutModifier) Nein – Rückwärts schreiben Das Layout macht die Komposition ungültig → Rekompositionsschleife.
Zusammensetzung Zeichnen (drawWithContent, Canvas) Nein – Rückwärts schreiben Das Zeichnen macht die Schleife „Komposition → Neukomposition“ ungültig.

Phasenkombinationen: Rückwärts- und Vorwärtsphasen

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

Rückwärts: Lesen in der Komposition, Schreiben im Layout

  • Was passiert: Die Komposition liest componentHeight, um zu bestimmen, welche UI ausgegeben werden soll. Später im Frame werden in der Layoutphase Ansichten gemessen oder platziert und ein neuer Wert in componentHeight geschrieben (z. B. mit onSizeChanged, onGloballyPositioned oder benutzerdefiniertem LayoutModifier).
  • Ergebnis: Durch die Änderung von componentHeight im Layout wird die gerade abgeschlossene Kompositionsphase ungültig. Beachten Sie, dass onSizeChanged die Größe nach Abschluss des Layoutmessungsdurchlaufs meldet. Wenn sich der aktualisierte Zustandswert beim nächsten Durchlauf stabilisiert, wird die Neuzusammensetzung möglicherweise nach einem zusätzlichen Frame angehalten. Wenn der neue Wert jedoch weiterhin die Größe ändert, führt dies zu einer unendlichen Frame-Schleife. Außerdem wird onGloballyPositioned nach dem Layout und der Platzierung ausgeführt, sodass Zustandsschreibvorgänge darin noch anfälliger für kontinuierliche Neuzusammensetzungs- und Neulayoutschleifen ü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 der Komposition, Schreiben in der Zeichnung

  • Was passiert: Der Status wird im Composable-Body (Kompositionsphase) gelesen, aber innerhalb von Modifier.drawWithContent, Modifier.drawBehind oder Canvas (Zeichenphase) geändert.
  • Ergebnis: Die Zeichenphase ä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 der Komposition, Schreiben in der Komposition (gleiche Phase)

  • Was passiert: count wird in einer zusammensetzbaren Funktion gelesen und nach dem Lesen direkt in einem anderen Inhalts-Slot einer zusammensetzbaren Funktion geändert count.
  • Ergebnis: Das Snapshot-System zeichnet den Lese- und den nachfolgenden Schreibvorgang innerhalb desselben Kompositionsdurchlaufs auf und macht den aktuellen Bereich sofort ungültig.

// ❌ 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ückwärtsschreiben

  1. Schreibe nicht in den Status in onGloballyPositioned, onSizeChanged, onPlaced, onLayoutRectChanged oder LayoutModifier, wenn dieser Status in der Composition gelesen wird, da dies das Problem mit der Korrektheit des ersten Frames verursacht.
    • Wenn Layoutkoordinaten oder -größen nur für benutzerdefiniertes Zeichnen benötigt werden, lies sie direkt in der Zeichen- oder Layoutphase aus (z. B. mit Modifier.drawWithCache oder Modifier.layout).
    • Für die Größenbestimmung auf Fensterebene (WindowWidthSizeClass): Heben Sie die Größenbeobachtung auf die Fensterebene an. Die Komposition wird vor der lokalen Messung nach Fenstergrößenklassen verzweigt.
    • Komposition einheitlich halten:Verwende ein einzelnes benutzerdefiniertes Layout oder Komponenten wie FlowRow oder LazyVerticalGrid, die die Messung und Platzierung während Phase 2 anpassen, ohne den in der Komposition verwendeten Zustand neu zu erstellen oder zu ändern.
    • Unterkomposition verwenden:Verwenden Sie BoxWithConstraints oder SubcomposeLayout, wenn untergeordnete Composables basierend auf der lokalen Breite oder Höhe verzweigt werden müssen. Seien Sie vorsichtig: Die Unterkomposition ist mit Leistungseinbußen verbunden und kann in der Regel vermieden werden.
    • Als letzte Option: Lasst zu, dass der erste Frame falsch ist, und speichert die Größe in onSizeChanged, um eine Neuzusammensetzung des zweiten Frames auszulösen. Dies führt zu sichtbarem Layout-Popping, Ruckeln und birgt das Risiko von Endlosschleifen.
  2. Ändern Sie den Status nicht, nachdem er zum ersten Mal in der Komposition gelesen wurde:
    • Sie können zwar während der Erstellung außerhalb von SideEffect sicher in MutableState-Objekte schreiben, achten Sie jedoch darauf, dass Sie nicht in einen Zustand schreiben, den Sie zuvor in der Erstellung gelesen haben. Es wird empfohlen, rememberUpdatedState zu verwenden, wenn ein Zustand in der Komposition geschrieben werden muss. Wenn während der Komposition auf andere Weise in einen Status geschrieben wird, ist das in der Regel ein Zeichen für einen fehlenden Effekt oder einen ungeeignet gestalteten Status oder eine ungeeignet gestaltete zusammensetzbare Funktion. Denken Sie daran, dass die Komposition optimistisch ist und immer mit dem neuesten Wert eines Zustands ausgeführt wird. Daher werden möglicherweise nicht alle Zustandsänderungen in Neukompositionen angezeigt. UI-Updates sollten nicht zur Verarbeitung einmaliger Ereignisse verwendet werden. Daher ist es unüblich, dass der Wert eines Zustands aufgrund einer Neuzusammensetzung aktualisiert werden muss.
    • Vermeiden Sie das Ändern von Zuständen, die außerhalb der Komposition beobachtet werden (z. B. ViewModel-Felder oder isVisible-Flags). Zustandsschreibvorgänge, die sich auf die Komposition auswirken, gehören in Ereignis-Lambdas (onClick), Koroutinen (LaunchedEffect) oder Nebenwirkungen (SideEffect). rememberUpdatedState ist eine Ausnahme, da es so konzipiert ist, dass es den Zustand ändert, der nur im @Composable-Body verwendet wird.
  3. Zustandslesevorgänge auf die spätestmögliche Phase verschieben:
    • Wenn Sie Zustände in Draw (Modifier.graphicsLayer { alpha = ... }) oder Layout (Modifier.offset { IntOffset(...) }) lesen, wird sichergestellt, dass Änderungen nur Phase 2 oder 3 ungültig machen und Phase 1 (Komposition) vollständig übersprungen wird.