Jetpack Compose-Phasen

Wie die meisten anderen UI-Toolkits rendert auch Compose einen Frame durch mehrere verschiedene Phasen. Das Android View-System besteht aus drei messen, Layout und Zeichnen. Die Funktion „Schreiben“ ist sehr ähnlich, wichtige zusätzliche Phase, die Komposition genannt, zu Beginn.

Die Komposition wird in allen unseren „Compose-Dokumenten“ beschrieben, einschließlich Thinking in Compose und State and Jetpack Compose.

Die drei Phasen eines Frames

Das Schreiben gliedert sich in drei Hauptphasen:

  1. Komposition: Welche UI soll angezeigt werden? Compose führt zusammensetzbare Funktionen aus erstellt eine Beschreibung Ihrer UI.
  2. Layout: Wo die Benutzeroberfläche platziert werden soll? Diese Phase umfasst zwei Schritte: Analyse und Platzierung. Layout-Elemente messen und platzieren sich selbst und alle untergeordneten Elemente in 2D-Koordinaten für jeden Knoten im Layoutbaum.
  3. Zeichnung: Darstellung des Renderings UI-Elemente werden in einen Canvas gezeichnet, Gerätebildschirms.
<ph type="x-smartling-placeholder">
</ph> Ein Bild der drei Phasen, in denen die Funktion „Compose“ Daten in die Benutzeroberfläche umwandelt (in der Reihenfolge, Daten, Zusammensetzung, Layout, Zeichnen, Benutzeroberfläche).
Abbildung 1. Die drei Phasen, in denen Daten von „Compose“ in eine UI umgewandelt werden.

Die Reihenfolge dieser Phasen ist im Allgemeinen gleich, sodass die Daten in einer einzigen Phase fließen. Richtung von der Komposition über das Layout bis hin zur Zeichnung, um einen Frame zu erstellen (auch bekannt als unidirektionalen Datenfluss). BoxWithConstraints und LazyColumn und LazyRow sind bemerkenswert Ausnahmen, bei denen die Zusammensetzung der untergeordneten Elemente vom Layout des übergeordneten Elements abhängt. .

Sie können davon ausgehen, dass diese drei Phasen praktisch für jeden Frame stattfinden. Um die Leistung zu verbessern, vermeidet die Compose-Funktion die Wiederholung von Arbeiten, die gleichen Ergebnisse aus denselben Eingaben in allen diesen Phasen. Verfassen skips das Ausführen einer zusammensetzbaren Funktion , wenn ein früheres Ergebnis wiederverwendet werden kann und die Benutzeroberfläche zum Schreiben weder das Layout noch den gesamten Baum neu zeichnen, wenn das nicht nötig ist. Mit „Compose“ werden nur die den Arbeitsaufwand, der für die Aktualisierung der Benutzeroberfläche erforderlich ist. Diese Optimierung ist möglich da „Compose“ Statuslesevorgänge innerhalb der verschiedenen Phasen verfolgt.

Die Phasen verstehen

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

Komposition

In der Erstellungsphase führt die Compose-Laufzeit zusammensetzbare Funktionen aus 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: Baum, der Ihre Benutzeroberfläche darstellt, die in der Komposition erstellt wird .

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

<ph type="x-smartling-placeholder">
</ph> Ein Code-Snippet mit fünf zusammensetzbaren Funktionen und dem resultierenden UI-Baum, wobei untergeordnete Knoten von ihren übergeordneten Knoten verzweigt werden.
Abbildung 3: Ein Unterabschnitt eines UI-Baums mit dem entsprechenden Code.

In diesen Beispielen ist jede zusammensetzbare Funktion im Code einem einzelnen Layout zugeordnet. Knoten in der Struktur der Benutzeroberfläche. In komplexeren Beispielen können zusammensetzbare Funktionen Logik und den Ablauf zu steuern und eine andere Baumstruktur für verschiedene Zustände zu erstellen.

Layout

In der Layout-Phase verwendet Compose die UI-Struktur, die in der Erstellungsphase erstellt wurde. als Eingabe verwenden. Die Sammlung von Layoutknoten enthält alle Informationen, die für Größe und Position jedes Knotens im 2D-Raum entscheiden.

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

Während der Layout-Phase wird der Baum in den folgenden drei Schritten durchlaufen. Algorithmus:

  1. Untergeordnete Elemente messen: Ein Knoten misst seine untergeordneten Elemente, falls vorhanden.
  2. Eigene Größe festlegen: Auf der Grundlage dieser Messungen entscheidet ein Knoten für sich allein. Größe.
  3. Untergeordnete Knoten platzieren: Jeder untergeordnete Knoten wird relativ zum eigenen Knoten eines Knotens platziert. .

