Jetpack Compose-Phasen

Wie die meisten anderen UI-Toolkits rendert Compose einen Frame in mehreren Phasen. Das Android-Ansichtssystem besteht aus drei Hauptphasen: Messung, Layout und Zeichnen. Compose ist sehr ähnlich, hat aber zu Beginn eine wichtige zusätzliche Phase namens Komposition.

Die Zusammensetzung wird in unseren Compose-Dokumenten beschrieben, einschließlich Komposition in Compose und State und Jetpack Compose.

Die drei Phasen eines Frames

Compose besteht aus drei Hauptphasen:

  1. Zusammensetzung: Welche Benutzeroberfläche angezeigt werden soll. Compose führt zusammensetzbare Funktionen aus und erstellt eine Beschreibung Ihrer Benutzeroberfläche.
  2. Layout: Platzierung der Benutzeroberfläche. 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. Zeichnen: Darstellung. UI-Elemente werden in einem Canvas gezeichnet, in der Regel auf dem Bildschirm eines Geräts.
Ein Bild der drei Phasen, in denen Compose Daten in eine Benutzeroberfläche umwandelt (in der Reihenfolge: Daten, Komposition, Layout, Zeichnung, Benutzeroberfläche).
Abbildung 1: Die drei Phasen, in denen Compose Daten in eine Benutzeroberfläche umwandelt.

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

Konzeptionell werden alle diese Phasen für jeden Frame ausgeführt. Zur Leistungsoptimierung wird bei Compose jedoch vermieden, Arbeit zu wiederholen, die in all diesen Phasen zu denselben Ergebnissen aus denselben Eingaben führen würde. Compose überspringt die Ausführung einer zusammensetzbaren Funktion, wenn ein früheres Ergebnis wiederverwendet werden kann. Außerdem wird in der Compose-Benutzeroberfläche der gesamte Baum nicht neu layoutet oder neu gezeichnet, wenn das nicht nötig ist. Compose führt nur die minimalen Schritte aus, die zum Aktualisieren der Benutzeroberfläche erforderlich sind. Diese Optimierung ist möglich, da Compose Zustandslesungen in den verschiedenen Phasen erfasst.

Phasen

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

Komposition

In der Kompositionierungsphase führt die Compose-Laufzeit kombinierbare Funktionen aus und gibt eine Baumstruktur aus, die Ihre Benutzeroberfläche 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 Kompositionphase erstellt wird.

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

Ein Code-Snippet mit fünf Composeables und dem daraus resultierenden UI-Baum, in dem sich untergeordnete Knoten von übergeordneten Knoten verzweigen.
Abbildung 3: Ein Teilbereich eines UI-Baums mit dem entsprechenden Code.

In diesen Beispielen wird jeder zusammensetzbare Codeblock einem einzelnen Layoutknoten im UI-Baum zugeordnet. In komplexeren Beispielen können Composables Logik und Ablaufsteuerung enthalten und bei unterschiedlichen Status einen anderen Baum erzeugen.

Layout

In der Layoutphase verwendet Compose den in der Kompositionphase erstellten UI-Baum als Eingabe. Die Sammlung der 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.

Während 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: 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:

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

Denken Sie an den UI-Baum aus dem vorherigen Abschnitt:

Ein Code-Snippet mit fünf Composeables und dem daraus resultierenden UI-Baum, in dem sich untergeordnete Knoten von übergeordneten Knoten verzweigen

Für diesen Baum funktioniert der Algorithmus so:

  1. Die Row misst ihre untergeordneten Elemente Image und Column.
  2. Der Image wird gemessen. Da es keine untergeordneten Elemente hat, legt es seine eigene Größe fest und meldet diese an die Row zurück.
  3. Als Nächstes wird der Column gemessen. Zuerst werden die eigenen untergeordneten Elemente (zwei Text-Kompositionen) gemessen.
  4. Die erste Text wird gemessen. Da es keine untergeordneten Elemente hat, legt es seine eigene Größe fest und meldet sie an die Column zurück.
    1. Die zweite Text wird gemessen. Da es keine untergeordneten Elemente hat, legt es seine eigene Größe fest und meldet sie an die Column zurück.
  5. Die Column bestimmt ihre eigene Größe anhand der Maße des untergeordneten Elements. Dabei wird die maximale Breite der untergeordneten Elemente und die Summe der Höhe der untergeordneten Elemente verwendet.
  6. Das Column platziert seine untergeordneten Elemente relativ zu sich selbst und ordnet sie vertikal aneinander an.
  7. Die Row bestimmt ihre eigene Größe anhand der Maße des untergeordneten Elements. Dabei wird 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 benötigt nur einen Durchlauf des UI-Baums, um alle Knoten zu messen und zu platzieren. Dadurch wird die Leistung verbessert. Wenn die Anzahl der Knoten im Baum zunimmt, erhöht sich die Zeit für das Durchlaufen des Baums linear. Wenn jeder Knoten dagegen 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 wird nacheinander auf dem Bildschirm gezeichnet.

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

