Jetpack Compose-Phasen

Wie die meisten anderen UI-Toolkits rendert die Funktion „Compose“ einen Frame durch mehrere verschiedene Phasen. Das Android View-System besteht aus drei Hauptphasen: Messen, Layout und Zeichnen. Die Erstellung ist sehr ähnlich, beinhaltet jedoch zu Beginn eine wichtige zusätzliche Phase, die als Zusammensetzung bezeichnet wird.

Die Zusammensetzung wird in unseren Compose-Dokumenten einschließlich Thinking in Composer sowie State und Jetpack Compose beschrieben.

Die drei Phasen eines Frames

Das Verfassen besteht aus drei Hauptphasen:

  1. Zusammensetzung: Welche UI angezeigt wird. Compose führt zusammensetzbare Funktionen aus und erstellt eine Beschreibung der UI.
  2. Layout: Position für die UI. Diese Phase umfasst zwei Schritte: Messung und Platzierung. Layoutelemente messen und platzieren sich selbst und alle untergeordneten Elemente in 2D-Koordinaten für jeden Knoten im Layoutbaum.
  3. Zeichnung: Wie der Inhalt gerendert wird. UI-Elemente zeichnen sich auf einem Canvas aus, in der Regel ein Gerätebildschirm.
Ein Bild der drei Phasen, in denen die Funktion „Compose“ Daten in eine Benutzeroberfläche (in der richtigen Reihenfolge, Daten, Komposition, Layout, Zeichnung, UI) umwandelt.
Abbildung 1. Die drei Phasen, in denen die Funktion „Compose“ Daten in eine UI umwandelt.

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

Sie können sicher davon ausgehen, dass diese drei Phasen praktisch für jeden Frame stattfinden. Allerdings vermeidet Compose aus Gründen der Leistung Wiederholungen, bei denen in allen Phasen dieselben Ergebnisse aus denselben Eingaben berechnet werden. Compose überspringt die Ausführung einer zusammensetzbaren Funktion, wenn sie ein früheres Ergebnis wiederverwenden kann. Außerdem wird bei der Erstellungs-UI nicht das Layout neu gestaltet oder die gesamte Struktur neu gezeichnet, falls dies nicht erforderlich ist. Mit der Funktion „Compose“ wird nur das geringste Maß an Arbeit ausgeführt, das für die Aktualisierung der UI erforderlich ist. Diese Optimierung ist möglich, da die Funktion „Compose“ Statuslesevorgänge in den verschiedenen Phasen verfolgt.

Die Phasen verstehen

In diesem Abschnitt wird ausführlicher beschrieben, wie die drei Erstellungsphasen für zusammensetzbare Funktionen ausgeführt werden.

Komposition

In der Erstellungsphase führt die Compose-Laufzeit zusammensetzbare Funktionen aus und gibt eine Baumstruktur aus, die Ihre UI darstellt. Diese UI-Baumstruktur 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 UI darstellt und in der Zusammensetzungsphase erstellt wurde.

Ein Unterabschnitt des Codes und der UI-Struktur sieht so aus:

Ein Code-Snippet mit fünf zusammensetzbaren Funktionen und dem daraus resultierenden UI-Baum, wobei untergeordnete Knoten von den übergeordneten Knoten verzweigt werden.
Abbildung 3: Ein Unterabschnitt einer UI-Struktur mit dem entsprechenden Code.

In diesen Beispielen wird jede zusammensetzbare Funktion im Code einem einzelnen Layoutknoten in der UI-Baumstruktur zugeordnet. In komplexeren Beispielen können zusammensetzbare Funktionen Logik- und Kontrollabläufe enthalten und in verschiedenen Status eine andere Struktur erzeugen.

Layout

In der Layoutphase verwendet Compose den UI-Baum, der in der Erstellungsphase generiert wurde, als Eingabe. Die Sammlung von Layoutknoten enthält alle Informationen, die zur Entscheidung über die Größe und Position der einzelnen Knoten im 2D-Raum erforderlich sind.

Abbildung 4: Die Messung und Platzierung jedes Layoutknotens in der UI-Baumstruktur während der Layoutphase.

Während der Layoutphase wird der Baum mit dem folgenden Algorithmus mit drei Schritten durchlaufen:

  1. Untergeordnete messen: Ein Knoten misst seine untergeordneten Elemente, falls vorhanden.
  2. Eigene Größe festlegen: Auf Grundlage dieser Messungen entscheidet ein Knoten über seine eigene Größe.
  3. Untergeordnete Orte platzieren: Jeder untergeordnete Knoten wird relativ zur eigenen Position eines Knotens platziert.

Am Ende dieser Phase hat jeder Layoutknoten Folgendes:

  • Eine zugewiesene width und height
  • Eine x- und y-Koordinate, an der sie gezeichnet werden soll