Am Ende dieser Phase hat jeder Layoutknoten Folgendes:

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

Rufen Sie sich noch einmal den UI-Baum aus dem vorherigen Abschnitt ins Gedächtnis:

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

Für diesen Baum funktioniert der Algorithmus so:

  1. Der Row misst die untergeordneten Elemente Image und Column.
  2. Image wird gemessen. Da es keine untergeordneten Elemente hat, entscheidet sie selbst. und gibt die Größe an Row zurück.
  3. Als Nächstes wird Column gemessen. Er misst seine eigenen Kinder (zwei Text zusammensetzbare Funktionen) zuerst.
  4. Die erste Text wird gemessen. Da sie keine untergeordneten Elemente hat, entscheidet sie, und gibt ihre Größe an Column zurück.
    1. Die zweite Text wird gemessen. Da sie keine untergeordneten Elemente hat, entscheidet sie, und gibt sie an Column zurück.
  5. Column verwendet die untergeordneten Messwerte, um seine eigene Größe zu bestimmen. Dabei werden die maximale Breite der untergeordneten Elemente und die Summe der Höhe ihrer untergeordneten Elemente.
  6. In der Column werden die untergeordneten Elemente relativ zu sich selbst platziert. vertikal miteinander verbunden.
  7. Row verwendet die untergeordneten Messwerte, um seine eigene Größe zu bestimmen. Dabei werden die maximale Höhe der untergeordneten Elemente und die Summe der Breiten ihrer untergeordneten Elemente. Dann platziert es ihre untergeordneten Elemente.

Beachten Sie, dass jeder Knoten nur einmal aufgerufen wurde. Für die Compose-Laufzeit ist nur eine den UI-Baum durchlaufen, um alle Knoten zu messen und zu platzieren, die Leistung. Wenn die Anzahl der Knoten in der Baumstruktur zunimmt, ist die aufgewendete Zeit beim Durchlaufen linear zu. Wenn dagegen jeder Knoten erhöht sich die Durchsuchungszeit exponentiell.

Zeichnung

In der Zeichenphase wird der Baum erneut von oben nach unten durchlaufen. zieht sich der Reihe nach auf den Bildschirm.

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

Im vorherigen Beispiel wird der Bauminhalt wie folgt gezeichnet:

  1. Mit Row werden alle Inhalte gezeichnet, die es enthalten kann, z. B. eine Hintergrundfarbe.
  2. Image zieht sich von selbst.
  3. Column zieht sich von selbst.
  4. Die erste und die zweite Text zeichnen sich jeweils selbst.

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

Status-Lesevorgänge

Wenn Sie den Wert eines Snapshot-Status während eines aus den oben genannten Phasen, verfolgt „Compose“ automatisch den Vorgang, der Wert gelesen wurde. Durch diese Verfolgung kann Compose den Reader neu ausführen, ändert sich der Statuswert und bildet die Grundlage für die Beobachtbarkeit des Status in Compose.

Der Status wird in der Regel mit mutableStateOf() erstellt und dann über einen abgerufen auf zwei Arten: durch direkten Zugriff auf die value-Property oder durch mit einem Kotlin-Property-Delegaten. Weitere Informationen hierzu finden Sie unter Status in zusammensetzbare Funktionen. Zum Zweck der „State Read“ (Zustand lesen). bezieht sich auf einen der beiden äquivalenten .

// 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 der Property delegieren, "getter" und „Setter“ werden für den Zugriff auf und die Aktualisierung der value Diese Getter- und Setter-Funktionen werden nur aufgerufen, wenn Sie auf die Eigenschaft als Wert und nicht beim Erstellen. Deshalb gibt es zwei Möglichkeiten, oben gleichwertige Werte.

Jeder Codeblock, der neu ausgeführt werden kann, wenn sich ein Lesestatus ändert, ist einen Neustartbereich. Mit „Compose“ werden Änderungen des Statuswerts verfolgt und neu gestartet in verschiedenen Phasen.

Lesevorgänge in Phasen

Wie bereits erwähnt, gibt es drei Hauptphasen in den Tracks „Komponieren“ und „Komponieren“. welche Status jeweils gelesen werden. So werden über die Funktion "Schreiben" nur für jedes betroffene Element Ihrer UI.

Sehen wir uns die einzelnen Phasen an und beschreiben, was passiert, wenn ein Statuswert gelesen wird. darin enthalten sind.

