Kotlin für Jetpack Compose

Jetpack Compose basiert auf Kotlin. In einigen Fällen bietet Kotlin spezielle Ausdrücke, die das Schreiben guten Compose-Codes erleichtern. Wenn Sie in einer anderen Programmiersprache denken und diese Sprache mental in Kotlin übersetzen, gehen Ihnen wahrscheinlich einige der Stärken von Compose verloren. Außerdem fällt es Ihnen möglicherweise schwer, idiomatisch geschriebenen Kotlin-Code zu verstehen. Wenn Sie sich mit dem Kotlin-Programmierstil vertraut machen, können Sie diese Fallstricke vermeiden.

Standardargumente

Wenn Sie eine Kotlin-Funktion schreiben, können Sie Standardwerte für Funktionsargumente angeben, die verwendet werden, wenn der Aufrufer diese Werte nicht explizit übergibt. Dadurch wird der Bedarf an überladenen Funktionen reduziert.

Angenommen, Sie möchten eine Funktion schreiben, die ein Quadrat zeichnet. Diese Funktion kann einen einzelnen erforderlichen Parameter haben, Seitenlänge, mit dem die Länge jeder Seite angegeben wird. Sie kann mehrere optionale Parameter haben, z. B. thickness, edgeColor usw. Wenn der Aufrufer diese nicht angibt, verwendet die Funktion Standardwerte. In anderen Sprachen müssen Sie möglicherweise mehrere Funktionen schreiben:

// We don't need to do this in Kotlin!
void drawSquare(int sideLength) { }

void drawSquare(int sideLength, int thickness) { }

void drawSquare(int sideLength, int thickness, Color edgeColor) { }

In Kotlin können Sie eine einzelne Funktion schreiben und die Standardwerte für die Argumente angeben:

fun drawSquare(
    sideLength: Int,
    thickness: Int = 2,
    edgeColor: Color = Color.Black
) {
}

So müssen Sie nicht mehrere redundante Funktionen schreiben und Ihr Code ist viel übersichtlicher. Wenn der Aufrufer keinen Wert für ein Argument angibt, ist er damit einverstanden, den Standardwert zu verwenden. Außerdem lässt sich anhand der benannten Parameter viel leichter nachvollziehen, was passiert. Wenn Sie sich den Code ansehen und einen Funktionsaufruf wie diesen sehen, wissen Sie möglicherweise nicht, was die Parameter bedeuten, ohne den drawSquare()-Code zu prüfen:

drawSquare(30, 5, Color.Red);

Im Gegensatz dazu ist dieser Code selbstdokumentierend:

drawSquare(sideLength = 30, thickness = 5, edgeColor = Color.Red)

Die meisten Compose-Bibliotheken verwenden Standardargumente. Es empfiehlt sich, dies auch für die von Ihnen geschriebenen komposierbaren Funktionen zu tun. So lassen sich Ihre Composeables anpassen, das Standardverhalten kann aber trotzdem ganz einfach aufgerufen werden. So könnten Sie beispielsweise ein einfaches Textelement erstellen:

Text(text = "Hello, Android!")

Dieser Code hat denselben Effekt wie der folgende, viel ausführlichere Code, in dem mehr der Parameter von Text explizit festgelegt sind:

Text(
    text = "Hello, Android!",
    color = Color.Unspecified,
    fontSize = TextUnit.Unspecified,
    letterSpacing = TextUnit.Unspecified,
    overflow = TextOverflow.Clip
)

Das erste Code-Snippet ist nicht nur viel einfacher und leichter zu lesen, sondern auch selbstdokumentierend. Wenn Sie nur den Parameter text angeben, legen Sie damit fest, dass für alle anderen Parameter die Standardwerte verwendet werden sollen. Im zweiten Snippet wird dagegen davon ausgegangen, dass Sie die Werte für diese anderen Parameter explizit festlegen möchten, auch wenn die von Ihnen festgelegten Werte zufällig die Standardwerte für die Funktion sind.

Funktionen höherer Ordnung und Lambda-Ausdrücke

Kotlin unterstützt Funktionen höherer Ordnung, also Funktionen, die andere Funktionen als Parameter erhalten. Compose baut auf diesem Ansatz auf. Die zusammensetzbare Funktion Button bietet beispielsweise einen Lambda-Parameter onClick. Der Wert dieses Parameters ist eine Funktion, die die Schaltfläche aufruft, wenn der Nutzer darauf klickt:

