Jetpack Compose-Phasen

Wie die meisten anderen UI-Toolkits rendert Compose einen Frame in mehreren unterschiedlichen Phasen. Das Android-View-System hat beispielsweise drei Hauptphasen: Messen, Layout und Zeichnen. Compose ist sehr ähnlich, hat aber zu Beginn eine wichtige zusätzliche Phase namens Zusammensetzung.

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

Die drei Phasen eines Frames

Compose besteht aus drei Hauptphasen:

  1. Zusammensetzung: Welche Benutzeroberfläche 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: Analyse und Platzierung. Layout-Elemente werden in 2D-Koordinaten gemessen und platziert, ebenso alle untergeordneten Elemente, und zwar für jeden Knoten im Layoutbaum.
  3. Zeichnung: So wird sie gerendert. UI-Elemente werden in einem Canvas gerendert, in der Regel auf einem Gerätebildschirm.
Die drei Phasen, in denen Compose Daten in die Benutzeroberfläche umwandelt (in der Reihenfolge: Daten, Komposition, Layout, Zeichnen, Benutzeroberfläche).
Abbildung 1: Die drei Phasen, in denen Compose Daten in die Benutzeroberfläche umwandelt.

Die Reihenfolge dieser Phasen ist in der Regel dieselbe, sodass Daten in eine Richtung fließen können, von der Komposition über das Layout bis hin zum Rendern eines Frames (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 alle diese Phasen. Um die Leistung zu optimieren, vermeidet Compose jedoch, Arbeit zu wiederholen, die in allen Phasen dieselben Ergebnisse aus denselben Eingaben berechnen würde. Compose überspringt die Ausführung einer zusammensetzbaren Funktion, wenn ein früheres Ergebnis wiederverwendet werden kann. Außerdem wird das gesamte Compose UI nicht neu angeordnet oder neu gezeichnet, wenn das nicht erforderlich ist. Compose führt nur die Mindestanzahl an Vorgängen aus, die zum Aktualisieren der Benutzeroberfläche erforderlich sind. Diese Optimierung ist möglich, weil Compose den Status in den verschiedenen Phasen verfolgt.

Phasen verstehen

In diesem Abschnitt wird genauer 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 Benutzeroberfläche darstellt. Dieser UI-Baum besteht aus Layoutknoten, die alle Informationen enthalten, die für die nächsten Phasen erforderlich sind, wie im folgenden Video gezeigt:

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

Ein Unterabschnitt des Code- und UI-Baums sieht so aus:

Ein Code-Snippet mit fünf Composables und dem resultierenden UI-Baum, wobei 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 im UI-Baum 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-Raum zu bestimmen.

Abbildung 4: Die Messung und Platzierung jedes Layoutknotens 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, sofern vorhanden.
  2. Eigene Größe festlegen: Anhand dieser Messungen legt ein Knoten seine eigene Größe fest.
  3. Untergeordnete Elemente platzieren: Jeder untergeordnete Knoten wird relativ zur Position eines Knotens platziert.

Am Ende dieser Phase hat jeder Layoutknoten Folgendes:

  • Eine zugewiesene Breite und Höhe
  • Eine x- und y-Koordinate, an der sie gezeichnet werden soll

Erinnern Sie sich an den UI-Baum aus dem vorherigen Abschnitt:

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

Für diesen Baum funktioniert der Algorithmus so:

  1. Die Row misst die untergeordneten Knoten Image und Column.
  2. Die Image wird gemessen. Es hat keine untergeordneten Elemente, daher wird die Größe selbst bestimmt und an Row zurückgemeldet.
  3. Als Nächstes wird der Column gemessen. Die Größe der eigenen untergeordneten Elemente (zwei Text-Composables) wird zuerst gemessen.
  4. Die erste Text wird gemessen. Da es keine untergeordneten Elemente hat, bestimmt es seine eigene Größe und meldet sie an Column zurück.
    1. Die zweite Text wird gemessen. Es hat keine untergeordneten Elemente, daher bestimmt es seine eigene Größe und meldet sie an Column zurück.
  5. Die Column richtet sich bei der Größenbestimmung nach den Maßen des Kindes. Dabei wird die maximale Breite des untergeordneten Elements und die Summe der Höhe der untergeordneten Elemente verwendet.
  6. Das Column positioniert seine untergeordneten Elemente relativ zu sich selbst und platziert sie vertikal untereinander.
  7. Die Row richtet sich bei der Größenbestimmung nach den Maßen des Kindes. Dabei werden die maximale Höhe des untergeordneten Elements 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. Für die Compose-Laufzeit ist nur ein Durchlauf des UI-Baums erforderlich, 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 jeder Knoten mehrmals besucht wird, erhöht sich die Durchlaufzeit exponentiell.

Zeichnen

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 dargestellt.

Im vorherigen Beispiel wird der Inhalt des Baums so dargestellt:

  1. Das Row rendert alle Inhalte, die es enthält, z. B. eine Hintergrundfarbe.
  2. Die Image wird automatisch gezeichnet.
  3. Die Column wird automatisch gezeichnet.
  4. Die erste und zweite Text werden jeweils selbst gezeichnet.

Abbildung 6 Ein UI-Baum und seine gerenderte Darstellung.

Statuslesevorgänge

Wenn Sie die value eines snapshot state während einer der oben aufgeführten Phasen lesen, verfolgt Compose automatisch, was es beim Lesen der value getan hat. Durch dieses Tracking kann Compose den Reader neu ausführen, wenn sich der Status von value ändert. Das ist die Grundlage für die Statusbeobachtung in Compose.

Der Status wird in der Regel mit mutableStateOf() erstellt und dann auf zwei Arten darauf zugegriffen: entweder direkt über die value-Property oder alternativ über einen Kotlin-Property-Delegate. Weitere Informationen dazu finden Sie unter Status in Composables. In diesem Leitfaden bezieht sich „Status lesen“ auf eine dieser beiden gleichwertigen 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)
)

