Jetpack Compose-Phasen

Wie die meisten anderen UI-Toolkits rendert Compose einen Frame in mehreren unterschiedlichen Phasen. Das Android-Ansichtssystem hat beispielsweise drei Hauptphasen: Messen, Layout und Zeichnen. Compose ist sehr ähnlich, hat aber am Anfang eine wichtige zusätzliche Phase, die Komposition genannt wird.

In der Compose-Dokumentation wird die Komposition unter Thinking in Compose und State and Jetpack Compose beschrieben.

Die drei Phasen eines Frames

Compose hat drei Hauptphasen:

  1. Komposition: Welche UI soll angezeigt werden? Compose führt zusammensetzbare Funktionen aus und erstellt eine Beschreibung Ihrer Benutzeroberfläche.
  2. Layout: Wo die Benutzeroberfläche platziert werden soll. Diese Phase besteht aus zwei Schritten: Messung und Platzierung. Layoutelemente messen und platzieren sich selbst und alle untergeordneten Elemente in 2D-Koordinaten für jeden Knoten im Layoutbaum.
  3. Zeichnung: So wird sie gerendert. UI-Elemente werden auf einer Canvas gezeichnet, in der Regel auf einem Gerätebildschirm.
Die drei Phasen, in denen Compose Daten in eine Benutzeroberfläche umwandelt (in der Reihenfolge: Daten, Komposition, Layout, Zeichnen, Benutzeroberfläche).
Abbildung 1. Die drei Phasen, in denen Compose Daten in eine Benutzeroberfläche umwandelt.

Die Reihenfolge dieser Phasen ist im Allgemeinen gleich, sodass die Daten in eine Richtung fließen, von der Komposition über das Layout bis hin zum Zeichnen, um einen Frame zu erzeugen (auch als unidirektionaler Datenfluss bezeichnet). BoxWithConstraints, LazyColumn und LazyRow sind bemerkenswerte Ausnahmen, bei denen die Zusammensetzung der untergeordneten Elemente von der Layoutphase des übergeordneten Elements abhängt.

Konzeptionell durchläuft jeder Frame diese Phasen. Um die Leistung zu optimieren, vermeidet Compose jedoch, dass in allen Phasen dieselben Ergebnisse aus denselben Eingaben berechnet werden. Compose überspringt das Ausführen einer zusammensetzbaren Funktion, wenn ein früheres Ergebnis wiederverwendet werden kann. Die Compose-UI führt kein neues Layout oder Zeichnen des gesamten Baums durch, wenn dies nicht erforderlich ist. Compose führt nur die Mindestmenge an Arbeit aus, die zum Aktualisieren der Benutzeroberfläche erforderlich ist. Diese Optimierung ist möglich, weil Compose die Statuslesevorgänge in den verschiedenen Phasen verfolgt.

Phasen verstehen

In diesem Abschnitt wird ausführlicher beschrieben, wie die drei Compose-Phasen für Composables ausgeführt werden.

Komposition

In der Kompositionsphase führt die Compose-Laufzeit zusammensetzbare Funktionen aus und gibt eine Baumstruktur aus, die Ihre UI darstellt. Dieser UI-Baum besteht aus Layoutknoten, die alle für die nächsten Phasen erforderlichen Informationen enthalten, wie im folgenden Video gezeigt:

Abbildung 2: Der Baum, der Ihre Benutzeroberfläche darstellt und in der Kompositionsphase erstellt wird.

Ein Teil des Codes und der UI-Baumstruktur sieht so aus:

Ein Code-Snippet mit fünf Composables und der resultierende UI-Baum, bei dem untergeordnete Knoten von ihren übergeordneten Knoten abzweigen.
Abbildung 3: Ein Unterabschnitt eines UI-Baums mit dem entsprechenden Code.

In diesen Beispielen wird jede zusammensetzbare Funktion im Code einem einzelnen Layoutknoten in der UI-Struktur zugeordnet. In komplexeren Beispielen können Composables Logik und Kontrollfluss enthalten und je nach Status einen anderen Baum erzeugen.