Rufen Sie sich noch einmal die UI-Struktur aus dem vorherigen Abschnitt auf:

Ein Code-Snippet mit fünf zusammensetzbaren Funktionen und dem daraus resultierenden UI-Baum, wobei untergeordnete Knoten von den übergeordneten Knoten verzweigt werden

Für diesen Baum funktioniert der Algorithmus so:

  1. Das Row erfasst die untergeordneten Elemente Image und Column.
  2. Image wird gemessen. Da es keine untergeordneten Elemente hat, wird eine eigene Größe festgelegt und die Größe wird an Row zurückgegeben.
  3. Als Nächstes wird Column gemessen. Zuerst werden seine eigenen untergeordneten Elemente (zwei zusammensetzbare Text-Objekte) gemessen.
  4. Die erste Text wird gemessen. Da es keine untergeordneten Elemente hat, legt sie eine eigene Größe fest und meldet diese an Column.
    1. Die zweite Text wird gemessen. Da sie keine untergeordneten Elemente hat, entscheidet sie ihre Größe und meldet sie an Column.
  5. Column bestimmt seine eigene Größe anhand der Maße der untergeordneten Elemente. Dabei werden die maximale Breite der untergeordneten Elemente und die Summe der Höhe der untergeordneten Elemente verwendet.
  6. Column platziert seine untergeordneten Elemente relativ zu sich selbst und platziert sie vertikal untereinander.
  7. Row bestimmt seine eigene Größe anhand der Maße der untergeordneten Elemente. Dabei werden die maximale Höhe der untergeordneten Elemente und die Summe der Breiten der untergeordneten Elemente verwendet. Dann werden die Kinder platziert.

Beachten Sie, dass jeder Knoten nur einmal besucht wurde. Für die Compose-Laufzeit ist nur ein Durchlauf durch den UI-Baum erforderlich, um alle Knoten zu messen und zu platzieren, was die Leistung verbessert. Wenn die Anzahl der Knoten im Baum zunimmt, erhöht sich die Zeit, die für den Durchlauf aufgewendet wird, linear. Wurde hingegen jeder Knoten mehrmals besucht, verlängert sich die Durchlaufzeit exponentiell.

Zeichnung

In der Zeichenphase wird der Baum erneut von oben nach unten durchlaufen und jeder Knoten wird der Reihe nach auf dem Bildschirm gezeichnet.

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

Im vorherigen Beispiel wird der Bauminhalt folgendermaßen gezeichnet:

  1. Das Row zeichnet jeglichen Inhalt, z. B. eine Hintergrundfarbe.
  2. Image zeichnet sich selbst.
  3. Column zeichnet sich selbst.
  4. Die erste und die zweite Text zeichnen sich selbst.

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

Statuslesevorgänge

Wenn Sie den Wert eines Snapshot-Status in einer der oben aufgeführten Phasen lesen, verfolgt Compose automatisch die Aktionen, die beim Lesen des Werts ausgeführt wurden. Dieses Tracking ermöglicht es Compose, den Reader noch einmal auszuführen, wenn sich der Statuswert ändert. Dies ist die Grundlage für die Beobachtbarkeit des Zustands in Compose.

Der Status wird normalerweise mit mutableStateOf() erstellt und dann auf eine von zwei Arten aufgerufen: durch direkten Zugriff auf das Attribut value oder alternativ über einen Kotlin-Attribut-Delegaten. Weitere Informationen finden Sie unter Zustand in zusammensetzbaren Funktionen. Im Rahmen dieses Leitfadens bezieht sich das Lesen eines Status auf eine der 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)
)

Im Hintergrund des Property-Bevollmächtigten werden die Funktionen „Getter“ und „Setter“ verwendet, um auf value des Bundesstaates zuzugreifen und diese zu aktualisieren. Diese Getter- und Setter-Funktionen werden nur aufgerufen, wenn Sie auf die Eigenschaft als Wert verweisen, und nicht, wenn sie erstellt wird. Daher sind die beiden oben genannten Methoden gleichwertig.

Jeder Codeblock, der neu ausgeführt werden kann, wenn sich der Lesestatus ändert, ist ein Neustartbereich. Compose verfolgt Statuswertänderungen und Neustartbereiche in verschiedenen Phasen.

Lesevorgänge in Phasenstatus

Wie bereits erwähnt, gibt es bei der Erstellung drei Hauptphasen. Dabei wird erfasst, welcher Status in jeder der beiden Phasen gelesen wird. So kann Compose nur die einzelnen Phasen benachrichtigen, in denen für jedes betroffene Element der UI Arbeiten ausgeführt werden müssen.

