Denken in Textform

Jetpack Compose ist ein modernes deklaratives UI-Toolkit für Android. Compose erleichtert das Schreiben und Verwalten der Benutzeroberfläche Ihrer App, da es eine deklarative API bietet, mit der Sie die Benutzeroberfläche Ihrer App rendern können, ohne Frontend-Ansichten imperativ zu ändern. Diese Terminologie muss erläutert werden, aber die Auswirkungen sind wichtig für das Design Ihrer App.

Das deklarative Programmierparadigma

Bisher wurde eine Android-View-Hierarchie als Baum von UI-Widgets dargestellt. Wenn sich der Status der App ändert, z. B. durch Nutzerinteraktionen, muss die UI-Hierarchie aktualisiert werden, um die aktuellen Daten anzuzeigen. 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.

Wenn Sie Ansichten manuell bearbeiten, steigt die Wahrscheinlichkeit von Fehlern. Wenn Daten an mehreren Stellen gerendert werden, kann es leicht passieren, dass Sie vergessen, eine der Ansichten zu aktualisieren, in der sie angezeigt werden. Außerdem können leicht ungültige Zustände entstehen, wenn zwei Updates auf unerwartete Weise in Konflikt geraten. Beispiel: Bei einer Aktualisierung wird versucht, einen Wert für einen Knoten 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 die Entwicklung und Aktualisierung von Benutzeroberflächen erheblich 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 zustandsbehafteten Ansichtshierarchie vermieden. Compose ist ein deklaratives UI-Framework.

Eine Herausforderung bei der Neuerstellung des gesamten Bildschirms besteht darin, dass dies in Bezug auf Zeit, Rechenleistung und Akkunutzung potenziell aufwendig ist. 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 das Design Ihrer UI-Komponenten, wie im Abschnitt Recomposition beschrieben.

Eine einfache komponierbare Funktion

Mit Compose können Sie Ihre Benutzeroberfläche erstellen, indem Sie eine Reihe von zusammensetzbaren Funktionen definieren, die Daten entgegennehmen und UI-Elemente ausgeben. Ein einfaches Beispiel ist ein Greeting-Widget, das ein String entgegennimmt und ein Text-Widget ausgibt, in dem eine Begrüßungsnachricht angezeigt wird.

Ein Screenshot eines Smartphones, auf dem der Text „Hello World“ angezeigt wird, und der Code für die einfache zusammensetzbare Funktion, mit der diese Benutzeroberfläche generiert wird

Abbildung 1: Eine einfache zusammensetzbare Funktion, der Daten übergeben werden und die damit ein Text-Widget auf dem Bildschirm rendert.

Einige wichtige Punkte zu dieser Funktion:

  • Die Funktion wird mit der Anmerkung @Composable annotiert. Alle zusammensetzbaren Funktionen müssen diese Annotation haben. Sie informiert den Compose-Compiler darüber, dass diese Funktion Daten in die Benutzeroberfläche konvertieren soll.

  • Die Funktion nimmt Daten entgegen. Composable-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 mit seinem Namen begrüßt werden kann.

  • Die Funktion zeigt Text in der Benutzeroberfläche an. Dazu wird die zusammensetzbare Funktion Text() aufgerufen, die das Text-UI-Element erstellt. Composable-Funktionen geben die UI-Hierarchie aus, indem sie andere Composable-Funktionen aufrufen.

  • Die Funktion gibt nichts zurück. Compose-Funktionen, die UI ausgeben, müssen nichts zurückgeben, da sie den gewünschten Bildschirmstatus beschreiben, anstatt UI-Widgets zu erstellen.

  • Diese Funktion ist schnell, idempotent und hat keine Nebeneffekte.

    • Die Funktion verhält sich gleich, wenn sie mehrmals mit demselben Argument aufgerufen wird. Sie verwendet keine anderen Werte wie globale Variablen oder Aufrufe von random().
    • Die Funktion beschreibt die Benutzeroberfläche ohne Nebeneffekte wie das Ändern von Eigenschaften oder globalen Variablen.

    Im Allgemeinen sollten alle zusammensetzbaren Funktionen mit diesen Eigenschaften geschrieben werden. Die Gründe dafür werden im Abschnitt Neuaufbau erläutert.

Der Paradigmenwechsel hin zu deklarativen Ansätzen

Bei vielen imperativen objektorientierten UI-Toolkits initialisieren Sie die Benutzeroberfläche, indem Sie einen Baum von Widgets instanziieren. Dazu wird häufig eine XML-Layoutdatei aufgeblasen. Jedes Widget verwaltet seinen eigenen internen Status und stellt Getter- und Setter-Methoden bereit, mit denen die App-Logik mit dem Widget interagieren kann.

