Denken in Textform

Jetpack Compose ist ein modernes deklaratives UI-Toolkit für Android. Compose erleichtert das Erstellen und Verwalten der App-Benutzeroberfläche, da es eine deklarative API bietet, mit der Sie die App-Benutzeroberfläche rendern können, ohne die Frontend-Ansichten zwingend zu ändern. Diese Terminologie erfordert einige Erläuterungen, aber die Auswirkungen sind für Ihr App-Design wichtig.

Das deklarative Programmierparadigma

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

Wenn Sie Daten manuell manipulieren, erhöht sich die Wahrscheinlichkeit von Fehlern. Wenn Daten an mehreren Stellen gerendert werden, kann es leicht passieren, dass eine der Ansichten, in denen sie angezeigt werden, nicht aktualisiert wird. Es ist auch leicht, ungültige Status zu erstellen, wenn zwei Aktualisierungen auf unerwartete Weise in Konflikt stehen. Beispielsweise kann bei einer Aktualisierung versucht werden, den 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 die gesamte Branche begonnen, zu einem deklarativen UI-Modell überzugehen, das die Entwicklung von Benutzeroberflächen erheblich vereinfacht. Dabei wird der gesamte Bildschirm neu generiert und dann nur die erforderlichen Änderungen angewendet. Mit diesem Ansatz wird die Komplexität der manuellen Aktualisierung einer zustandsabhängigen Ansichtshierarchie vermieden. Compose ist ein deklaratives UI-Framework.

Eine Herausforderung beim Generieren des gesamten Displays besteht darin, dass es in Bezug auf Zeit, Rechenleistung und Akkunutzung potenziell teuer 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. Das hat einige Auswirkungen auf die Gestaltung Ihrer UI-Komponenten, wie unter Neuzusammensetzung erläutert.

Eine einfache komponierbare Funktion

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

Ein Screenshot eines Smartphones mit dem Text „Hallo Welt“ und dem Code für die einfache Composable-Funktion, die diese UI generiert

Abbildung 1: Eine einfache kombinierbare Funktion, der Daten übergeben werden, die zum Rendern eines Text-Widgets auf dem Bildschirm verwendet werden.

Einige wichtige Punkte zu dieser Funktion:

  • Die Funktion ist mit der Anmerkung @Composable annotiert. Alle zusammensetzbaren Funktionen müssen diese Anmerkung haben. Diese Anmerkung informiert den Compose-Compiler darüber, dass diese Funktion dazu dient, Daten in eine Benutzeroberfläche umzuwandeln.

  • Die Funktion nimmt Daten auf. Zusammensetzbare Funktionen können Parameter akzeptieren, mit denen die App-Logik die Benutzeroberfläche beschreiben kann. In diesem Fall nimmt unser Widget eine String an, damit der Nutzer mit 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 tatsächlich erstellt. Kompositionsfähige Funktionen geben eine UI-Hierarchie aus, indem sie andere kompositionsfähige Funktionen aufrufen.

  • Die Funktion gibt nichts zurück. 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 frei von Nebenwirkungen.

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

    Im Allgemeinen sollten alle kombinierbaren Funktionen aus den Gründen, die unter Neuzusammensetzung erläutert werden, mit diesen Eigenschaften geschrieben werden.

Der deklarative Paradigmenwechsel

Bei vielen imperativen objektorientierten UI-Toolkits wird die Benutzeroberfläche durch Instanziieren eines Widgetsbaums initialisiert. Dazu wird häufig eine XML-Layoutdatei aufgebläht. Jedes Widget hat einen eigenen internen Status und stellt Getter- und Setter-Methoden bereit, mit denen die App-Logik mit dem Widget interagieren kann.

Beim deklarativen Ansatz von Compose sind Widgets relativ zustandslos und haben keine Setter- oder Getter-Funktionen. Widgets werden nicht als Objekte angezeigt. Sie aktualisieren die Benutzeroberfläche, indem Sie dieselbe zusammensetzbare Funktion mit unterschiedlichen Argumenten aufrufen. So können Sie Architekturmustern wie einem ViewModel einen Status zuweisen, wie im Leitfaden zur App-Architektur beschrieben. Ihre Composeables sind dann dafür verantwortlich, den aktuellen Anwendungsstatus jedes Mal in eine Benutzeroberfläche umzuwandeln, wenn sich die beobachtbaren Daten aktualisieren.