Layout

In der Layoutphase verwendet Compose den in der Kompositionsphase erstellten UI-Baum als Eingabe. Die Sammlung von Layoutknoten enthält alle Informationen, die erforderlich sind, um die Größe und Position jedes Knotens im 2D-Bereich zu bestimmen.

Abbildung 4: Die Messung und Platzierung der einzelnen Layoutknoten im UI-Baum während der Layoutphase.

In der Layoutphase wird der Baum mit dem folgenden dreistufigen Algorithmus durchlaufen:

  1. Untergeordnete Elemente messen: Ein Knoten misst seine untergeordneten Elemente, falls vorhanden.
  2. Eigene Größe festlegen: Basierend auf diesen Messungen legt ein Knoten seine eigene Größe fest.
  3. Untergeordnete Elemente platzieren: Jedes untergeordnete Element wird relativ zur Position des Knotens platziert.

Am Ende dieser Phase hat jeder Layoutknoten:

  • Eine zugewiesene Breite und Höhe
  • Eine X,Y-Koordinate, an der es gezeichnet werden soll

Hier ist noch einmal der UI-Baum aus dem vorherigen Abschnitt:

Ein Code-Snippet mit fünf Composables und der resultierende UI-Baum, bei dem untergeordnete Knoten von ihren übergeordneten Knoten abzweigen

Für diesen Baum funktioniert der Algorithmus so:

  1. Row misst die untergeordneten Elemente Image und Column.
  2. Image wird gemessen. Es hat keine untergeordneten Elemente, daher legt es seine eigene Größe fest und meldet diese an das Row zurück.
  3. Als Nächstes wird die Column gemessen. Zuerst werden die eigenen untergeordneten Elemente (zwei Text-Kompositionen) gemessen.
  4. Die ersten Text werden gemessen. Es hat keine untergeordneten Elemente, daher legt es seine eigene Größe fest und meldet sie an das Column-Element zurück.
    1. Der zweite Text wird gemessen. Es hat keine untergeordneten Elemente, daher legt es seine eigene Größe fest und meldet sie an das Column zurück.
  5. Das Column verwendet die Messungen der untergeordneten Elemente, um seine eigene Größe zu bestimmen. Es verwendet die maximale Breite der untergeordneten Elemente und die Summe der Höhe der untergeordneten Elemente.
  6. Das Column platziert seine untergeordneten Elemente relativ zu sich selbst und ordnet sie vertikal untereinander an.
  7. Das Row verwendet die Messungen des untergeordneten Elements, um seine eigene Größe zu bestimmen. Dabei werden die maximale Höhe der untergeordneten Elemente und die Summe der Breiten der untergeordneten Elemente verwendet. Anschließend werden die untergeordneten Elemente platziert.

Beachten Sie, dass jeder Knoten nur einmal besucht wurde. Die Compose-Laufzeit erfordert nur einen Durchlauf durch den UI-Baum, um alle Knoten zu messen und zu platzieren. Das verbessert die Leistung. Wenn die Anzahl der Knoten im Baum zunimmt, steigt die Zeit, die für das Durchlaufen des Baums benötigt wird, linear an. Wenn dagegen jeder Knoten mehrmals besucht wird, erhöht sich die Durchlaufzeit exponentiell.

Zeichnung

In der Zeichenphase wird der Baum noch einmal von oben nach unten durchlaufen und jeder Knoten zeichnet sich nacheinander auf dem Bildschirm.

Abbildung 5: In der Zeichenphase werden die Pixel auf dem Bildschirm gezeichnet.

Im vorherigen Beispiel wird der Baum wie folgt dargestellt:

  1. Der Row zeichnet alle Inhalte, die er möglicherweise hat, z. B. eine Hintergrundfarbe.
  2. Die Image zeichnet sich selbst.
  3. Der Column zeichnet sich selbst.
  4. Der erste und der zweite Text zeichnen sich selbst.

