Kotlin dla Jetpack Compose

Jetpack Compose jest oparty na Kotlinie. W niektórych przypadkach Kotlin udostępnia specjalne idiomy, które ułatwiają pisanie dobrego kodu Compose. Jeśli myślisz w innym języku programowania i tłumaczysz go na Kotlin, prawdopodobnie nie wykorzystasz w pełni możliwości Compose i może Ci być trudno zrozumieć idiomatyczny kod w Kotlinie. Lepsze poznanie stylu Kotlina może pomóc Ci uniknąć tych pułapek.

Argumenty domyślne

Podczas pisania funkcji w języku Kotlin możesz określić wartości domyślne argumentów funkcji, które będą używane, jeśli wywołujący nie przekaże ich jawnie. Ta funkcja zmniejsza potrzebę stosowania przeładowanych funkcji.

Załóżmy na przykład, że chcesz napisać funkcję, która rysuje kwadrat. Ta funkcja może mieć jeden wymagany parametr, sideLength, określający długość każdego boku. Może mieć kilka opcjonalnych parametrów, takich jak thickness, edgeColor itp. Jeśli wywołujący nie określi tych parametrów, funkcja użyje wartości domyślnych. W innych językach możesz oczekiwać, że napiszesz kilka funkcji:

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

W języku Kotlin możesz napisać jedną funkcję i określić wartości domyślne argumentów:

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

Oprócz tego, że nie musisz pisać wielu zbędnych funkcji, ta funkcja sprawia, że kod jest znacznie bardziej czytelny. Jeśli wywołujący nie określi wartości argumentu, oznacza to, że chce użyć wartości domyślnej. Dodatkowo nazwane parametry znacznie ułatwiają zrozumienie tego, co się dzieje. Jeśli w kodzie zobaczysz wywołanie funkcji podobne do tego, możesz nie wiedzieć, co oznaczają parametry, bez sprawdzenia kodu drawSquare():

drawSquare(30, 5, Color.Red);

Ten kod jest natomiast samowystarczalny:

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

Większość bibliotek Compose używa argumentów domyślnych i warto robić to samo w przypadku funkcji kompozycyjnych, które piszesz. Dzięki temu funkcje kompozycyjne można dostosowywać, ale domyślne działanie jest nadal łatwe do wywołania. Możesz na przykład utworzyć prosty element tekstowy w ten sposób:

Text(text = "Hello, Android!")

Ten kod ma taki sam efekt jak poniższy, znacznie bardziej rozbudowany kod, w którym więcej parametrów Text jest ustawionych jawnie:

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

Pierwszy fragment kodu jest nie tylko znacznie prostszy i łatwiejszy do odczytania, ale też sam się dokumentuje. Określając tylko parametr text, informujesz, że w przypadku wszystkich pozostałych parametrów chcesz używać wartości domyślnych. Z kolei drugi fragment kodu sugeruje, że chcesz jawnie ustawić wartości tych innych parametrów, chociaż ustawione wartości są domyślnymi wartościami funkcji.

Funkcje wyższego rzędu i wyrażenia lambda

Kotlin obsługuje funkcje wyższego rzędu, czyli funkcje, które przyjmują inne funkcje jako parametry. Compose opiera się na tym podejściu. Na przykład funkcja kompozycyjna Button udostępnia parametr lambda onClick. Wartość tego parametru to funkcja, którą przycisk wywołuje, gdy użytkownik go kliknie:

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

Funkcje wyższego rzędu naturalnie łączą się z wyrażeniami lambda, czyli wyrażeniami, które dają w wyniku funkcję. Jeśli potrzebujesz funkcji tylko raz, nie musisz jej definiować w innym miejscu, aby przekazać ją do funkcji wyższego rzędu. Zamiast tego możesz zdefiniować funkcję bezpośrednio za pomocą wyrażenia lambda. W poprzednim przykładzie założono, że element myClickFunction() jest zdefiniowany w innym miejscu. Jeśli jednak używasz tej funkcji tylko w tym miejscu, prościej jest zdefiniować ją w wierszu za pomocą wyrażenia lambda:

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