Im vorherigen Beispiel wird der Bauminhalt so dargestellt:

  1. Das Row zeichnet alle Inhalte, die es haben könnte, z. B. eine Hintergrundfarbe.
  2. Das Image wird automatisch gezeichnet.
  3. Das Column wird automatisch gezeichnet.
  4. Das erste und das zweite Text werden jeweils selbst gezeichnet.

Abbildung 6 Ein UI-Baum und seine gezeichnete Darstellung.

Lesevorgänge für Status

Wenn Sie den Wert eines Snapshot-Status während einer der oben aufgeführten Phasen lesen, wird in Compose automatisch erfasst, was beim Lesen des Werts gerade ausgeführt wurde. Durch dieses Tracking kann Compose den Reader neu ausführen, wenn sich der Statuswert ändert. Dies ist die Grundlage für die Zustandsbeobachtung in Compose.

Der Status wird in der Regel mit mutableStateOf() erstellt und dann auf eine von zwei Arten darauf zugegriffen: durch direkten Zugriff auf die Property value oder alternativ über einen Kotlin-Property-Delegierten. Weitere Informationen finden Sie unter Status in Composeables. In diesem Leitfaden bezieht sich „Status lesen“ auf eine dieser ä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 Property-Delegate werden „Getter“- und „Setter“-Funktionen verwendet, um auf den value des Status 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. Daher sind die beiden oben genannten Methoden äquivalent.

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

Lesevorgänge für den Phasenstatus

Wie bereits erwähnt, gibt es drei Hauptphasen in Compose. Compose zeichnet auf, welcher Status in jeder dieser Phasen gelesen wird. So kann Compose nur die spezifischen Phasen benachrichtigen, die für jedes betroffene Element Ihrer Benutzeroberfläche Aufgaben ausführen müssen.

Sehen wir uns die einzelnen Phasen an und beschreiben, was passiert, wenn in einer Phase ein Statuswert gelesen wird.

Phase 1: Komposition

Statuslesungen in einer @Composable-Funktion oder einem Lambda-Block wirken sich auf die Zusammensetzung und möglicherweise auf die nachfolgenden Phasen aus. Wenn sich der Statuswert ändert, plant der Orchestrierungstool-Recompiler Wiederholungen aller kombinierbaren Funktionen, die diesen Statuswert lesen. Hinweis: Die Laufzeit entscheidet möglicherweise, einige oder alle der kombinierbaren 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.

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

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 das Mess-Lambda ausgeführt, das an die Layout-Komposition übergeben wurde, die MeasureScope.measure-Methode der LayoutModifier-Schnittstelle usw. Im Placement-Schritt wird der Placement-Block der Funktion layout, der Lambda-Block von Modifier.offset { … } usw. ausgeführt.

Die Statuslesungen während dieser Schritte wirken sich auf das Layout und möglicherweise auf die Zeichenphase aus. Wenn sich der Statuswert ändert, plant Compose UI die Layoutphase. Außerdem wird die Zeichenphase ausgeführt, wenn sich Größe oder Position geändert haben.

Genauer gesagt haben der Analyseschritt und der Placement-Schritt separate Neustartbereiche. Das bedeutet, dass der Analyseschritt nicht vor dem Lesen des Status im Placement-Schritt noch einmal aufgerufen wird. Diese beiden Schritte sind jedoch oft miteinander verwoben. Ein im Placement-Schritt gelesener Status kann sich also auf andere Neustarts auswirken, die zum Analyseschritt gehören.

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

Statuslesungen während des Zeichnens wirken sich auf die Zeichenphase aus. Gängige Beispiele sind Canvas(), Modifier.drawBehind und Modifier.drawWithContent. Wenn sich der Statuswert ä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)
}

Statuslesungen optimieren

Da Compose ein lokalisiertes Tracking von Statuslesungen durchführt, können wir die Arbeitslast minimieren, indem wir jeden Status in einer geeigneten Phase lesen.

Sehen wir uns ein Beispiel an. Hier sehen wir ein Image(), bei dem die finale Layoutposition mit dem Offset-Modifikator verschoben wird. Das führt beim Scrollen des Nutzers zu einem 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 nicht optimalen Leistung. So wie er geschrieben ist, liest der Code den Wert des Status firstVisibleItemScrollOffset und gibt ihn an die Funktion Modifier.offset(offset: Dp) weiter. Wenn der Nutzer scrollt, ändert sich der Wert von firstVisibleItemScrollOffset. Wie wir wissen, zeichnet Compose alle Statuslesungen auf, damit der Lesecode neu gestartet (erneut aufgerufen) werden kann. In unserem Beispiel ist das der Inhalt der Box.

Dies ist ein Beispiel für einen Status, der in der Komposition-Phase gelesen wird. Das ist nicht unbedingt schlecht und in der Tat die Grundlage der Neuzusammensetzung, die es ermöglicht, bei Datenänderungen eine neue Benutzeroberfläche zu generieren.