Im deklarativen Ansatz von Compose sind Widgets relativ zustandslos und bieten keine Setter- oder Getter-Funktionen. Tatsächlich werden Widgets nicht als Objekte verfügbar gemacht. Sie aktualisieren die Benutzeroberfläche, indem Sie dieselbe zusammensetzbare Funktion mit anderen Argumenten aufrufen. So lässt sich der Status für Architekturmuster wie ViewModel einfach bereitstellen, wie im Leitfaden zur App-Architektur beschrieben. Ihre Composables sind dann dafür verantwortlich, den aktuellen Anwendungsstatus jedes Mal in eine Benutzeroberfläche zu transformieren, wenn die beobachtbaren Daten aktualisiert werden.

Abbildung des Datenflusses in einer Compose-Benutzeroberfläche, von Objekten auf hoher Ebene bis hin zu ihren untergeordneten Elementen.

Abbildung 2: Die App-Logik stellt Daten für die Composable-Funktion der obersten Ebene bereit. 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 nach unten in der Hierarchie übergibt.

Wenn der Nutzer mit der Benutzeroberfläche interagiert, werden Ereignisse wie onClick ausgelöst. Diese Ereignisse sollten die App-Logik benachrichtigen, die dann den Status der App ändern kann. Wenn sich der Status ändert, werden die zusammensetzbaren Funktionen mit den neuen Daten noch einmal 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. Die zusammensetzbaren Funktionen werden dann bei Bedarf automatisch mit neuen Parametern aufgerufen.

Dynamischer Content

Da zusammensetzbare Funktionen in Kotlin statt in XML geschrieben werden, können sie so dynamisch sein wie jeder andere Kotlin-Code. 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 für jeden Nutzer eine Begrüßung. Composable-Funktionen können sehr komplex sein. Mit if-Anweisungen können Sie festlegen, 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 sind einer der Hauptvorteile von Jetpack Compose.

Neuzusammensetzung

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 neu zusammensetzen, die sich geändert haben.

Sehen Sie sich beispielsweise diese zusammensetzbare Funktion an, mit der eine Schaltfläche angezeigt wird:

@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 das Neuzusammenstellen des gesamten UI-Baums rechenintensiv sein und somit Rechenleistung und Akkulaufzeit beanspruchen. Compose löst dieses Problem mit der intelligenten Neugestaltung.

Bei der Neukomposition werden Ihre zusammensetzbaren Funktionen noch einmal aufgerufen, wenn sich die Eingaben ändern. Dies geschieht, wenn sich die Eingaben der Funktion ändern. Wenn Compose aufgrund neuer Eingaben neu zusammengesetzt wird, werden nur die Funktionen oder Lambdas aufgerufen, die sich möglicherweise geändert haben. Der Rest wird übersprungen. Wenn alle Funktionen oder Lambdas mit unveränderten Parametern übersprungen werden, kann Compose effizient neu zusammengesetzt werden.

Verlassen Sie sich niemals auf Nebeneffekte, die durch das Ausführen zusammensetzbarer Funktionen entstehen, da die Neuzusammensetzung einer Funktion übersprungen werden kann. Andernfalls kann es zu unerwartetem Verhalten in Ihrer App kommen. Eine Nebenwirkung ist jede Änderung, die für den Rest Ihrer App sichtbar ist. Die folgenden Aktionen sind beispielsweise gefährliche Nebenwirkungen:

  • In eine Property eines freigegebenen Objekts schreiben
  • Aktualisieren eines beobachtbaren Elements in ViewModel
  • Gemeinsame Einstellungen werden aktualisiert

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

Mit diesem Code wird beispielsweise eine Composable-Funktion erstellt, um einen Wert in SharedPreferences zu aktualisieren. Die Composable sollte nicht selbst aus freigegebenen Einstellungen lesen oder in sie schreiben. Stattdessen werden mit diesem Code die Lese- und Schreibvorgänge in eine ViewModel in einer Hintergrund-Coroutine verschoben. Die App-Logik ü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 Dinge beschrieben, die Sie bei der Verwendung von Compose beachten sollten:

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

In den folgenden Abschnitten wird beschrieben, wie Sie zusammensetzbare Funktionen erstellen, die die Neukomposition unterstützen. In jedem Fall ist es am besten, Ihre zusammensetzbaren Funktionen schnell, idempotent und ohne Nebeneffekte zu halten.

Bei der Neukomposition werden so viele Elemente wie möglich übersprungen

Wenn Teile Ihrer Benutzeroberfläche ungültig sind, versucht Compose, nur die Teile neu zu rendern, die aktualisiert werden müssen. Das bedeutet, dass das System das Composable einer einzelnen Schaltfläche möglicherweise überspringt, ohne eines der Composables darüber oder darunter im UI-Baum auszuführen.