Button(
    // ...
    onClick = myClickFunction
)
// ...

Funktionen höherer Ordnung passen gut zu Lambda-Ausdrücken, also Ausdrücken, die zu einer Funktion ausgewertet werden. Wenn Sie die Funktion nur einmal benötigen, müssen Sie sie nicht an anderer Stelle definieren, um sie an die höhere Funktion zu übergeben. Stattdessen können Sie die Funktion direkt mit einem Lambda-Ausdruck definieren. Im vorherigen Beispiel wird davon ausgegangen, dass myClickFunction() an anderer Stelle definiert ist. Wenn Sie diese Funktion jedoch nur hier verwenden, ist es einfacher, sie inline mit einem Lambda-Ausdruck zu definieren:

Button(
    // ...
    onClick = {
        // do something
        // do something else
    }
) { /* ... */ }

Nachgestellte Lambdas

Kotlin bietet eine spezielle Syntax für den Aufruf von höheren Funktionen, deren letzter Parameter ein Lambda ist. Wenn Sie einen Lambda-Ausdruck als Parameter übergeben möchten, können Sie die Syntax für nachgestellten Lambda verwenden. Anstatt den Lambda-Ausdruck in die Klammern zu setzen, wird er danach eingefügt. Das ist eine häufige Situation in Compose. Sie müssen also mit dem Code vertraut sein.

Der letzte Parameter für alle Layouts, z. B. für die zusammensetzbare Funktion Column(), ist content, eine Funktion, die die untergeordneten UI-Elemente ausgibt. Angenommen, Sie möchten eine Spalte mit drei Textelementen erstellen und eine Formatierung anwenden. Dieser Code würde funktionieren, ist aber sehr umständlich:

Column(
    modifier = Modifier.padding(16.dp),
    content = {
        Text("Some text")
        Text("Some more text")
        Text("Last text")
    }
)

Da der Parameter content der letzte in der Funktionssignatur ist und wir seinen Wert als Lambda-Ausdruck übergeben, können wir ihn aus den Klammern herausnehmen:

Column(modifier = Modifier.padding(16.dp)) {
    Text("Some text")
    Text("Some more text")
    Text("Last text")
}

Die beiden Beispiele haben genau dieselbe Bedeutung. Die geschweiften Klammern definieren den Lambda-Ausdruck, der an den Parameter content übergeben wird.

Wenn das einzige Argument, das Sie übergeben, dieses abschließende Lambda ist, d. h., wenn der letzte Parameter ein Lambda ist und Sie keine anderen Parameter übergeben, können Sie die Klammern auch ganz weglassen. Angenommen, Sie müssen der Column beispielsweise keinen Modifikator übergeben. Sie könnten den Code so schreiben:

Column {
    Text("Some text")
    Text("Some more text")
    Text("Last text")
}

Diese Syntax ist in Compose sehr häufig, insbesondere für Layoutelemente wie Column. Der letzte Parameter ist ein Lambda-Ausdruck, der die untergeordneten Elemente des Elements definiert. Diese untergeordneten Elemente werden nach dem Funktionsaufruf in geschweiften Klammern angegeben.

Bereiche und Empfänger

Einige Methoden und Eigenschaften sind nur in einem bestimmten Umfang verfügbar. Durch den begrenzten Umfang können Sie Funktionen dort anbieten, wo sie benötigt werden, und verhindern, dass sie versehentlich dort verwendet werden, wo sie nicht angebracht sind.

Sehen wir uns ein Beispiel in Compose an. Wenn Sie das Row-Layout aufrufen, wird Ihr Content-Lambda automatisch in einem RowScope aufgerufen. So können Row-Funktionen freigegeben werden, die nur innerhalb eines Row gültig sind. Im folgenden Beispiel wird gezeigt, wie Row einen zeilenspezifischen Wert für den Modifikator align ausgegeben hat:

Row {
    Text(
        text = "Hello world",
        // This Text is inside a RowScope so it has access to
        // Alignment.CenterVertically but not to
        // Alignment.CenterHorizontally, which would be available
        // in a ColumnScope.
        modifier = Modifier.align(Alignment.CenterVertically)
    )
}