Phase 1: Zusammensetzung

Statuslesevorgänge innerhalb einer @Composable-Funktion oder eines Lambda-Blocks beeinflussen die Zusammensetzung und möglicherweise auch die weiteren Phasen. Wenn sich der Statuswert ändert, recomposer plant Wiederholungen aller zusammensetzbaren Funktionen, die den Code state-Wert auf. Es kann sein, dass die Laufzeit einige oder alle der zusammensetzbaren Funktionen verwenden, wenn sich die Eingaben nicht geändert haben. Weitere Informationen finden Sie unter Überspringen, wenn die Eingaben nicht geändert.

Abhängig vom Ergebnis der Erstellung werden in der Editor-Benutzeroberfläche das Layout und die Zeichnung ausgeführt. Phasen. Diese Phasen werden möglicherweise übersprungen, wenn der Inhalt und die Größe gleich bleiben. und das Layout ändert sich nicht.

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 Placement. Die wird die Lambda-Messung ausgeführt, die an die zusammensetzbare Funktion Layout übergeben wird. MeasureScope.measure der LayoutModifier-Schnittstelle usw. Die beim Placement-Schritt den Platzierungsblock der layout-Funktion, der Lambda-Funktion, Modifier.offset { … } und so weiter.

Status-Reads während der einzelnen Schritte wirken sich auf das Layout und möglicherweise Zeichenphase. Wenn sich der Statuswert ändert, wird das Layout in der 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 die Schritte zur Messung und die Platzierung separate die Bereiche neu starten, d. h., die Statuslesevorgänge im Placement-Schritt werden nicht noch einmal aufgerufen noch einmal mit der Messung beginnen. Diese beiden Schritte sind jedoch häufig sind miteinander verknüpft, sodass sich ein im Placement-Schritt gelesener Status auf andere Neustarts auswirken kann. die zum Schritt der Messung 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

Statuslesevorgänge während des Zeichencodes wirken sich auf die Zeichenphase aus. Gängige Beispiele umfassen Canvas(), Modifier.drawBehind und Modifier.drawWithContent. Wann? Wenn sich der Statuswert ändert, wird 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)
}

Statuslesevorgänge optimieren

Wenn Compose lokalisierte Status-Lesevorgänge durchführt, können wir die Anzahl der durchgeführt werden, indem jeder Zustand in einer geeigneten Phase gelesen wird.

Sehen wir uns ein Beispiel an. Hier haben wir ein Image(), das den Offset verwendet. , um die endgültige Layoutposition zu verschieben. Dies führt zu einem Parallaxe-Effekt, wenn die Nutzenden scrollen.

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, aber die Leistung ist nicht optimal. Wie bereits erwähnt, liest den Wert des firstVisibleItemScrollOffset-Zustands und übergibt ihn an die Modifier.offset(offset: Dp) . Wenn der Nutzer scrollt, wird der Wert firstVisibleItemScrollOffset ändern können. Wie wir wissen, verfolgt „Compose“ alle Statuslesevorgänge, sodass es neu gestartet werden kann. (re-invoke) des Lesecodes, der in unserem Beispiel der Inhalt der Box

Dies ist ein Beispiel für einen Zustand, der innerhalb der composition-Phase gelesen wird. Dies ist nicht unbedingt eine schlechte Sache und tatsächlich die Grundlage der Neuzusammensetzung, sodass Datenänderungen eine neue Benutzeroberfläche ausgeben.

In diesem Beispiel ist sie jedoch nicht optimal, da jedes Scroll-Ereignis des gesamten zusammensetzbaren Inhalts, der neu bewertet und dann auch gemessen wird. und schließlich gezeichnet werden. Bei jedem Scrollen wird die Erstellungsphase ausgelöst Auch wenn sich die angezeigten Inhalte nicht geändert haben, nur wo sie zu sehen sind. Wir können unseren Zustandslesevorgang so optimieren, dass nur die Layoutphase neu ausgelöst wird.

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

Diese Version verwendet einen Lambda-Parameter, bei dem das resultierende Offset vom mit dem Lambda-Block. Aktualisieren wir nun unseren Code, um sie 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 leistungsstärker? Der Lambdablock, den wir für den Modifikator bereitstellen, ist die während der Layoutphase aufgerufen werden, insbesondere während der im Placement-Schritt). Das bedeutet, dass der Status firstVisibleItemScrollOffset länger lesen können. Mit „Compose“ wird erfasst, wann der Status gelesen wird, bedeutet diese Änderung Folgendes: Wenn sich der Wert firstVisibleItemScrollOffset ändert, Mit der Funktion „Schreiben“ müssen nur die Layout- und Zeichenphasen neu gestartet werden.