Jede zusammensetzbare Funktion und jedes Lambda kann neu zusammengesetzt werden. Das folgende Beispiel zeigt, wie bei der Neuzusammenstellung 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 kann das Einzige sein, was während einer Neuzusammenstellung ausgeführt wird. Compose kann direkt zur Column-Lambda-Funktion springen, ohne die übergeordneten Funktionen auszuführen, wenn sich header ändert. Bei der Ausführung von Column kann es vorkommen, dass Compose die Elemente von LazyColumn überspringt, wenn sich names nicht geändert hat.

Auch hier gilt, dass die Ausführung aller zusammensetzbaren Funktionen oder Lambdas keine Nebeneffekte haben sollte. Wenn Sie einen Nebeneffekt ausführen müssen, lösen Sie ihn über einen Callback aus.

Die Empfehlungen zur Körperzusammensetzung sind optimistisch.

Die Neukomposition beginnt immer dann, wenn Compose davon ausgeht, dass sich die Parameter einer Composable geändert haben könnten. Die Neukomposition ist optimistisch. Das bedeutet, dass Compose davon ausgeht, dass die Neukomposition abgeschlossen ist, bevor sich die Parameter wieder ändern. Wenn sich ein Parameter ändert, bevor die Neuzusammensetzung abgeschlossen ist, kann es sein, dass Compose die Neuzusammensetzung abbricht und mit dem neuen Parameter neu startet.

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

Achten Sie darauf, dass alle zusammensetzbaren Funktionen und Lambdas idempotent und frei von Nebeneffekten sind, um optimistische Neukompositionen zu ermöglichen.

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

In einigen Fällen wird eine zusammensetzbare Funktion 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 dies zu Rucklern in der Benutzeroberfläche führen.

Wenn Ihr Widget beispielsweise versucht, Geräteeinstellungen zu lesen, könnte es diese Einstellungen potenziell Hunderte Male pro Sekunde lesen, was sich katastrophal auf die Leistung Ihrer App auswirken würde.

Wenn Ihre zusammensetzbare Funktion Daten benötigt, sollten Sie Parameter für die Daten definieren. Sie können dann rechenintensive Aufgaben in einen anderen Thread außerhalb der Komposition verschieben und die Daten mithilfe von mutableStateOf oder LiveData an Compose übergeben.

Komponierbare Funktionen können parallel ausgeführt werden

Compose kann die Neuzusammenstellung optimieren, indem Composable-Funktionen parallel ausgeführt werden. So könnte Compose mehrere Kerne nutzen und Composables, die nicht auf dem Bildschirm angezeigt werden, mit niedrigerer Priorität ausführen.

Bei dieser Optimierung wird eine zusammensetzbare Funktion möglicherweise in einem Pool von Hintergrund-Threads ausgeführt. Wenn eine zusammensetzbare Funktion eine Funktion für ein ViewModel aufruft, kann Compose diese Funktion möglicherweise gleichzeitig von mehreren Threads aus aufrufen.

Damit Ihre Anwendung korrekt funktioniert, sollten alle zusammensetzbaren Funktionen keine Nebeneffekte 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. Das liegt daran, dass solcher Code nicht threadsicher ist und einen unzulässigen Nebeneffekt des zusammensetzbaren Lambdas darstellt.

Hier ist ein Beispiel für ein Composable, das eine Liste und ihre Anzahl 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 Nebeneffekten und transformiert die Eingabeliste in die Benutzeroberfläche. Dieser Code eignet sich hervorragend zum Anzeigen einer kleinen Liste. 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 Neuzusammenstellung geändert. Das kann bei jedem Frame einer Animation oder bei jeder Aktualisierung der Liste der Fall sein. In beiden Fällen wird in der Benutzeroberfläche die falsche Anzahl angezeigt. Aus diesem Grund werden solche Schreibvorgänge in Compose nicht unterstützt. Durch das Verhindern dieser Schreibvorgänge kann das Framework Threads ändern, um zusammensetzbare Lambdas auszuführen.

Composable-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 garantiert. Wenn eine zusammensetzbare Funktion Aufrufe anderer zusammensetzbarer Funktionen enthält, werden diese Funktionen möglicherweise in beliebiger Reihenfolge ausgeführt. Compose kann erkennen, dass einige UI-Elemente eine höhere Priorität haben als andere, und sie 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 StartScreen() beispielsweise keine globale Variable festlegen kann (Nebeneffekt), die von MiddleScreen() genutzt werden kann. Stattdessen muss jede dieser Funktionen in sich geschlossen sein.

Weitere Informationen

Weitere Informationen zum Programmieren in Compose und zu zusammensetzbaren Funktionen finden Sie in den folgenden zusätzlichen Ressourcen.

Videos