Wyrażenia lambda na końcu

Kotlin oferuje specjalną składnię wywoływania funkcji wyższego rzędu, których ostatnim parametrem jest lambda. Jeśli chcesz przekazać wyrażenie lambda jako ten parametr, możesz użyć składni lambda końcowej. Zamiast umieszczać wyrażenie lambda w nawiasach, umieszczasz je po nich. Jest to częsta sytuacja w Compose, więc musisz wiedzieć, jak wygląda kod.

Na przykład ostatnim parametrem wszystkich układów, takich jak funkcja kompozycyjna Column(), jest content, czyli funkcja, która emituje elementy interfejsu podrzędnego. Załóżmy, że chcesz utworzyć kolumnę zawierającą 3 elementy tekstowe i musisz zastosować do niej formatowanie. Ten kod zadziała, ale jest bardzo nieporęczny:

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

Ponieważ parametr content jest ostatnim w sygnaturze funkcji, a jego wartość przekazujemy jako wyrażenie lambda, możemy go wyciągnąć z nawiasów:

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

Oba przykłady mają dokładnie takie samo znaczenie. Nawiasy klamrowe definiują wyrażenie lambda, które jest przekazywane do parametru content.

Jeśli jedynym przekazywanym parametrem jest lambda na końcu, czyli jeśli ostatni parametr jest lambdą i nie przekazujesz żadnych innych parametrów, możesz całkowicie pominąć nawiasy. Załóżmy na przykład, że nie musisz przekazywać modyfikatora do elementu Column. Kod możesz napisać w ten sposób:

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

Ta składnia jest dość powszechna w Compose, zwłaszcza w przypadku elementów układu, takich jak Column. Ostatni parametr to wyrażenie lambda definiujące elementy podrzędne elementu, a te elementy podrzędne są podawane w nawiasach klamrowych po wywołaniu funkcji.

Celowniki i odbiorniki

Niektóre metody i właściwości są dostępne tylko w określonym zakresie. Ograniczony zakres pozwala oferować funkcje tam, gdzie są potrzebne, i uniknąć przypadkowego użycia ich w miejscach, w których nie są odpowiednie.

Rozważmy przykład użyty w Compose. Gdy wywołasz układ Row, lambda treści zostanie automatycznie wywołana w ramach RowScope. Umożliwia to Row udostępnianie funkcji, które są prawidłowe tylko w ramach Row. Poniższy przykład pokazuje, jak Row udostępnił wartość konkretnego wiersza dla modyfikatora align:

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

Niektóre interfejsy API akceptują lambdy, które są wywoływane w zakresie odbiorcy. Te lambdy mają dostęp do właściwości i funkcji zdefiniowanych w innych miejscach na podstawie deklaracji parametru:

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

Więcej informacji znajdziesz w dokumentacji języka Kotlin na temat literałów funkcji z odbiornikiem.

Delegowane usługi

Kotlin obsługuje właściwości delegowane. Te właściwości są nazywane tak, jakby były polami, ale ich wartość jest określana dynamicznie przez obliczenie wyrażenia. Te właściwości możesz rozpoznać po użyciu składni by:

class DelegatingClass {
    var name: String by nameGetterFunction()

    // ...
}

Inny kod może uzyskać dostęp do usługi za pomocą kodu takiego jak ten:

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

Gdy funkcja println() jest wykonywana, wywoływana jest funkcja nameGetterFunction(), aby zwrócić wartość ciągu.

Te delegowane właściwości są szczególnie przydatne podczas pracy z właściwościami obsługiwanymi przez stan:

var showDialog by remember { mutableStateOf(false) }

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

Destrukturyzacja klas danych

Jeśli zdefiniujesz klasę danych, możesz łatwo uzyskać dostęp do danych za pomocą deklaracji destrukcyjnej. Załóżmy na przykład, że definiujesz klasę Person:

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

