Denken in Textform

Jetpack Compose ist ein modernes deklaratives UI-Toolkit für Android. Compose vereinfacht das Schreiben und Verwalten der Benutzeroberfläche Ihrer App, indem es eine deklarative API bereitstellt, mit der Sie die Benutzeroberfläche Ihrer App rendern können, ohne Frontend-Ansichten imperativ zu verändern. Diese Begriffe müssen etwas erläutert werden, aber die Auswirkungen sind für das Design Ihrer Anwendung wichtig.

Das deklarative Programmierparadigma

Bisher konnte eine Android-Ansichtshierarchie als Baumstruktur von UI-Widgets dargestellt werden. Wenn sich der Status der App aufgrund von Nutzerinteraktionen ändert, muss die UI-Hierarchie aktualisiert werden, damit die aktuellen Daten angezeigt werden. Die gängigste Methode zum Aktualisieren der Benutzeroberfläche besteht darin, den Baum mit Funktionen wie findViewById() zu durchlaufen und Knoten durch Aufrufen von Methoden wie button.setText(String), container.addChild(View) oder img.setImageBitmap(Bitmap) zu ändern. Diese Methoden ändern den internen Status des Widgets.

Das manuelle Bearbeiten von Ansichten erhöht die Wahrscheinlichkeit von Fehlern. Wenn ein Datenelement an mehreren Stellen gerendert wird, vergessen Sie möglicherweise, eine der Ansichten zu aktualisieren, in denen es angezeigt wird. Dies kann auch zu unzulässigen Zuständen führen, wenn zwei Aktualisierungen auf unerwartete Weise in Konflikt geraten. Beispielsweise könnte ein Update versuchen, einen Wert eines Knotens festzulegen, der gerade aus der Benutzeroberfläche entfernt wurde. Im Allgemeinen steigt die Komplexität der Softwarewartung mit der Anzahl der Ansichten, die aktualisiert werden müssen.

In den letzten Jahren hat sich die gesamte Branche auf ein deklaratives UI-Modell umgestellt, das das Erstellen und Aktualisieren von Benutzeroberflächen vereinfacht.

Bei dieser Technik wird der gesamte Bildschirm konzeptionell neu generiert und dann werden nur die erforderlichen Änderungen angewendet. Mit diesem Ansatz wird die Komplexität der manuellen Aktualisierung einer zustandsorientierten Ansichtshierarchie vermieden. Compose ist ein deklaratives UI-Framework.

Eine Herausforderung bei der Regenerierung des gesamten Bildschirms besteht darin, dass dies potenziell teuer ist, was Zeit, Rechenleistung und Akkuverbrauch betrifft. Um diese Kosten zu minimieren, wählt Compose intelligent aus, welche Teile der Benutzeroberfläche zu einem bestimmten Zeitpunkt neu gezeichnet werden müssen. Dies hat einige Auswirkungen auf die Gestaltung Ihrer UI-Komponenten, wie unter Neuzusammensetzung beschrieben.

Beispiel für eine zusammensetzbare Funktion

Mit Compose können Sie Ihre Benutzeroberfläche erstellen, indem Sie eine Reihe von komponierbaren Funktionen definieren, die Daten aufnehmen und UI-Elemente ausgeben. Ein Beispiel ist ein Greeting-Widget, das ein String-Widget annimmt und ein Text-Widget ausgibt, das eine Begrüßungsnachricht anzeigt.

Ein Smartphone mit dem Text „Hello World“ und dem Code für die zusammensetzbare Funktion, die diese Benutzeroberfläche generiert.
Abbildung 1. Eine zusammensetzbare Funktion, an die Daten übergeben werden und die diese verwendet, um ein Text-Widget auf dem Bildschirm zu rendern.

