Kotlin für Jetpack Compose

Jetpack Compose basiert auf Kotlin. In einigen Fällen bietet Kotlin spezielle Idiome, die das Schreiben von gutem Compose-Code erleichtern. Wenn Sie eine andere Programmiersprache verwenden und diese Sprache mental in Kotlin übersetzen, werden Ihnen wahrscheinlich einige der Stärken von „Compose“ entgehen und es kann schwierig sein, idiomatisch geschriebenen Kotlin-Code zu verstehen. Wenn Sie sich mit dem Kotlin-Stil vertraut machen, können Sie diese Schwierigkeiten vermeiden.

Standardargumente

Wenn Sie eine Kotlin-Funktion schreiben, können Sie Standardwerte für Funktionsargumente angeben. Diese werden verwendet, wenn der Aufrufer diese Werte nicht explizit übergibt. Durch diese Funktion werden überladene Funktionen reduziert.

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

// 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
) {
}

Diese Funktion erspart Ihnen das Schreiben mehrerer redundanter Funktionen, sondern macht Ihren Code auch viel verständlicher. Wenn der Aufrufer keinen Wert für ein Argument angibt, ist er bereit, den Standardwert zu verwenden. Außerdem erleichtern die benannten Parameter den Überblick. Wenn Sie im Code 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 zusammensetzbaren Funktionen zu tun, die Sie schreiben. Durch diese Vorgehensweise werden die zusammensetzbaren Funktionen zwar angepasst, das Standardverhalten bleibt aber weiterhin einfach. Sie könnten zum Beispiel ein einfaches Textelement wie dieses erstellen:

Text(text = "Hello, Android!")

Dieser Code hat den gleichen Effekt wie der folgende, viel ausführlichere Code, in dem mehr Text-Parameter explizit festgelegt werden:

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 dokumentiert auch selbst. Indem Sie nur den Parameter text angeben, dokumentieren Sie, dass Sie für alle anderen Parameter die Standardwerte verwenden möchten. Das zweite Snippet dagegen impliziert, dass Sie die Werte für diese anderen Parameter explizit festlegen möchten. Die festgelegten Werte sind jedoch die Standardwerte für die Funktion.

Funktionen höherer Ordnung und Lambda-Ausdrücke

Kotlin unterstützt höhere Funktionen, also Funktionen, die andere Funktionen als Parameter erhalten. Compose baut auf diesem Ansatz auf. Beispielsweise stellt die zusammensetzbare Funktion Button den Lambda-Parameter onClick bereit. Der Wert dieses Parameters ist eine Funktion, die von der Schaltfläche aufgerufen wird, wenn der Nutzer darauf klickt:

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

Funktionen höherer Ordnung passen natürlich mit Lambda-Ausdrücken, also Ausdrücken, die eine Funktion ergeben. Wenn Sie die Funktion nur einmal benötigen, müssen Sie sie nicht an anderer Stelle definieren, um sie an die Funktion höherer Ordnung 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 jedoch hier nur diese Funktion verwenden, ist es einfacher, die Funktion einfach inline mit einem Lambda-Ausdruck zu definieren:

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

Nachgestellte Lambdas

Kotlin bietet eine spezielle Syntax zum Aufrufen von Funktionen höherer Ordnung, deren letzter Parameter ein Lambda ist. Wenn Sie einen Lambda-Ausdruck als diesen Parameter übergeben möchten, können Sie die nachlaufende Lambda-Syntax verwenden. Anstatt den Lambda-Ausdruck in Klammern zu setzen, fügen Sie ihn hinterher ein. Dies ist eine gängige Situation in Compose. Sie müssen also wissen, wie der Code aussieht.

Der letzte Parameter für alle Layouts wie die zusammensetzbare Funktion Column() ist beispielsweise content. Diese Funktion gibt die untergeordneten UI-Elemente aus. Angenommen, Sie möchten eine Spalte mit drei Textelementen erstellen und müssen Formatierungen vornehmen. 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 content-Parameter der letzte Parameter in der Funktionssignatur ist und wir seinen Wert als Lambda-Ausdruck übergeben, können wir ihn aus den Klammern ziehen:

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

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

Wenn der einzige Parameter, den Sie weitergeben, das nachgestellte Lambda ist, d. h. wenn der letzte Parameter ein Lambda ist und Sie keine anderen Parameter übergeben, können Sie die Klammern ganz weglassen. Angenommen, Sie müssen keinen Modifikator an Column übergeben. Sie könnten den Code wie folgt schreiben:

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

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

Umfang und Empfänger