Abbildung des Datenflusses in einer Compose-Benutzeroberfläche, von übergeordneten Objekten bis hin zu ihren untergeordneten Objekten.

Abbildung 2: Die App-Logik stellt Daten für die zusammensetzbare Funktion der obersten Ebene bereit. Diese Funktion verwendet die Daten, um die Benutzeroberfläche zu beschreiben, indem sie andere Composeables aufruft und die entsprechenden Daten an diese Composeables und weiter unten in der Hierarchie weitergibt.

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 Neuzusammensetzung genannt.

Abbildung, wie UI-Elemente auf Interaktionen reagieren, indem Ereignisse ausgelöst werden, 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 noch einmal 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 eine Begrüßung für jeden Nutzer. Kompositionsfähige Funktionen können recht 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 ist einer der Hauptvorteile von Jetpack Compose.

Neuzusammensetzung

In einem imperativen UI-Modell wird ein Widget durch Aufruf eines Setters geändert, um seinen internen Status zu ändern. In Compose rufen Sie die zusammensetzbare Funktion noch einmal mit neuen Daten auf. Dadurch wird die Funktion neu erstellt. Die von der Funktion gesendeten Widgets werden bei Bedarf mit neuen Daten neu gezeichnet. Das Compose-Framework kann nur die Komponenten neu zusammenstellen, die sich geändert haben.

Betrachten Sie beispielsweise diese zusammensetzbare Funktion, 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 Text-Funktion 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 erstellt.

Wie bereits erwähnt, kann das Neuaufbauen des gesamten UI-Baums sehr rechenintensiv sein, was die Rechenleistung und die Akkulaufzeit beeinträchtigen kann. Compose löst dieses Problem mit dieser intelligenten Neuzusammensetzung.

Bei der Neuzusammensetzung werden Ihre kompositionsfähigen Funktionen noch einmal aufgerufen, wenn sich die Eingaben ändern. Das passiert, wenn sich die Eingaben der Funktion ändern. Wenn Compose anhand neuer Eingaben neu zusammengesetzt wird, werden nur die Funktionen oder Lambdas aufgerufen, die sich möglicherweise geändert haben. Der Rest wird übersprungen. Da alle Funktionen oder Lambdas übersprungen werden, deren Parameter sich nicht geändert haben, kann Compose effizient neu zusammengesetzt werden.

Verlassen Sie sich niemals auf Nebenwirkungen, die durch die Ausführung zusammensetzbarer Funktionen entstehen, da die Neuzusammensetzung einer Funktion übersprungen werden kann. Andernfalls kann es zu ungewöhnlichem und unvorhersehbarem Verhalten in Ihrer App kommen. Eine Nebenwirkung ist jede Änderung, die für den Rest Ihrer App sichtbar ist. Folgende Aktionen sind beispielsweise gefährliche Nebenwirkungen:

  • In eine Property eines freigegebenen Objekts schreiben
  • Observable in ViewModel aktualisieren
  • Gemeinsam genutzte Einstellungen aktualisieren

Kompositionsfähige Funktionen können so oft wie bei jedem Frame neu ausgeführt werden, z. B. beim Rendern einer Animation. Zusammensetzbare Funktionen sollten schnell sein, um Ruckler bei Animationen zu vermeiden. Wenn Sie aufwendige Vorgänge ausführen müssen, z. B. das Lesen aus freigegebenen Einstellungen, führen Sie diese in einer Hintergrund-Coroutine aus und übergeben Sie das Ergebnis als Parameter an die zusammensetzbare Funktion.

In diesem Beispiel wird mit dem Code ein Composeable erstellt, um einen Wert in SharedPreferences zu aktualisieren. Das composable sollte nicht selbst aus den freigegebenen Einstellungen lesen oder schreiben. Stattdessen verschiebt dieser Code das Lesen und Schreiben in eine ViewModel in einer Hintergrund-Coroutine. Die App-Logik übergibt den aktuellen Wert mit einem Rückruf, 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 Neuzusammensetzung werden so viele kombinierbare Funktionen und Lambdas wie möglich übersprungen.
  • Die Neuzusammensetzung ist optimistisch und kann storniert werden.
  • Eine kompositionsfähige Funktion kann ziemlich häufig ausgeführt werden, z. B. bei jedem Frame einer Animation.
  • Kompositionsfähige Funktionen können parallel ausgeführt werden.
  • Zusammensetzbare Funktionen können in beliebiger Reihenfolge ausgeführt werden.