Lassen Sie uns jede Phase durchgehen und beschreiben, was passiert, wenn ein Statuswert darin gelesen wird.

Phase 1: Zusammensetzung

Zustandslesevorgänge innerhalb einer @Composable-Funktion oder eines Lambda-Blocks beeinflussen die Zusammensetzung und möglicherweise die nachfolgenden Phasen. Wenn sich der Statuswert ändert, plant die Neuerstellung die Ausführung aller zusammensetzbaren Funktionen, die diesen Statuswert gelesen haben. Die Laufzeit kann einige oder alle zusammensetzbaren Funktionen ü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 Zusammensetzung werden die Layout- und Zeichenphasen über die Benutzeroberfläche zum Schreiben ausgeführt. Diese Phasen werden möglicherweise übersprungen, wenn der Inhalt gleich bleibt, sich die Größe und das 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 umfasst zwei Schritte: Messung und Placement. Im Messschritt wird die Lambda-Messung ausgeführt, die an die zusammensetzbare Funktion Layout, die Methode MeasureScope.measure der Schnittstelle LayoutModifier usw. übergeben wurde. Im Placement-Schritt werden der Platzierungsblock der layout-Funktion, der Lambda-Block von Modifier.offset { … } usw. ausgeführt.

Die Statuslesevorgänge während jedes dieser Schritte wirken sich auf das Layout und möglicherweise die Zeichenphase aus. Wenn sich der Statuswert ändert, wird die Layoutphase über die Benutzeroberfläche „Compose“ geplant. Außerdem wird die Zeichenphase ausgeführt, wenn sich Größe oder Position geändert hat.

Um genauer zu sein, haben der Messschritt und der Placement-Schritt separate Bereiche für den Neustart. Das heißt, dass bei den Statuslesevorgängen im Placement-Schritt der Messschritt vorher nicht noch einmal aufgerufen wird. Diese beiden Schritte sind jedoch häufig miteinander verknüpft. Daher kann sich ein im Placement-Schritt gelesener Status auf andere Neustartbereiche auswirken, die zum Messschritt 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

Das Lesen des Status während des Zeichencodes wirkt sich auf die Zeichenphase aus. Gängige Beispiele sind Canvas(), Modifier.drawBehind und Modifier.drawWithContent. Wenn sich der Statuswert ändert, führt die Erstellungs-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)
}

Statuslesevorgänge optimieren

Wenn mit Compose ein lokalisiertes Lese-Tracking durchführt, können wir den Arbeitsaufwand minimieren, indem wir jeden Status in einer geeigneten Phase lesen.

Sehen wir uns ein Beispiel an. Hier sehen Sie ein Image(), das den Offset-Modifikator verwendet, um seine endgültige Layoutposition zu verschieben, was zu einem Parallaxe-Effekt führt, wenn der Nutzer scrollt.

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 jedoch zu einer nicht optimalen Leistung. Wie beschrieben liest der Code den Wert des Status firstVisibleItemScrollOffset und übergibt ihn an die Funktion Modifier.offset(offset: Dp). Wenn der Nutzer scrollt, ändert sich der Wert von firstVisibleItemScrollOffset. Wie wir wissen, verfolgt Compose alle Statuslesevorgänge, sodass der Lesecode neu gestartet (noch einmal aufgerufen) werden kann. In unserem Beispiel ist das der Inhalt von Box.

Dies ist ein Beispiel für einen Zustand, der in der Zusammensetzungsphase gelesen wird. Das ist nicht unbedingt schlecht, sondern bildet sogar die Grundlage für die Neuzusammensetzung, sodass bei Datenänderungen eine neue UI generiert werden kann.

In diesem Beispiel ist dies nicht optimal, da jedes Scroll-Ereignis dazu führt, dass der gesamte zusammensetzbare Inhalt neu bewertet und dann ebenfalls gemessen, angelegt und schließlich gezeichnet wird. Die Erstellungsphase wird bei jedem Scrollen ausgelöst, auch wenn sich die angezeigten Inhalte nicht geändert haben, sondern nur wo sie angezeigt werden. Wir können unseren Lesestatus so optimieren, dass nur die Layoutphase noch einmal ausgelöst wird.

Es ist eine andere Version des Offset-Modifikators verfügbar: Modifier.offset(offset: Density.() -> IntOffset).

Diese Version verwendet einen Lambda-Parameter, bei dem der resultierende Offset vom Lambda-Block zurückgegeben wird. Aktualisieren wir nun 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 so leistungsstärker? Der Lambda-Block, den wir für den Modifikator bereitstellen, wird während der Layoutphase aufgerufen (insbesondere während der Platzierungsphase der Layoutphase), was bedeutet, dass der firstVisibleItemScrollOffset-Zustand während der Komposition nicht mehr gelesen wird. Da „Compose“ erfasst, wenn der Status gelesen wird, bedeutet diese Änderung, dass bei einer Änderung des Werts firstVisibleItemScrollOffset nur die Layout- und Zeichenphasen neu gestartet werden müssen.