Einige Methoden und Attribute sind nur in einem bestimmten Bereich verfügbar. Durch den begrenzten Umfang können Sie Funktionen dort anbieten, wo sie benötigt werden, und vermeiden, dass sie versehentlich bei ungeeigneten Inhalten verwendet werden.

Hier ein Beispiel aus der Funktion „Compose“. Wenn Sie die zusammensetzbare Funktion Row aufrufen, wird Ihre inhaltsbezogene Lambda-Funktion automatisch in einem RowScope aufgerufen. Dadurch kann Row Funktionen bereitstellen, die nur innerhalb einer Row gültig sind. Das folgende Beispiel zeigt, wie Row einen zeilenspezifischen Wert für den align-Modifikator offengelegt 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 Zugriff auf Eigenschaften und Funktionen, die an anderer Stelle anhand der Parameterdeklaration 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 Attribute

Kotlin unterstützt delegierte Attribute. Diese Attribute werden wie Felder aufgerufen, ihr Wert wird jedoch dynamisch durch Auswertung eines Ausdrucks bestimmt. Sie erkennen diese Attribute an der Syntax by:

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)

Bei der Ausführung von println() wird nameGetterFunction() aufgerufen, um den Wert des Strings zurückzugeben.

Diese delegierten Attribute sind besonders nützlich, wenn Sie mit zustandsgestützten Attributen arbeiten:

var showDialog by remember { mutableStateOf(false) }

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

Datenklassen zerstören

Wenn Sie eine Datenklasse definieren, können Sie mit einer destrukturierenden Deklaration 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 seine Werte zugreifen:

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

// ...

val (name, age) = mary

Diese Art von Code finden Sie häufig in den Funktionen zum Schreiben:

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

Mit Kotlin ist es einfach, Singletons zu deklarieren, d. h. Klassen, die immer nur eine Instanz haben. Diese Singletons werden mit dem Keyword object deklariert. Für Compose werden solche Objekte häufig verwendet. Beispielsweise ist MaterialTheme als Singleton-Objekt definiert. Die Attribute MaterialTheme.colors, shapes und typography enthalten alle Werte für das aktuelle Thema.

Typsichere Builder und DSLs

Kotlin ermöglicht das Erstellen domainspezifischer Sprachen (DSLs) mit typsicheren Buildern. DSLs ermöglichen es, komplexe hierarchische Datenstrukturen auf übersichtliche und lesbare Weise zu erstellen.

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, die Funktionsliterale mit Empfänger verwenden. Wenn wir die zusammensetzbare Funktion Canvas als Beispiel nehmen, wird als Parameter eine Funktion mit DrawScope als Empfänger (onDraw: DrawScope.() -> Unit) verwendet. Dadurch kann der Codeblock die in DrawScope definierten Mitgliederfunktionen aufrufen.

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 unterstützen asynchrone Programmierung auf Sprachebene in Kotlin. Koroutinen können die Ausführung sperren, ohne Threads zu blockieren. Eine responsive UI ist von Natur aus asynchron. Jetpack Compose löst dieses Problem, indem Koroutinen auf API-Ebene statt Callbacks implementiert werden.

Jetpack Compose bietet APIs, die die Verwendung von Koroutinen innerhalb der UI-Ebene sicher machen. Die Funktion rememberCoroutineScope gibt ein CoroutineScope-Objekt zurück, mit dem Sie Koroutinen in Event-Handlern erstellen und Composer APIs zum Anhalten aufrufen können. Im folgenden Beispiel 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()
        }
    }
) { /* ... */ }

Koroutinen führen den Codeblock standardmäßig sequentiell aus. Bei einer laufenden Koroutine, die eine Anhaltenfunktion aufruft, wird ihre Ausführung ausgesetzt, bis die Funktion wiederkehrt. Dies gilt auch dann, wenn die Anhaltenfunktion die Ausführung zu einer anderen CoroutineDispatcher verschiebt. Im vorherigen Beispiel wird loadData erst ausgeführt, wenn die Anhaltefunktion animateScrollTo wieder angezeigt wird.

Wenn Code gleichzeitig ausgeführt werden soll, müssen neue Koroutinen erstellt werden. Im obigen Beispiel sind zwei Koroutinen erforderlich, um das Scrollen zum oberen Bildschirmrand zu parallelisieren und Daten aus viewModel zu laden.

// 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()
        }
    }
) { /* ... */ }

Koroutinen erleichtern das Kombinieren asynchroner APIs. Im folgenden Beispiel kombinieren wir den pointerInput-Modifikator mit den Animations-APIs, um die Position eines Elements zu animieren, wenn der Nutzer auf den Bildschirm 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 Koroutinen finden Sie im Leitfaden Kotlin-Koroutinen unter Android.