Einige wichtige Hinweise zu dieser Funktion:

  • Annotation: Die Funktion wird mit der Annotation @Composable annotiert. Alle zusammensetzbaren Funktionen müssen diese Annotation haben. Diese Annotation informiert den Compose-Compiler darüber, dass diese Funktion Daten in eine UI umwandeln soll.
  • Dateneingabe: Die Funktion nimmt Daten auf. Komponierbare Funktionen können Parameter akzeptieren, mit denen die App-Logik die Benutzeroberfläche beschreiben kann. In diesem Fall akzeptiert unser Widget einen String, damit der Nutzer namentlich begrüßt werden kann.
  • UI-Anzeige: Die Funktion zeigt Text in der UI an. Dazu wird die zusammensetzbare Funktion Text() aufgerufen, die das Text-UI-Element erstellt. Komponierbare Funktionen geben die UI-Hierarchie aus, indem sie andere komponierbare Funktionen aufrufen.
  • Kein Rückgabewert: Die Funktion gibt nichts zurück. Compose-Funktionen, die eine UI ausgeben, müssen nichts zurückgeben, da sie den Zielbildschirmstatus beschreiben, anstatt UI-Widgets zu erstellen.
  • Eigenschaften: Diese Funktion ist schnell, idempotent und frei von Nebenwirkungen.

    • Die Funktion verhält sich bei mehreren Aufrufen mit demselben Argument gleich und verwendet keine anderen Werte wie globale Variablen oder Aufrufe von random().
    • Die Funktion beschreibt die Benutzeroberfläche ohne Nebenwirkungen, z. B. das Ändern von Attributen oder globalen Variablen.

    Im Allgemeinen müssen alle zusammensetzbaren Funktionen mit diesen Eigenschaften geschrieben werden. Die Gründe dafür werden unter Neuzusammensetzung erläutert.

Der deklarative Paradigmenwechsel

Bei vielen imperativen objektorientierten UI-Toolkits initialisieren Sie die UI, indem Sie einen Baum von Widgets instanziieren. Dazu wird häufig eine XML-Layoutdatei aufgebläht. Jedes Widget hat einen eigenen internen Status und stellt Getter- und Setter-Methoden bereit, über die die App-Logik mit dem Widget interagieren kann.

Beim deklarativen Ansatz von Compose sind Widgets relativ zustandslos und stellen keine Setter- oder Getter-Funktionen bereit. Widgets werden nicht als Objekte bereitgestellt. Sie aktualisieren die UI, indem Sie dieselbe zusammensetzbare Funktion mit anderen Argumenten aufrufen. Dies vereinfacht die Bereitstellung des Status für Architekturmuster wie ViewModel, wie im Leitfaden zur App-Architektur beschrieben. Ihre Composables sind dann dafür verantwortlich, den aktuellen Anwendungsstatus jedes Mal in eine Benutzeroberfläche umzuwandeln, wenn die beobachtbaren Daten aktualisiert werden.

Abbildung des Datenflusses in einer Compose-UI, von übergeordneten Objekten bis zu ihren untergeordneten Elementen.
Abbildung 2: Die App-Logik stellt der zusammensetzbaren Funktion auf oberster Ebene Daten zur Verfügung. Diese Funktion verwendet die Daten, um die Benutzeroberfläche zu beschreiben, indem sie andere Composables aufruft und die entsprechenden Daten an diese Composables und weiter unten in der Hierarchie übergibt.

Wenn der Nutzer mit der Benutzeroberfläche interagiert, löst die Benutzeroberfläche Ereignisse wie onClick aus. Diese Ereignisse sollten die App-Logik benachrichtigen, die dann den Status der App ändern kann. Wenn sich der Status ändert, werden die zusammensetzbaren Funktionen noch einmal mit den neuen Daten aufgerufen. Dadurch werden die UI-Elemente neu gezeichnet. Dieser Vorgang wird als Neuzusammensetzung bezeichnet.

Abbildung, die zeigt, wie UI-Elemente auf Interaktionen reagieren, indem sie Ereignisse auslösen, die von der App-Logik verarbeitet werden.
Abbildung 3: Der Nutzer hat mit einem UI-Element interagiert, wodurch ein Ereignis ausgelöst wurde. Die App-Logik reagiert auf das Ereignis und die zusammensetzbaren Funktionen werden bei Bedarf automatisch mit neuen Parametern aufgerufen.

Dynamischer Inhalt

Da zusammensetzbare Funktionen in Kotlin statt in XML geschrieben werden, können sie so dynamisch wie jeder andere Kotlin-Code sein. Angenommen, Sie möchten eine Benutzeroberfläche erstellen, die eine Liste von Nutzern begrüßt:

@Composable
fun Greeting(names: List<String>) {
    for (name in names) {
        Text("Hello $name")
    }
}

Diese Funktion nimmt eine Liste von Namen entgegen und generiert eine Begrüßung für jeden Nutzer. Komponierbare Funktionen können sehr komplex sein. Mit if-Anweisungen können Sie entscheiden, ob ein bestimmtes UI-Element angezeigt werden soll. Sie können Schleifen verwenden. Sie können Hilfsfunktionen aufrufen. Sie haben die volle Flexibilität der zugrunde liegenden Sprache. Diese Leistungsfähigkeit und Flexibilität ist einer der Hauptvorteile von Jetpack Compose.

Neukomposition