Abbildung 6. Ein UI-Baum und seine gezeichnete Darstellung.

Statuslesevorgänge

Wenn Sie den value eines snapshot state während einer der zuvor aufgeführten Phasen lesen, verfolgt Compose automatisch, was es beim Lesen des value getan hat. Durch dieses Tracking kann Compose den Reader noch einmal ausführen, wenn sich der value des Zustands ändert. Dies ist die Grundlage für die Beobachtbarkeit des Zustands in Compose.

In der Regel erstellen Sie den Status mit mutableStateOf() und greifen dann auf eine von zwei Arten darauf zu: entweder direkt über das Attribut value oder über einen Kotlin-Attribut-Delegaten. Weitere Informationen dazu finden Sie unter Zustand in Composables. Im Rahmen dieses Leitfadens bezieht sich ein „Zustandslesevorgang“ auf eine dieser beiden äquivalenten Zugriffsmethoden.

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

Im Hintergrund des Property-Delegates werden „Getter“- und „Setter“-Funktionen verwendet, um auf den value des Zustands zuzugreifen und ihn zu aktualisieren. Diese Getter- und Setter-Funktionen werden nur aufgerufen, wenn Sie auf die Property als Wert verweisen, und nicht, wenn sie erstellt wird. Deshalb sind die beiden zuvor beschriebenen Methoden gleichwertig.

Jeder Codeblock, der bei einer Änderung des Lesestatus noch einmal ausgeführt werden kann, ist ein Neustartbereich. Compose verfolgt Änderungen am Status value und startet Bereiche in verschiedenen Phasen neu.

Phasenstatus-Lesevorgänge

Wie bereits erwähnt, gibt es in Compose drei Hauptphasen. Compose verfolgt, welcher Status in jeder dieser Phasen gelesen wird. So kann Compose nur die spezifischen Phasen benachrichtigen, die für jedes betroffene Element Ihrer UI ausgeführt werden müssen.

In den folgenden Abschnitten werden die einzelnen Phasen und die Vorgänge beim Lesen eines Statuswerts beschrieben.

Phase 1: Komposition

Zustandslesevorgänge innerhalb einer @Composable-Funktion oder eines Lambda-Blocks wirken sich auf die Komposition und möglicherweise auf die nachfolgenden Phasen aus. Wenn sich der value des Zustands ändert, plant der Recomposer die erneute Ausführung aller zusammensetzbaren Funktionen, die den value dieses Zustands lesen. Beachten Sie, dass die Laufzeitumgebung einige oder alle zusammensetzbaren Funktionen überspringen kann, wenn sich die Eingaben nicht geändert haben. Weitere Informationen finden Sie unter Überspringen, wenn sich die Eingaben nicht geändert haben.

Je nach Ergebnis der Komposition führt die Compose-UI die Layout- und Zeichenphasen aus. Diese Phasen werden möglicherweise übersprungen, wenn der Inhalt gleich bleibt und sich Größe und Layout nicht ändern.

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: Layout

Die Layoutphase besteht aus zwei Schritten: Messung und Platzierung. Im Messschritt wird unter anderem das Mess-Lambda ausgeführt, das an die Layout komponierbare Funktion übergeben wird, sowie die MeasureScope.measure-Methode der LayoutModifier-Schnittstelle. Im Platzierungsschritt wird der Platzierungsblock der Funktion layout, der Lambda-Block von Modifier.offset { … } und ähnliche Funktionen ausgeführt.

Zustandslesevorgänge während dieser Schritte wirken sich auf das Layout und möglicherweise auf die Zeichenphase aus. Wenn sich der value des Status ändert, plant die Compose-UI die Layoutphase. Außerdem wird die Zeichenphase ausgeführt, wenn sich die Größe oder Position geändert hat.

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: Zeichnen