Unter der Haube des Eigenschaftendelegaten werden „Getter“- und „Setter“-Funktionen verwendet, um auf das value des Status zuzugreifen und es zu aktualisieren. Diese Getter- und Setter-Funktionen werden nur aufgerufen, wenn Sie auf die Eigenschaft als Wert verweisen, nicht wenn sie erstellt wird. Daher sind die beiden oben beschriebenen Methoden gleichwertig.

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

Phasengesteuerte Statuslesevorgänge

Wie bereits erwähnt, gibt es in Compose drei Hauptphasen. Compose verfolgt, welcher Status in jeder Phase gelesen wird. So kann Compose nur die spezifischen Phasen benachrichtigen, die für jedes betroffene Element Ihrer Benutzeroberfläche Arbeit leisten müssen.

In den folgenden Abschnitten werden die einzelnen Phasen beschrieben und es wird erläutert, was passiert, wenn in einer Phase ein Statuswert gelesen wird.

Phase 1: Komposition

Statuslesevorgänge in einer @Composable-Funktion oder einem Lambda-Block wirken sich auf die Zusammensetzung und möglicherweise auf die nachfolgenden Phasen aus. Wenn sich der value des Status ändert, plant der Recomposer die erneute Ausführung aller zusammensetzbaren Funktionen, die den value dieses Status lesen. Die Laufzeit kann entscheiden, einige oder alle zusammensetzbaren Funktionen zu überspringen, wenn sich die Eingaben nicht geändert haben. Weitere Informationen finden Sie unter Überspringen, wenn sich die Eingaben nicht geändert haben.

Abhängig vom Ergebnis der Komposition führt 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 die Measure-Lambda-Funktion ausgeführt, die an die zusammensetzbare Funktion Layout übergeben wird, sowie die Methode MeasureScope.measure der Schnittstelle LayoutModifier. Im Platzierungsschritt wird der Platzierungsblock der Funktion layout, der Lambda-Block von Modifier.offset { … } und ähnliche Funktionen ausgeführt.

Das Lesen des Status in jedem dieser Schritte wirkt sich auf das Layout und möglicherweise auf die Zeichenphase aus. Wenn sich der value des Status ändert, plant 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: Ziehen

Statuslesevorgä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, wird in der Compose-Benutzeroberfläche nur die Zeichenphase ausgeführt.

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 Status, der während der Ziehungsphase gelesen wird, nur dazu führt, dass die Ziehungsphase noch einmal ausgeführt wird.

Statuslesevorgänge optimieren

Da Compose das Lesen von lokalisiertem Status verfolgt, können Sie die Menge der Arbeit minimieren, indem Sie jeden Status in einer geeigneten Phase lesen.

