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 Zusammenstellung.
In der Compose-Dokumentation wird die Zusammensetzung unter Thinking in Compose und State and Jetpack Compose beschrieben.
Die drei Phasen eines Frames
Compose hat drei Hauptphasen:
- Zusammensetzung: Welche Benutzeroberfläche soll angezeigt werden? Compose führt zusammensetzbare Funktionen aus und erstellt eine Beschreibung Ihrer Benutzeroberfläche.
- Layout: Wo die Benutzeroberfläche platziert werden soll. Diese Phase besteht aus zwei Schritten: Analyse und Platzierung. Layout-Elemente messen und platzieren sich selbst und alle untergeordneten Elemente in 2D-Koordinaten für jeden Knoten im Layoutbaum.
- Zeichnung: So wird sie gerendert. UI-Elemente werden auf einem Canvas gezeichnet, in der Regel auf einem Gerätebildschirm.
Die Reihenfolge dieser Phasen ist in der Regel dieselbe, sodass Daten in eine Richtung von der Komposition über das Layout bis zum Zeichnen fließen, 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 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-Layout nicht neu berechnet und der gesamte Baum nicht neu gezeichnet, wenn das 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 Statuslesevorgänge 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:
In diesen Beispielen entspricht jede zusammensetzbare Funktion im Code einem einzelnen Layoutknoten im UI-Baum. 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:
- Untergeordnete Elemente messen: Ein Knoten misst seine untergeordneten Elemente, sofern vorhanden.
- Eigene Größe festlegen: Anhand dieser Messungen legt ein Knoten seine eigene Größe fest.
- 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:
Für diesen Baum funktioniert der Algorithmus so:
- Die
Rowmisst dieImageundColumnihrer untergeordneten Elemente. - Die
Imagewird gemessen. Es hat keine untergeordneten Elemente, daher wird die Größe selbst bestimmt und an dasRowzurückgemeldet. - Als Nächstes wird
Columngemessen. Die Größe der eigenen untergeordneten Elemente (zweiTextComposables) wird zuerst gemessen. - Die erste
Textwird gemessen. Da es keine untergeordneten Elemente hat, bestimmt es seine eigene Größe und meldet sie an dasColumnzurück.- Die zweite
Textwird gemessen. Es hat keine untergeordneten Elemente, daher bestimmt es seine eigene Größe und meldet sie anColumnzurück.
- Die zweite
- Die
Columnrichtet 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. - Die
Columnpositioniert ihre untergeordneten Elemente relativ zu sich selbst und platziert sie vertikal untereinander. - Die
Rowrichtet sich bei der Größenbestimmung nach den Maßen des Kindes. Dazu wird 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.
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 dargestellt.
Im vorherigen Beispiel wird der Inhalt des Baums so dargestellt:
- Das
Rowrendert alle Inhalte, die es möglicherweise enthält, z. B. eine Hintergrundfarbe. - Das
Imagewird automatisch gezeichnet. - Das
Columnwird automatisch gezeichnet. - Das erste und zweite
Textwerden jeweils selbst gezeichnet.
Abbildung 6. Ein UI-Baum und seine gerenderte Darstellung.
Statuslesevorgänge
Wenn Sie die value eines snapshot state in einer der oben aufgeführten Phasen lesen, verfolgt Compose automatisch, was es beim Lesen der value getan hat. Durch diese Nachverfolgung kann Compose den Reader neu ausführen, wenn sich der value des Status ändert. Sie ist die Grundlage für die Statusbeobachtbarkeit in Compose.
Der Status wird in der Regel mit mutableStateOf() erstellt und dann auf eine von zwei Arten aufgerufen: entweder durch direkten Zugriff auf die value-Property oder durch Verwendung eines Kotlin-Property-Delegaten. Weitere Informationen dazu finden Sie unter Status in Composables. In diesem Leitfaden bezieht sich „Status lesen“ auf eine dieser 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.
Phasenzustandslesevorgä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 Phasen benachrichtigen, die für jedes betroffene Element Ihrer Benutzeroberfläche Arbeit leisten müssen.
In den folgenden Abschnitten werden die einzelnen Phasen und die Vorgänge beim Lesen eines Statuswerts beschrieben.
Phase 1: Komposition
Statuslesevorgänge in einer @Composable-Funktion oder einem Lambda-Block wirken sich auf die Komposition 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 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 die Measure-Lambda-Funktion ausgeführt, die an die zusammensetzbare Funktion Layout übergeben wurde, sowie die Methode MeasureScope.measure der LayoutModifier-Schnittstelle.
Im Platzierungsschritt wird der Platzierungsblock der Funktion layout, der Lambda-Block von Modifier.offset { … } und ähnliche Funktionen ausgeführt.
Statuslesevorgänge während der einzelnen Schritte wirken 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 Größe oder Position geändert haben.
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 Zeichnungscodes wirken sich auf die Zeichnungsphase 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) }
Lesevorgänge für den Optimize-Status
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(), das den Offset-Modifikator verwendet, um seine 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 Neukomposition, 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 bewertet, 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. 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 in 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.
Recomposition-Schleife (zyklische Phasenabhängigkeit)
In diesem Leitfaden wurde zuvor erwähnt, dass die Phasen von Compose immer in derselben Reihenfolge aufgerufen werden und dass es nicht möglich ist, 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 { 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. 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 für den Nutzer auf dem Bildschirm hin- und herspringt.
Zusammensetzung des ersten Frames
Während der Kompositionsphase des ersten Frames ist imageHeightPx anfangs 0. Daher wird der Text mit Modifier.padding(top = 0) bereitgestellt.
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 Padding versehen, das genau der Höhe des Bildes entspricht. Während der Layoutphase wird imageHeightPx noch einmal festgelegt. Es wird jedoch keine weitere Neukomposition geplant, da der Wert gleich bleibt.
Dieses Beispiel mag gekünstelt erscheinen, aber Vorsicht vor diesem allgemeinen Muster:
Modifier.onSizeChanged(),onGloballyPositioned()oder andere Layoutvorgänge- Status 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. Durch die Einführung eines dynamischen Status wird dieses Prinzip verletzt.
Weitere Informationen zu Recomposition-Schleifen und dazu, wie Sie vermeiden, dass in verschiedenen Phasen in den Status geschrieben wird, finden Sie unter Rückwärtsschreiben in Compose.
Empfehlungen für Sie
- Hinweis: Linktext wird angezeigt, wenn JavaScript deaktiviert ist
- Zustand und Jetpack Compose
- Listen und Raster
- Kotlin für Jetpack Compose