Dieses Beispiel stützt sich auf die verschiedenen Offset-Modifikatoren, um den resultierenden Code zu optimieren, aber die Grundidee ist wahr: Versuchen Sie, Zustandslesevorgänge in der niedrigstmöglichen Phase zu lokalisieren, damit Compose den minimalen Arbeitsaufwand ausführen kann.

Natürlich ist es oft unbedingt notwendig, Zustände in der Zusammensetzungsphase zu lesen. Dennoch gibt es Fälle, in denen wir die Anzahl der Neuzusammensetzungen durch Filtern von Statusänderungen minimieren können. Weitere Informationen dazu finden Sie unter DesignatedStateOf: Konvertieren eines oder mehrerer Statusobjekte in einen anderen Status.

Neuzusammensetzungsschleife (zyklische Phasenabhängigkeit)

Wir haben bereits erwähnt, dass die Erstellungsphasen immer in der gleichen Reihenfolge aufgerufen werden und dass es keine Möglichkeit gibt, zurückzugehen, während sie sich im selben Frame befinden. Dies hindert Apps jedoch nicht daran, in verschiedenen Frames Kompositionsschleifen zu erzeugen. 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 (schlecht) eine vertikale Säule implementiert, auf der das Bild oben und der Text darunter zu sehen ist. Wir verwenden Modifier.onSizeChanged(), um die Größe des Bilds aufzulösen, und verwenden dann Modifier.padding() für den Text, um ihn nach unten zu verschieben. Die unnatürliche Umwandlung von Px zurück zu Dp deutet bereits auf ein Problem mit dem Code hin.

Das Problem bei diesem Beispiel ist, dass wir nicht zum "endgültigen" Layout innerhalb eines einzelnen Frames gelangen. Der Code stützt sich auf mehrere Frames. Dies führt unnötigen Aufwand und führt dazu, dass die UI auf dem Bildschirm herumspringt.

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

In der Erstellungsphase des ersten Frames hat imageHeightPx den Wert 0 und der Text wird mit Modifier.padding(top = 0) bereitgestellt. Dann folgt die Layoutphase und der Callback für den onSizeChanged-Modifikator wird aufgerufen. In diesem Fall wird die imageHeightPx auf die tatsächliche Höhe des Bildes aktualisiert. Mit der Funktion „Compose“ wird die Neuzusammensetzung für den nächsten Frame geplant. Während der Zeichenphase wird der Text mit einem Abstand von 0 gerendert, da die Wertänderung noch nicht widergespiegelt wird.

Compose startet dann den zweiten Frame, der durch die Wertänderung von imageHeightPx geplant wurde. Der Zustand wird im Box-Inhaltsblock gelesen und in der Zusammensetzungsphase aufgerufen. Dieses Mal wird der Text mit einem Abstand versehen, der der Bildhöhe entspricht. In der Layoutphase wird der Wert von imageHeightPx durch den Code noch einmal festgelegt. Es ist jedoch keine Neuzusammensetzung geplant, da der Wert gleich bleibt.

Schließlich erhalten wir den gewünschten Abstand im Text, aber es ist nicht optimal, einen zusätzlichen Frame zu verwenden, um den Abstandswert zurück in eine andere Phase zu übergeben. Dies führt dazu, dass ein Frame mit sich überschneidenden Inhalten erstellt wird.

Dieses Beispiel mag erfunden 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 Layoutmodifikator (padding(), height() oder ähnlich).
  • Potenziell wiederholen

Die Fehlerbehebung für das Beispiel oben besteht darin, die richtigen Layout-Primitive zu verwenden. Das obige Beispiel kann mit einem einfachen Column() implementiert werden. Möglicherweise haben Sie jedoch ein komplexeres Beispiel, für das ein benutzerdefiniertes Layout erforderlich ist, für das ein benutzerdefiniertes Layout geschrieben werden muss. Weitere Informationen finden Sie unter Benutzerdefinierte Layouts.

Das allgemeine Prinzip besteht darin, eine einzige Datenquelle für mehrere UI-Elemente zu haben, die in Relation zueinander gemessen und platziert werden sollen. Wenn Sie eine korrekte Layout-Primitive verwenden oder ein benutzerdefiniertes Layout erstellen, dient das minimal gemeinsam genutzte übergeordnete Element als Quelle der Wahrheit, mit der die Beziehung zwischen mehreren Elementen koordiniert werden kann. Die Einführung eines dynamischen Zustands widerspricht diesem Prinzip.