Dazu ein Beispiel: In diesem Beispiel gibt es ein Image(), für das der Offset-Modifikator verwendet wird, um die endgültige Layoutposition zu verschieben. Dadurch entsteht beim Scrollen ein Parallaxeneffekt.

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. Wie geschrieben, liest der Code die value des firstVisibleItemScrollOffset-Zustands und übergibt sie an die Funktion Modifier.offset(offset: Dp). Während der Nutzer scrollt, ändert sich die value der firstVisibleItemScrollOffset. Wie Sie bereits gelernt haben, verfolgt Compose alle Statuslesevorgänge, damit der Lesecode neu gestartet (neu aufgerufen) werden kann. In diesem Beispiel ist das der Inhalt von Box.

Dies ist ein Beispiel für das Lesen eines Status in der Phase composition. Das ist nicht unbedingt schlecht, sondern die Grundlage für die Neuzusammensetzung, die es ermöglicht, dass durch Datenänderungen neue UI-Elemente ausgegeben werden.

Wichtiger Punkt: Dieses Beispiel ist suboptimal, da jedes Scroll-Ereignis dazu führt, dass 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 ist eine weitere Version des Offset-Modifiers verfügbar: Modifier.offset(offset: Density.() -> IntOffset).

Diese Version verwendet einen Lambda-Parameter, wobei der resultierende Offset vom Lambda-Block zurückgegeben wird. So 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 für den Modifier bereitstellen, 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 nachverfolgt, wann der Status gelesen wird, muss Compose bei einer Änderung von value der firstVisibleItemScrollOffset nur die Layout- und Zeichenphasen neu starten.

Natürlich ist es oft absolut notwendig, Zustände in der Kompositionsphase zu lesen. Trotzdem gibt es Fälle, in denen Sie die Anzahl der Neuzusammensetzungen minimieren können, indem Sie Statusänderungen filtern. Weitere Informationen dazu finden Sie unter derivedStateOf: Ein oder mehrere Statusobjekte in einen anderen Status konvertieren.

Schleife für die Neuzusammensetzung (zyklische Phasenabhängigkeit)

In diesem Leitfaden wurde zuvor 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 Kompositionsloops über verschiedene Frames hinweg geraten. Betrachten wir dieses Beispiel:

Box {
    var imageHeightPx by remember { mutableStateOf(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. Dazu wird Modifier.onSizeChanged() verwendet, um die aufgelöste Größe des Bildes abzurufen, und dann Modifier.padding() für den Text, um ihn nach unten zu verschieben. Die unnatürliche Konvertierung von Px zurück zu Dp deutet bereits auf ein Problem im Code hin.

Das Problem bei diesem Beispiel ist, dass der Code nicht in einem einzigen Frame zum „finalen“ Layout gelangt. Der Code basiert auf mehreren Frames, was unnötige Arbeit verursacht und dazu führt, dass die Benutzeroberfläche auf dem Bildschirm für den Nutzer hin- und herspringt.

Zusammensetzung des ersten Frames

Während der Kompositionsphase des ersten Frames ist imageHeightPx anfangs 0. Der Code gibt den Text daher mit Modifier.padding(top = 0) aus. In der nachfolgenden Layoutphase wird der Callback des onSizeChanged-Modifiers aufgerufen, wodurch imageHeightPx auf die tatsächliche Höhe des Bildes aktualisiert wird. „Compose“ plant dann eine Neukomposition für den nächsten Frame. Während 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

Mit „Compose“ wird der zweite Frame initiiert, 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 Innenabstand versehen, der genau der Höhe des Bildes entspricht. Während der Layoutphase wird imageHeightPx noch einmal festgelegt. Es wird jedoch keine weitere Neuzusammenstellung geplant, da der Wert gleich bleibt.

Diagramm, das eine Neukompositions-Schleife zeigt, in der eine Größenänderung in der Layoutphase eine Neukomposition auslöst, die dann dazu führt, dass das Layout noch einmal erfolgt.

Dieses Beispiel mag gekünstelt erscheinen, aber Vorsicht vor diesem allgemeinen Muster:

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

Die Lösung für das vorherige Beispiel besteht darin, die richtigen Layout-Grundelemente zu verwenden. Das vorherige Beispiel kann mit einem Column() implementiert werden. Möglicherweise haben Sie aber ein komplexeres Beispiel, das etwas Benutzerdefiniertes erfordert. In diesem Fall müssen Sie ein benutzerdefiniertes Layout schreiben. Weitere Informationen finden Sie im Leitfaden Benutzerdefinierte Layouts.

Das allgemeine Prinzip besteht darin, eine einzige Quelle für mehrere UI-Elemente zu haben, die in Bezug 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, mit der die Beziehung zwischen mehreren Elementen koordiniert werden kann. Die Einführung eines dynamischen Status verstößt gegen diesen Grundsatz.