In den folgenden Abschnitten erfahren Sie, wie Sie zusammensetzbare Funktionen erstellen, um die Neuzusammensetzung zu unterstützen. In jedem Fall sollten Ihre kompositionsfähigen Funktionen schnell, idempotent und ohne Nebenwirkungen sein.

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

Wenn Teile Ihrer Benutzeroberfläche ungültig sind, versucht Compose, nur die Teile neu zu erstellen, die aktualisiert werden müssen. Das bedeutet, dass das Composeable einer einzelnen Schaltfläche möglicherweise übersprungen wird, ohne dass die Composeables darüber oder darunter im UI-Baum ausgeführt werden.

Jede kombinierbare Funktion und jedes Lambda kann sich selbst neu zusammensetzen. Hier ein Beispiel, das zeigt, wie bei der Neuzusammensetzung beim Rendern einer Liste einige Elemente ü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 bei einer Neuzusammensetzung der einzige sein, der ausgeführt wird. Wenn sich der header ändert, springt Compose möglicherweise zum Column-Lambda, ohne eines seiner übergeordneten Elemente auszuführen. Wenn Column ausgeführt wird, überspringt Compose möglicherweise die Elemente von LazyColumn, wenn sich names nicht geändert hat.

Die Ausführung aller kombinierbaren Funktionen oder Lambdas sollte wieder ohne Nebenwirkungen erfolgen. Wenn Sie einen Nebeneffekt ausführen möchten, lösen Sie ihn über einen Callback aus.

Die Neuzusammensetzung ist optimistisch

Die Neuzusammensetzung wird gestartet, wenn Compose der Meinung ist, dass sich die Parameter eines Composeables 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, wird die Neuzusammensetzung möglicherweise abgebrochen und mit dem neuen Parameter neu gestartet.

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

Alle kombinierbaren Funktionen und Lambdas müssen idempotent und ohne Nebenwirkungen sein, um eine optimistische Neuzusammensetzung zu ermöglichen.

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

In einigen Fällen wird eine kompositionsfähige Funktion möglicherweise für jeden Frame einer UI-Animation ausgeführt. Wenn die Funktion aufwendige Vorgänge ausführt, z. B. das Lesen aus dem Gerätespeicher, kann dies zu Rucklern 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 verheerende Auswirkungen auf die Leistung Ihrer App hat.

Wenn für Ihre zusammensetzbare Funktion Daten erforderlich sind, sollten Sie Parameter für die Daten definieren. Sie können dann aufwendige Arbeitsschritte in einen anderen Thread außerhalb der Komposition verschieben und die Daten mit mutableStateOf oder LiveData an Compose übergeben.

Komponierbare Funktionen können parallel ausgeführt werden

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

Diese Optimierung würde bedeuten, dass eine kompostierbare Funktion in einem Pool von Hintergrundthreads ausgeführt werden könnte. Wenn eine kombinierbare Funktion eine Funktion auf einem ViewModel aufruft, ruft Compose diese Funktion möglicherweise gleichzeitig aus mehreren Threads auf.

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

Wenn eine kombinierbare 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 dieser Code nicht threadsicher ist und eine unzulässige Nebenwirkung des zusammensetzbaren Lambdas darstellt.

Hier ein Beispiel für ein Composeable, 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 Nebenwirkungen und wandelt die Eingabeliste in eine Benutzeroberfläche um. Dieser Code eignet sich hervorragend für die Anzeige 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 Neuzusammensetzung geändert. Das kann bei jedem Frame einer Animation oder bei der 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 Verbot dieser Schreibvorgänge können Threads vom Framework geändert werden, um zusammensetzbare Lambdas auszuführen.

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

Wenn Sie sich den Code einer zusammensetzbaren Funktion ansehen, könnten Sie annehmen, 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, können diese Funktionen in beliebiger Reihenfolge ausgeführt werden. Compose kann erkennen, dass einige UI-Elemente eine höhere Priorität als andere haben, und sie zuerst zeichnen.

Angenommen, Sie haben folgenden Code, 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 (Nebeneffekt) festlegen und MiddleScreen() diese Änderung nutzen kann. Stattdessen muss jede dieser Funktionen in sich geschlossen sein.

Weitere Informationen

Weitere Informationen zum Denken in Compose- und kombinierbaren Funktionen finden Sie in den folgenden zusätzlichen Ressourcen.

Videos