In diesem Beispiel ist das jedoch nicht optimal, da bei jedem Scrollereignis der gesamte zusammensetzbare Inhalt neu bewertet und dann auch gemessen, layoutet und schließlich gezeichnet wird. Wir lösen die Compose-Phase bei jedem Scrollen aus, auch wenn sich das, was wir anzeigen, nicht geändert hat, sondern nur wo es angezeigt wird. Wir können die Statuslesung so optimieren, dass nur die Layoutphase noch einmal ausgelöst wird.

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

Diese Version nimmt einen Lambda-Parameter an, wobei der resultierende Offset vom Lambda-Block zurückgegeben wird. Aktualisieren wir unseren 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 also leistungsfähiger? Der Lambda-Block, den wir 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-Status während der Komposition nicht mehr gelesen wird. Da Compose erfasst, wann der Status gelesen wird, bedeutet diese Änderung, dass Compose nur die Layout- und Zeichenphasen neu starten muss, wenn sich der Wert von firstVisibleItemScrollOffset ändert.

In diesem Beispiel werden die verschiedenen Offset-Modifizierer verwendet, um den resultierenden Code zu optimieren. Der allgemeine Ansatz ist jedoch richtig: Versuchen Sie, Statuslesungen auf die niedrigste mögliche Phase zu beschränken, damit Compose möglichst wenig Arbeit ausführen muss.

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

Neuzusammensetzungsschleife (zyklische Phasenabhängigkeit)

Wir haben bereits erwähnt, dass die Phasen von Compose immer in derselben Reihenfolge aufgerufen werden und dass es im selben Frame keine Möglichkeit gibt, rückwärts zu gehen. Das verhindert jedoch nicht, dass Apps in Kompositionsschleifen ü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() }
        )
    )
}

Hier haben wir eine (schlecht) implementierte vertikale Spalte mit dem Bild oben und dem Text darunter. Wir verwenden Modifier.onSizeChanged(), um die Größe des Bildes zu ermitteln, und dann Modifier.padding(), um den Text nach unten zu verschieben. Die unnatürliche Umwandlung von Px zurück zu Dp weist bereits darauf hin, dass ein Problem mit dem Code vorliegt.

Das Problem bei diesem Beispiel ist, dass wir nicht innerhalb eines einzelnen Frames zum „finalen“ Layout gelangen. Der Code setzt mehrere Frames voraus, was unnötige Arbeit verursacht und dazu führt, dass die Benutzeroberfläche auf dem Bildschirm springt.

Sehen wir uns die einzelnen Frames an, um zu sehen, was passiert:

In der Kompositionphase des ersten Frames hat imageHeightPx den Wert 0 und der Text wird mit Modifier.padding(top = 0) versehen. Anschließend folgt die Layoutphase und der Callback für den onSizeChanged-Modifikator wird aufgerufen. In diesem Fall wird imageHeightPx auf die tatsächliche Höhe des Bildes aktualisiert. Erstellen Sie einen Zeitplan für die Neuzusammensetzung des nächsten Frames. In der Zeichenphase wird der Text mit einem Abstand von 0 gerendert, da die Wertänderung noch nicht berücksichtigt wird.

Compose startet dann den zweiten Frame, der durch die Wertänderung von imageHeightPx geplant wurde. Der Status wird im Box-Inhaltsblock gelesen und in der Kompositionphase aufgerufen. Dieses Mal hat der Text einen Abstand, der der Bildhöhe entspricht. In der Layoutphase wird der Wert von imageHeightPx zwar noch einmal durch den Code festgelegt, aber es wird keine Neuzusammensetzung geplant, da der Wert gleich bleibt.

Am Ende erhalten wir das gewünschte Textabstand, aber es ist nicht optimal, einen zusätzlichen Frame zu verwenden, um den Wert für den Abstand an eine andere Phase zurückzugeben. Dies führt zu einem Frame mit sich überschneidenden Inhalten.

Dieses Beispiel mag etwas konstruiert erscheinen, 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 Layout-Modifikator (padding(), height() oder ähnlich).
  • Potenziell wiederholen

Für das Beispiel oben ist es am besten, die richtigen Layout-Primitiven zu verwenden. Das Beispiel oben kann mit einer einfachen Column()-Funktion implementiert werden. Möglicherweise haben Sie aber ein komplexeres Beispiel, für das etwas Besonderes erforderlich ist, was wiederum ein benutzerdefiniertes Layout erfordert. Weitere Informationen finden Sie im Leitfaden zu benutzerdefinierten Layouts.

Das allgemeine Prinzip besteht darin, eine einzige Source of Truth für mehrere UI-Elemente zu haben, die im Hinblick aufeinander gemessen und platziert werden sollten. Wenn Sie ein richtiges Layout-Element verwenden oder ein benutzerdefiniertes Layout erstellen, dient das minimal 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 dieses Prinzip.