Dieses Beispiel basiert auf den verschiedenen Offset-Modifikatoren, um den Generell gilt aber: Versuchen Sie, Statuslesevorgänge in der kürzesten Phase, sodass mit der Funktion „Compose“ so wenig wie möglich arbeiten.

Natürlich ist es oft absolut notwendig, die Stadien in der Komposition zu lesen. . Dennoch gibt es Fälle, in denen wir die Anzahl der indem Sie Statusänderungen filtern. Weitere Informationen hierzu Siehe RelatedStateOf: Konvertieren eines oder mehrerer Statusobjekte in ein anderes Bundesstaat.

Neuzusammensetzungsschleife (zyklische Phasenabhängigkeit)

Wir haben bereits erwähnt, dass die Phasen von „Compose“ immer im selben und dass es keine Möglichkeit gibt, im selben Frame rückwärts zu springen. Das verhindert jedoch nicht, dass Apps in Kompositionsschleifen geraten. in verschiedenen Frames zu sehen. 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 (fehlerhaft) eine vertikale Spalte implementiert, bei der das Bild ganz oben steht, und dann den Text darunter. Wir verwenden Modifier.onSizeChanged(), um zu erfahren, die Größe des Bildes aufgelöst und dann mit Modifier.padding() für den Text und verschieben es nach unten. Die unnatürliche Konvertierung von Px zurück in Dp ist bereits gibt an, dass ein Problem mit dem Code vorliegt.

Das Problem bei diesem Beispiel ist, dass wir nicht zum „endgültigen“ Layout innerhalb von auf einen einzelnen Frame. Der Code basiert auf mehreren Frames, wodurch unnötige Arbeit und führt dazu, dass die Nutzenden auf dem Bildschirm herumspringen.

Gehen wir die einzelnen Frames durch, um zu sehen, was passiert:

In der Zusammensetzungsphase des ersten Frames hat imageHeightPx den Wert 0, und der Text wird mit Modifier.padding(top = 0) angegeben. Dann wird das Layout folgt, und der Callback für den onSizeChanged-Modifikator wird aufgerufen. Dabei wird imageHeightPx auf die tatsächliche Höhe des Bildes aktualisiert. Erstellt einen Plan für die Neuzusammensetzung des nächsten Frames. In der Zeichenphase Text wird mit einem Abstand von 0 gerendert, da die Wertänderung nicht widergespiegelt wird noch nicht.

Compose startet dann den zweiten Frame, der durch die Wertänderung von imageHeightPx Der Status wird im Box-Inhaltsblock eingelesen und aufgerufen in der Phase der Komposition. Dieses Mal wird für den Text ein Abstand an die Bildhöhe anpassen. In der Layoutphase legt der Code den Wert imageHeightPx wieder, aber es ist keine Neuzusammensetzung geplant, da der Wert bleibt gleich.

Am Ende erhalten wir den gewünschten Abstand für den Text, aber es ist nicht optimal, einen zusätzlichen Frame ausgeben, um den Padding-Wert an eine andere Phase zurückzugeben, führt dazu, dass ein Frame mit überlappendem Inhalt erstellt wird.

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

  • Modifier.onSizeChanged(), onGloballyPositioned() oder ein anderes Layout Betriebsabläufe
  • Bestimmten Status aktualisieren
  • Verwenden Sie diesen Status als Eingabe für einen Layoutmodifikator (padding(), height() oder ähnlich)
  • Mögliche Wiederholung

Die Lösung für das obige Beispiel besteht darin, die richtigen Layout-Primitive zu verwenden. Das Beispiel lässt sich mit einem einfachen Column() implementieren. Sie haben jedoch möglicherweise ein komplexes Beispiel, bei dem ein benutzerdefinierter Vorgang erforderlich ist, der das Schreiben einer benutzerdefiniertes Layout. Weitere Informationen finden Sie im Leitfaden zu benutzerdefinierten Layouts. .

Das Prinzip ist hier im Allgemeinen, eine Single Source of Truth für mehrere Benutzeroberflächen Elemente, die gemessen und im Verhältnis zueinander platziert werden sollen. Mit ein geeignetes Layout-Primitive erstellen oder ein benutzerdefiniertes Layout erstellen, ein gemeinsames Elternteil dient als Informationsquelle, die die Beziehung koordinieren kann. zwischen mehreren Elementen. Der dynamische Zustand widerspricht diesem Prinzip.