Jeśli masz obiekt tego typu, możesz uzyskać dostęp do jego wartości za pomocą kodu takiego jak ten:

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

// ...

val (name, age) = mary

Ten rodzaj kodu często występuje w funkcjach Compose:

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.

    // ...
}

Klasy danych zapewniają wiele innych przydatnych funkcji. Na przykład podczas definiowania klasy danych kompilator automatycznie definiuje przydatne funkcje, takie jak equals()copy(). Więcej informacji znajdziesz w dokumentacji klas danych.

Obiekty singleton

Kotlin ułatwia deklarowanie singletonów, czyli klas, które zawsze mają tylko jedną instancję. Są one deklarowane za pomocą słowa kluczowego object. Compose często korzysta z takich obiektów. Na przykład MaterialTheme jest zdefiniowany jako obiekt singleton; właściwości MaterialTheme.colors, shapestypography zawierają wartości bieżącego motywu.

Kreatory i języki DSL bezpieczne pod względem typów

Kotlin umożliwia tworzenie języków specyficznych dla domeny (DSL) za pomocą bezpiecznych pod względem typów konstruktorów. Języki DSL umożliwiają tworzenie złożonych hierarchicznych struktur danych w bardziej czytelny i łatwiejszy w utrzymaniu sposób.

Jetpack Compose używa języków DSL w przypadku niektórych interfejsów API, takich jak LazyRowLazyColumn.

@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 gwarantuje bezpieczeństwo typów w przypadku konstruktorów za pomocą literałów funkcji z odbiornikiem. Weźmy na przykład funkcję kompozycyjną Canvas, która przyjmuje jako parametr funkcję z odbiornikiem DrawScope, onDraw: DrawScope.() -> Unit, co umożliwia blokowi kodu wywoływanie funkcji składowych zdefiniowanych w DrawScope.

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

Więcej informacji o budowniczych bezpiecznych pod względem typów i językach DSL znajdziesz w dokumentacji języka Kotlin.

Współprogramy Kotlin

W języku Kotlin korutyny zapewniają obsługę programowania asynchronicznego na poziomie języka. Korutyny mogą wstrzymywać wykonywanie bez blokowania wątków. Interfejs użytkownika reagujący na działania użytkownika jest z natury asynchroniczny, a Jetpack Compose rozwiązuje ten problem, wykorzystując na poziomie interfejsu API coroutines zamiast wywołań zwrotnych.

Jetpack Compose udostępnia interfejsy API, które umożliwiają bezpieczne korzystanie z korutyn w warstwie interfejsu. Funkcja rememberCoroutineScope zwraca CoroutineScope, za pomocą którego możesz tworzyć współprogramy w procedurach obsługi zdarzeń i wywoływać interfejsy API zawieszania Compose. Poniżej znajdziesz przykład użycia interfejsu ScrollState API animateScrollTo.

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

Domyślnie korutyny wykonują blok kodu sekwencyjnie. Działająca korutyna, która wywołuje funkcję zawieszania, zawiesza swoje wykonanie do momentu, aż funkcja zawieszania zwróci wartość. Dzieje się tak nawet wtedy, gdy funkcja zawieszania przenosi wykonanie do innego CoroutineDispatcher. W poprzednim przykładzie funkcja loadData nie zostanie wykonana, dopóki funkcja zawieszania animateScrollTo nie zwróci wartości.

Aby wykonywać kod współbieżnie, musisz utworzyć nowe korutyny. W przykładzie powyżej do równoległego przewijania do góry ekranu i wczytywania danych z viewModel potrzebne są 2 korutyny.

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

Korutyny ułatwiają łączenie asynchronicznych interfejsów API. W poniższym przykładzie łączymy modyfikator pointerInput z interfejsami API animacji, aby animować pozycję elementu, gdy użytkownik dotknie ekranu.

@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)
        )
    }

Więcej informacji o korutynach znajdziesz w przewodniku Korutyny w Kotlinie na Androidzie.