Zustandslesevorgänge während des Zeichencodes wirken sich auf die Zeichenphase aus. Gängige Beispiele sind Canvas(), Modifier.drawBehind und Modifier.drawWithContent. Wenn sich der value des Status ändert, führt Compose UI nur die Zeichenphase aus.

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

Diagramm, das zeigt, dass ein während der Zeichenphase gelesener Zustand nur dazu führt, dass die Zeichenphase noch einmal ausgeführt wird.

Status-Lesevorgänge optimieren

Da Compose das Lesen von lokalisierten Zuständen verfolgt, können Sie den Arbeitsaufwand minimieren, indem Sie jeden Zustand in einer geeigneten Phase lesen.

Dazu ein Beispiel: In diesem Beispiel wird ein Image() verwendet, dessen endgültige Layoutposition mit dem Offset-Modifikator verschoben wird. Dadurch entsteht beim Scrollen ein Parallax-Effekt.

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) {
        // ...
    }
}

Dieser Code funktioniert, führt aber zu einer suboptimalen Leistung. Der Code liest den value des firstVisibleItemScrollOffset-Zustands und übergibt ihn an die Funktion Modifier.offset(offset: Dp). Wenn der Nutzer scrollt, ändert sich der value des firstVisibleItemScrollOffset. Wie Sie bereits gelernt haben, verfolgt Compose alle Statuslesevorgänge, damit der Lesecode neu gestartet (erneut aufgerufen) werden kann. In diesem Beispiel ist das der Inhalt von Box.

Dies ist ein Beispiel für das Lesen eines Status innerhalb der Kompositionsphase. Das ist nicht unbedingt etwas Schlechtes und bildet die Grundlage der Neuzusammensetzung, sodass Datenänderungen eine neue UI ausgeben können.

Wichtiger Punkt: Dieses Beispiel ist suboptimal, da bei jedem Scroll-Ereignis der gesamte zusammensetzbare Inhalt neu ausgewertet, gemessen, angeordnet und schließlich gezeichnet wird. Sie lösen die Compose-Phase bei jedem Scrollen aus, obwohl sich der angezeigte Inhalt nicht geändert hat, sondern nur seine Position. Sie können das Lesen des Status optimieren, sodass nur die Layoutphase neu ausgelöst wird.

Offset mit Lambda

Es gibt eine weitere Version des Offset-Modifikators: Modifier.offset(offset: Density.() -> IntOffset).

Diese Version verwendet einen Lambda-Parameter, wobei der resultierende Offset vom Lambda-Block zurückgegeben wird. Aktualisieren Sie den Code, um ihn zu verwenden:

Box {
    val listState = rememberLazyListState()

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

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

Warum ist das leistungsfähiger? Der Lambda-Block, den Sie dem Modifikator zur Verfügung stellen, wird während der Layoutphase aufgerufen (genauer gesagt während des Platzierungsschritts der Layoutphase). Das bedeutet, dass der firstVisibleItemScrollOffset-Zustand während der Komposition nicht mehr gelesen wird. Da Compose verfolgt, wann der Status gelesen wird, bedeutet diese Änderung, dass Compose nur die Layout- und Zeichenphasen neu starten muss, wenn sich der value von firstVisibleItemScrollOffset ändert.

Natürlich ist es oft absolut notwendig, Zustände in der Kompositionsphase zu lesen. Dennoch gibt es Fälle, in denen du die Anzahl der Neuzusammensetzungen minimieren kannst, indem du Statusänderungen filterst. Weitere Informationen hierzu finden Sie unter derivedStateOf: Ein oder mehrere Zustandsobjekte in einen anderen Zustand konvertieren.

Rekombinationsschleife (zyklische Phasenabhängigkeit)

In diesem Leitfaden wurde bereits erwähnt, dass die Phasen von Compose immer in derselben Reihenfolge aufgerufen werden und dass es keine Möglichkeit gibt, innerhalb desselben Frames zurückzugehen. Das verhindert jedoch nicht, dass Apps in verschiedenen Frames in Kompositionsschleifen geraten. Betrachten wir dieses Beispiel:

Box {
    var imageHeightPx by remember { mutableIntStateOf(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() }
        )
    )
}