In einem imperativen UI-Modell rufen Sie zum Ändern eines Widgets einen Setter für das Widget auf, um seinen internen Status zu ändern. In Compose rufen Sie die zusammensetzbare Funktion noch einmal mit neuen Daten auf. Dadurch wird die Funktion neu zusammengesetzt. Die von der Funktion ausgegebenen Widgets werden bei Bedarf mit neuen Daten neu gezeichnet. Das Compose-Framework kann nur die Komponenten, die sich geändert haben, intelligent neu zusammensetzen.

Sehen Sie sich beispielsweise diese zusammensetzbare Funktion an, die eine Schaltfläche anzeigt:

@Composable
fun ClickCounter(clicks: Int, onClick: () -> Unit) {
    Button(onClick = onClick) {
        Text("I've been clicked $clicks times")
    }
}

Jedes Mal, wenn auf die Schaltfläche geklickt wird, aktualisiert der Aufrufer den Wert von clicks. Compose ruft die Lambda-Funktion mit der Funktion Text noch einmal auf, um den neuen Wert anzuzeigen. Dieser Vorgang wird als Neuzusammensetzung bezeichnet. Andere Funktionen, die nicht vom Wert abhängen, werden nicht neu zusammengesetzt.

Wie bereits erwähnt, kann die Neuzusammensetzung des gesamten UI-Baums rechenintensiv sein, was Rechenleistung und Akkulaufzeit verbraucht. Compose löst dieses Problem mit der intelligenten Neuzusammensetzung.

Die Neuzusammensetzung ist der Prozess, bei dem Ihre zusammensetzbaren Funktionen noch einmal aufgerufen werden, wenn sich die Eingaben ändern. Wenn Compose basierend auf neuen Eingaben eine Neuzusammensetzung vornimmt, werden nur die Funktionen oder Lambdas aufgerufen, die sich möglicherweise geändert haben. Der Rest wird übersprungen. Durch das Überspringen von Funktionen oder Lambdas mit unveränderten Parametern wird die Neuzusammensetzung in Compose effizienter.

Verlassen Sie sich nie auf Nebenwirkungen, die durch die Ausführung zusammensetzbarer Funktionen entstehen, da die Neuzusammensetzung einer Funktion übersprungen werden kann. Andernfalls kann es zu einem merkwürdigen und unvorhersehbaren Verhalten in Ihrer App kommen. Ein Nebeneffekt ist jede Änderung, die für den Rest Ihrer App sichtbar ist. Die folgenden Aktionen sind beispielsweise alle gefährliche Nebeneffekte:

  • In ein Attribut eines freigegebenen Objekts schreiben
  • Observable in ViewModel aktualisieren
  • Gemeinsame Einstellungen werden aktualisiert

Komponierbare Funktionen können bei jedem Frame neu ausgeführt werden, z. B. wenn eine Animation gerendert wird. Komponierbare Funktionen sollten schnell sein, um Ruckeln bei Animationen zu vermeiden. Wenn Sie aufwendige Vorgänge ausführen müssen, z. B. das Lesen aus freigegebenen Einstellungen, tun Sie dies in einer Hintergrund-Koroutine und übergeben Sie das Wertergebnis als Parameter an die zusammensetzbare Funktion.

In diesem Beispiel wird ein Composable erstellt, um einen Wert in SharedPreferences zu aktualisieren. Das Compose-Element sollte nicht selbst aus den freigegebenen Einstellungen lesen oder in diese schreiben. Stattdessen werden die Lese- und Schreibvorgänge in eine ViewModel in einer Hintergrund-Koroutine verschoben. Die Anwendungslogik übergibt den aktuellen Wert mit einem Callback, um eine Aktualisierung auszulösen.

@Composable
fun SharedPrefsToggle(
    text: String,
    value: Boolean,
    onValueChanged: (Boolean) -> Unit
) {
    Row {
        Text(text)
        Checkbox(checked = value, onCheckedChange = onValueChanged)
    }
}

In diesem Dokument werden einige Punkte erläutert, die Sie bei der Verwendung von Compose beachten sollten:

  • Bei der Neuzusammensetzung werden so viele zusammensetzbare Funktionen und Lambdas wie möglich übersprungen.
  • Die Neuzusammensetzung ist optimistisch und kann abgebrochen werden.
  • Eine zusammensetzbare Funktion kann sehr häufig ausgeführt werden, sogar für jeden Frame einer Animation.
  • Komponierbare Funktionen können parallel ausgeführt werden.
  • Komponierbare Funktionen können in beliebiger Reihenfolge ausgeführt werden.