Einige APIs akzeptieren Lambdas, die im Empfängerbereich aufgerufen werden. Diese Lambdas haben basierend auf der Parameterdeklaration Zugriff auf Eigenschaften und Funktionen, die an anderer Stelle definiert sind:

Box(
    modifier = Modifier.drawBehind {
        // This method accepts a lambda of type DrawScope.() -> Unit
        // therefore in this lambda we can access properties and functions
        // available from DrawScope, such as the `drawRectangle` function.
        drawRect(
            /*...*/
            /* ...
        )
    }
)

Weitere Informationen finden Sie in der Kotlin-Dokumentation unter Funktionsliterale mit Empfänger.

Delegierte Unterkünfte

Kotlin unterstützt delegierte Eigenschaften. Diese Properties werden aufgerufen, als wären sie Felder, ihr Wert wird jedoch dynamisch durch Auswertung eines Ausdrucks ermittelt. Sie erkennen diese Properties an der by-Syntax:

class DelegatingClass {
    var name: String by nameGetterFunction()

    // ...
}

Anderer Code kann mit folgendem Code auf die Property zugreifen:

val myDC = DelegatingClass()
println("The name property is: " + myDC.name)

Wenn println() ausgeführt wird, wird nameGetterFunction() aufgerufen, um den Wert des Strings zurückzugeben.

Diese delegierten Properties sind besonders nützlich, wenn Sie mit zustandsbasierten Properties arbeiten:

var showDialog by remember { mutableStateOf(false) }

// Updating the var automatically triggers a state change
showDialog = true

Datenklassen destrukturieren

Wenn Sie eine Datenklasse definieren, können Sie mit einer Destrukturierungsdeklaration ganz einfach auf die Daten zugreifen. Angenommen, Sie definieren eine Person-Klasse:

data class Person(val name: String, val age: Int)

Wenn Sie ein Objekt dieses Typs haben, können Sie mit folgendem Code auf die Werte zugreifen:

val mary = Person(name = "Mary", age = 35)

// ...

val (name, age) = mary

Diese Art von Code wird häufig in Compose-Funktionen verwendet:

Row {

    val (image, title, subtitle) = createRefs()

    // The `createRefs` function returns a data object;
    // the first three components are extracted into the
    // image, title, and subtitle variables.

    // ...
}

Datenklassen bieten viele weitere nützliche Funktionen. Wenn Sie beispielsweise eine Datenklasse definieren, definiert der Compiler automatisch nützliche Funktionen wie equals() und copy(). Weitere Informationen finden Sie in der Dokumentation zu Datenklassen.

Singleton-Objekte

In Kotlin können Sie ganz einfach Singletons deklarieren, also Klassen, die immer nur eine Instanz haben. Diese Singletons werden mit dem Schlüsselwort object deklariert. In Compose werden häufig solche Objekte verwendet. Beispiel: MaterialTheme ist als Singleton-Objekt definiert. Die Eigenschaften MaterialTheme.colors, shapes und typography enthalten alle die Werte für das aktuelle Design.

Typsichere Builder und DSLs

Mit Kotlin können domainspezifische Sprachen (DSLs) mit typsicheren Buildern erstellt werden. Mithilfe von DSLs lassen sich komplexe hierarchische Datenstrukturen übersichtlicher und leichter verwalten.

Jetpack Compose verwendet DSLs für einige APIs wie LazyRow und LazyColumn.

@Composable
fun MessageList(messages: List<Message>) {
    LazyColumn {
        // Add a single item as a header
        item {
            Text("Message List")
        }

        // Add list of messages
        items(messages) { message ->
            Message(message)
        }
    }
}

Kotlin garantiert typsichere Builder mithilfe von Funktionsliteralen mit Empfänger. Nehmen wir als Beispiel die Canvas-Funktion. Sie nimmt als Parameter eine Funktion mit DrawScope als Empfänger (onDraw: DrawScope.() -> Unit) an, sodass der Codeblock Mitgliedsfunktionen aufrufen kann, die in DrawScope definiert sind.

Canvas(Modifier.size(120.dp)) {
    // Draw grey background, drawRect function is provided by the receiver
    drawRect(color = Color.Gray)

    // Inset content by 10 pixels on the left/right sides
    // and 12 by the top/bottom
    inset(10.0f, 12.0f) {
        val quadrantSize = size / 2.0f

        // Draw a rectangle within the inset bounds
        drawRect(
            size = quadrantSize,
            color = Color.Red
        )

        rotate(45.0f) {
            drawRect(size = quadrantSize, color = Color.Blue)
        }
    }
}

Weitere Informationen zu typsicheren Buildern und DSLs finden Sie in der Kotlin-Dokumentation.

Kotlin-Koroutinen

Coroutinen bieten in Kotlin auf Sprachebene Unterstützung für die asynchrone Programmierung. Mit Coroutinen kann die Ausführung angehalten werden, ohne Threads zu blockieren. Eine responsive Benutzeroberfläche ist von Natur aus asynchron. Jetpack Compose löst dieses Problem, indem es coroutines auf API-Ebene verwendet, anstatt Callbacks zu verwenden.

Jetpack Compose bietet APIs, die die sichere Verwendung von Tasks in der UI-Ebene ermöglichen. Die Funktion rememberCoroutineScope gibt einen CoroutineScope zurück, mit dem Sie Tasks in Ereignis-Handlern erstellen und Compose Suspend APIs aufrufen können. Im Beispiel unten wird die animateScrollTo API von ScrollState verwendet.

// Create a CoroutineScope that follows this composable's lifecycle
val composableScope = rememberCoroutineScope()
Button(
    // ...
    onClick = {
        // Create a new coroutine that scrolls to the top of the list
        // and call the ViewModel to load data
        composableScope.launch {
            scrollState.animateScrollTo(0) // This is a suspend function
            viewModel.loadData()
        }
    }
) { /* ... */ }

Bei Coroutinen wird der Codeblock standardmäßig sequenziell ausgeführt. Bei einer laufenden Coroutine, die eine Suspend-Funktion aufruft, wird die Ausführung angehalten, bis die Suspend-Funktion zurückkehrt. Das gilt auch, wenn die Ausführung durch die Funktion „suspend“ auf eine andere CoroutineDispatcher verschoben wird. Im vorherigen Beispiel wird loadData erst ausgeführt, wenn die Funktion „pausieren“ animateScrollTo zurückgegeben wird.

Wenn Code gleichzeitig ausgeführt werden soll, müssen neue Tasks erstellt werden. Im obigen Beispiel sind zwei Tasks erforderlich, um das Scrollen zum oberen Bildschirmrand und das Laden von Daten aus viewModel parallel auszuführen.

// Create a CoroutineScope that follows this composable's lifecycle
val composableScope = rememberCoroutineScope()
Button( // ...
    onClick = {
        // Scroll to the top and load data in parallel by creating a new
        // coroutine per independent work to do
        composableScope.launch {
            scrollState.animateScrollTo(0)
        }
        composableScope.launch {
            viewModel.loadData()
        }
    }
) { /* ... */ }

Mit Coroutinen lassen sich asynchrone APIs leichter kombinieren. Im folgenden Beispiel kombinieren wir den pointerInput-Modifikator mit den Animations-APIs, um die Position eines Elements zu animieren, wenn der Nutzer auf das Display tippt.

@Composable
fun MoveBoxWhereTapped() {
    // Creates an `Animatable` to animate Offset and `remember` it.
    val animatedOffset = remember {
        Animatable(Offset(0f, 0f), Offset.VectorConverter)
    }

    Box(
        // The pointerInput modifier takes a suspend block of code
        Modifier
            .fillMaxSize()
            .pointerInput(Unit) {
                // Create a new CoroutineScope to be able to create new
                // coroutines inside a suspend function
                coroutineScope {
                    while (true) {
                        // Wait for the user to tap on the screen
                        val offset = awaitPointerEventScope {
                            awaitFirstDown().position
                        }
                        // Launch a new coroutine to asynchronously animate to
                        // where the user tapped on the screen
                        launch {
                            // Animate to the pressed position
                            animatedOffset.animateTo(offset)
                        }
                    }
                }
            }
    ) {
        Text("Tap anywhere", Modifier.align(Alignment.Center))
        Box(
            Modifier
                .offset {
                    // Use the animated offset as the offset of this Box
                    IntOffset(
                        animatedOffset.value.x.roundToInt(),
                        animatedOffset.value.y.roundToInt()
                    )
                }
                .size(40.dp)
                .background(Color(0xff3c1361), CircleShape)
        )
    }

Weitere Informationen zu Tasks finden Sie im Leitfaden Kotlin-Tasks unter Android.