In diesem Beispiel wird eine vertikale Spalte mit dem Bild oben und dem Text darunter implementiert. Mit Modifier.onSizeChanged() wird die aufgelöste Größe des Bildes abgerufen und mit Modifier.padding() wird der Text nach unten verschoben. Die unnatürliche Konvertierung von Px zurück zu Dp weist bereits darauf hin, dass der Code ein Problem hat.

Das Problem bei diesem Beispiel ist, dass der Code nicht innerhalb eines einzelnen Frames das „endgültige“ Layout erreicht. Der Code basiert auf mehreren Frames, was zu unnötiger Arbeit führt und dazu, dass die Benutzeroberfläche auf dem Bildschirm des Nutzers hin- und herspringt.

Zusammensetzung des ersten Frames

Während der Kompositionsphase des ersten Frames ist imageHeightPx anfangs 0. Daher wird der Text im Code mit Modifier.padding(top = 0) angegeben. In der nachfolgenden Layoutphase wird der Callback des Modifikators onSizeChanged aufgerufen, der imageHeightPx auf die tatsächliche Höhe des Bildes aktualisiert. Compose plant dann eine Neuzusammensetzung für den nächsten Frame. In der aktuellen Zeichenphase wird der Text jedoch mit einem Padding von 0 gerendert, da der aktualisierte imageHeightPx-Wert noch nicht berücksichtigt wird.

Zweite Frame-Komposition

Compose initiiert den zweiten Frame, der durch die Änderung des Werts von imageHeightPx ausgelöst wird. In der Kompositionsphase dieses Frames wird der Status im Inhaltsblock Box gelesen. Der Text wird jetzt mit einem Padding versehen, das genau der Höhe des Bildes entspricht. Während der Layoutphase wird imageHeightPx noch einmal festgelegt. Es wird jedoch keine weitere Neuzusammensetzung geplant, da der Wert konsistent bleibt.

Diagramm, das eine Neuzusammensetzungsschleife zeigt, bei der eine Größenänderung in der Layoutphase eine Neuzusammensetzung auslöst, die dann dazu führt, dass das Layout noch einmal erstellt wird.

Dieses Beispiel mag konstruiert wirken, aber achten Sie auf dieses allgemeine Muster:

  • Modifier.onSizeChanged(), onGloballyPositioned() oder andere Layoutvorgänge
  • Status aktualisieren
  • Verwenden Sie diesen Status als Eingabe für einen Layoutmodifikator (padding(), height() oder ähnlich).
  • Potenziell wiederholen

Die Lösung für das vorherige Beispiel besteht darin, die richtigen Layout-Primitive zu verwenden. Das vorherige Beispiel kann mit einem Column() implementiert werden. Möglicherweise haben Sie jedoch ein komplexeres Beispiel, das etwas Benutzerdefiniertes erfordert, was das Schreiben eines benutzerdefinierten Layouts erfordert. Weitere Informationen finden Sie im Leitfaden zu benutzerdefinierten Layouts.

Das allgemeine Prinzip besteht darin, eine einzige verlässliche Quelle für mehrere UI-Elemente zu haben, die im Verhältnis zueinander gemessen und platziert werden sollen. Wenn Sie ein geeignetes Layout-Primitive verwenden oder ein benutzerdefiniertes Layout erstellen, dient das minimale gemeinsame übergeordnete Element als „Source of Truth“, die die Beziehung zwischen mehreren Elementen koordinieren kann. Die Einführung eines dynamischen Zustands bricht dieses Prinzip.

Weitere Informationen zu Neuzusammensetzungsschleifen und dazu, wie Sie das Schreiben in den Status über Phasen hinweg vermeiden, finden Sie unter Rückwärts schreiben in Compose.