In den folgenden Abschnitten wird beschrieben, wie Sie zusammensetzbare Funktionen erstellen, die die Neuzusammensetzung unterstützen. In jedem Fall empfiehlt es sich, zusammensetzbare Funktionen schnell, idempotent und frei von Nebenwirkungen zu halten.

Die Neuzusammensetzung überspringt so viel wie möglich

Wenn Teile der UI ungültig sind, versucht Compose, nur die Teile neu zu erstellen, die aktualisiert werden müssen. Das bedeutet, dass die erneute Ausführung eines einzelnen Composables von Button übersprungen werden kann, ohne dass Composables, die sich im UI-Baum weiter oben oder unten befinden, ausgeführt werden.

Jede zusammensetzbare Funktion und Lambda-Funktion kann sich selbst neu zusammensetzen. Das folgende Beispiel zeigt, wie bei der Neuzusammensetzung einige Elemente beim Rendern einer Liste übersprungen werden können:

/**
 * Display a list of names the user can click with a header
 */
@Composable
fun NamePicker(
    header: String,
    names: List<String>,
    onNameClicked: (String) -> Unit
) {
    Column {
        // this will recompose when [header] changes, but not when [names] changes
        Text(header, style = MaterialTheme.typography.bodyLarge)
        HorizontalDivider()

        // LazyColumn is the Compose version of a RecyclerView.
        // The lambda passed to items() is similar to a RecyclerView.ViewHolder.
        LazyColumn {
            items(names) { name ->
                // When an item's [name] updates, the adapter for that item
                // will recompose. This will not recompose when [header] changes
                NamePickerItem(name, onNameClicked)
            }
        }
    }
}

/**
 * Display a single name the user can click.
 */
@Composable
private fun NamePickerItem(name: String, onClicked: (String) -> Unit) {
    Text(name, Modifier.clickable(onClick = { onClicked(name) }))
}

Jeder dieser Bereiche ist möglicherweise das Einzige, was während einer Neuzusammensetzung ausgeführt wird. Compose kann zum Lambda-Ausdruck Column springen, ohne die übergeordneten Elemente auszuführen, wenn sich header ändert. Wenn Column ausgeführt wird, kann Compose die Elemente von LazyColumn überspringen, wenn sich names nicht geändert hat.

Auch hier gilt: Alle zusammensetzbaren Funktionen oder Lambdas sollten frei von Nebenwirkungen sein. Wenn Sie einen Nebeneffekt ausführen müssen, lösen Sie ihn über einen Callback aus.

Die Neuzusammensetzung ist optimistisch

Die Neuzusammensetzung beginnt immer dann, wenn Compose davon ausgeht, dass sich die Parameter einer zusammensetzbaren Funktion geändert haben könnten. Die Neuzusammensetzung ist optimistisch. Das bedeutet, dass Compose davon ausgeht, dass die Neuzusammensetzung abgeschlossen ist, bevor sich die Parameter wieder ändern. Wenn sich ein Parameter vor Abschluss der Neuzusammensetzung ändert, bricht Compose die Neuzusammensetzung möglicherweise ab und startet sie mit dem neuen Parameter neu.

Wenn die Neuzusammensetzung abgebrochen wird, verwirft Compose den UI-Baum aus der Neuzusammensetzung. Wenn Sie Nebenwirkungen haben, die von der angezeigten Benutzeroberfläche abhängen, wird die Nebenwirkung auch dann angewendet, wenn die Komposition abgebrochen wird. Dies kann zu einem inkonsistenten App-Status führen.

Prüfen Sie, ob alle zusammensetzbaren Funktionen und Lambdas idempotent und frei von Nebenwirkungen sind, um die optimistische Neuzusammensetzung zu verarbeiten.

Komponierbare Funktionen werden möglicherweise recht häufig ausgeführt

In einigen Fällen wird eine zusammensetzbare Funktion möglicherweise für jeden Frame einer UI-Animation ausgeführt. Wenn die Funktion rechenintensive Vorgänge ausführt, z. B. das Lesen aus dem Gerätespeicher, kann sie zu UI-Rucklern führen.

Wenn dein Widget beispielsweise versucht, Geräteeinstellungen zu lesen, könnte es diese Einstellungen möglicherweise hunderte Male pro Sekunde lesen, was sich katastrophal auf die Leistung deiner App auswirken würde.

Wenn eine zusammensetzbare Funktion Daten benötigt, definieren Sie Parameter für diese Daten. Anschließend können Sie rechenintensive Aufgaben in einen anderen Thread außerhalb der Komposition verschieben und den resultierenden Wert mit mutableStateOf oder LiveData als Parameter an die zusammensetzbare Funktion übergeben.

Komponierbare Funktionen können parallel ausgeführt werden

Compose kann die Neuzusammensetzung optimieren, indem zusammensetzbare Funktionen parallel ausgeführt werden. So kann Compose mehrere Kerne nutzen und zusammensetzbare Funktionen, die nicht auf dem Bildschirm angezeigt werden, mit einer niedrigeren Priorität ausführen.

Diese Optimierung würde bedeuten, dass eine zusammensetzbare Funktion in einem Pool von Hintergrundthreads ausgeführt werden könnte. Wenn eine zusammensetzbare Funktion eine Funktion für ein ViewModel aufruft, kann Compose diese Funktion von mehreren Threads gleichzeitig aufrufen.

Damit die Anwendung ordnungsgemäß funktioniert, sollten alle zusammensetzbaren Funktionen keine Nebenwirkungen haben. Lösen Sie stattdessen Nebenwirkungen über Callbacks wie onClick aus, die immer im UI-Thread ausgeführt werden.

Wenn eine zusammensetzbare Funktion aufgerufen wird, kann der Aufruf in einem anderen Thread als dem des Aufrufers erfolgen. Das bedeutet, dass Code, der Variablen in einem zusammensetzbaren Lambda ändert, vermieden werden sollte, da ein solcher Code nicht threadsicher ist und eine unzulässige Nebenwirkung des zusammensetzbaren Lambdas darstellt.

Hier ist ein Beispiel für eine zusammensetzbare Funktion, die eine Liste und die Anzahl der Elemente anzeigt:

@Composable
fun ListComposable(myList: List<String>) {
    Row(horizontalArrangement = Arrangement.SpaceBetween) {
        Column {
            for (item in myList) {
                Text("Item: $item")
            }
        }
        Text("Count: ${myList.size}")
    }
}

Dieser Code ist frei von Nebenwirkungen und wandelt die Eingabeliste in eine Benutzeroberfläche um. Das ist ein guter Code, um eine kleine Liste anzuzeigen. Wenn die Funktion jedoch in eine lokale Variable schreibt, ist dieser Code nicht threadsicher oder korrekt:

@Composable
fun ListWithBug(myList: List<String>) {
    var items = 0

    Row(horizontalArrangement = Arrangement.SpaceBetween) {
        Column {
            for (item in myList) {
                Card {
                    Text("Item: $item")
                    items++ // Avoid! Side-effect of the column recomposing.
                }
            }
        }
        Text("Count: $items")
    }
}

In diesem Beispiel wird items bei jeder Neuzusammensetzung geändert. Das kann jeder Frame einer Animation sein oder wenn die Liste aktualisiert wird. In beiden Fällen wird auf der Benutzeroberfläche die falsche Anzahl angezeigt. Aus diesem Grund werden solche Schreibvorgänge in Compose nicht unterstützt. Durch das Verbot dieser Schreibvorgänge ermöglichen wir es dem Framework, Threads zu ändern, um zusammensetzbare Lambdas auszuführen.

Komponierbare Funktionen können in beliebiger Reihenfolge ausgeführt werden

Wenn Sie sich den Code für eine zusammensetzbare Funktion ansehen, gehen Sie möglicherweise davon aus, dass der Code in der Reihenfolge ausgeführt wird, in der er angezeigt wird. Das ist aber nicht unbedingt der Fall. Wenn eine zusammensetzbare Funktion Aufrufe anderer zusammensetzbarer Funktionen enthält, können diese Funktionen in beliebiger Reihenfolge ausgeführt werden. Compose kann erkennen, dass einige UI-Elemente eine höhere Priorität haben als andere, und diese zuerst zeichnen.

Angenommen, Sie haben Code wie diesen, um drei Bildschirme in einem Tab-Layout zu zeichnen:

@Composable
fun ButtonRow() {
    MyFancyNavigation {
        StartScreen()
        MiddleScreen()
        EndScreen()
    }
}

Die Aufrufe von StartScreen, MiddleScreen und EndScreen können in beliebiger Reihenfolge erfolgen. Das bedeutet, dass Sie beispielsweise nicht StartScreen() eine globale Variable (einen Nebeneffekt) festlegen und MiddleScreen() diese Änderung nutzen lassen können. Stattdessen muss jede dieser Funktionen eigenständig sein.

Weitere Informationen

Weitere Informationen zum Denken in Compose und zu zusammensetzbaren Funktionen findest du in den folgenden zusätzlichen Ressourcen.

Videos