Kotlin dla Jetpack Compose

Jetpack Compose jest oparty na języku Kotlin. 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 mentalnie tłumaczysz go na Kotlin, prawdopodobnie nie wykorzystasz w pełni możliwości Compose i możesz mieć trudności ze zrozumieniem kodu Kotlin napisanego w idiomatyczny sposób. Lepsze poznanie stylu Kotlina może pomóc Ci uniknąć tych pułapek.

Argumenty domyślne

Podczas pisania funkcji w Kotlinie 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 tych wartości. Ta funkcja zmniejsza potrzebę stosowania przeciążonych funkcji.

Załóżmy na przykład, że chcesz napisać funkcję, która rysuje kwadrat. Ta funkcja może mieć 1 wymagany parametr, sideLength, określający długość każdego boku. Może mieć kilka parametrów opcjonalnych, takich jak thickness, edgeColor itd. Jeśli wywołujący nie określi tych parametrów, the function użyje wartości domyślnych. W innych językach prawdopodobnie trzeba by napisać 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 Kotlinie 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
) {
}

Ta funkcja nie tylko pozwala uniknąć pisania wielu zbędnych funkcji, ale też 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. Ponadto nazwane parametry znacznie ułatwiają zrozumienie, co się dzieje. Jeśli spojrzysz na kod i zobaczysz wywołanie funkcji w ten sposób, możesz nie wiedzieć, co oznaczają parametry, bez sprawdzenia kodu drawSquare():

drawSquare(30, 5, Color.Red);

Natomiast ten kod jest samokomentujący:

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

Większość bibliotek Compose używa argumentów domyślnych. Dobrą praktyką jest stosowanie tej samej zasady w przypadku funkcji kompozycyjnych, które piszesz. Dzięki temu Twoje funkcje kompozycyjne są konfigurowalne, ale domyślne działanie jest nadal proste 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 ten bardziej rozbudowany kod, w którym więcej Text parametrów 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ż samokomentujący. Określając tylko parametr text, dokumentujesz, że w przypadku wszystkich innych parametrów chcesz użyć wartości domyślnych. Natomiast drugi fragment sugeruje, że chcesz jawnie ustawić wartości tych innych parametrów, chociaż ustawione wartości są wartościami domyślnymi 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 Button kompozycyjna udostępnia parametr lambda onClick. Wartością tego parametru jest 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 są obliczane jako funkcja. 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 po prostu zdefiniować funkcję w tym miejscu za pomocą wyrażenia lambda. W poprzednim przykładzie zakłada się, że funkcja myClickFunction() jest zdefiniowana w innym miejscu. Jeśli jednak używasz tej funkcji tylko tutaj, prościej jest zdefiniować ją w tekście za pomocą wyrażenia lambda:

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

Wyrażenia lambda na końcu

Kotlin oferuje specjalną składnię do 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 wyrażenia lambda na końcu. Zamiast umieszczać wyrażenie lambda w nawiasach, umieszczasz je później. W Compose jest to częsta sytuacja, dlatego musisz wiedzieć, jak wygląda kod.

Na przykład ostatnim parametrem wszystkich układów, takim 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 nich formatowanie. Ten kod będzie działać, ale jest bardzo niewygodny:

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 to samo znaczenie. Nawiasy klamrowe definiują wyrażenie lambda, które jest przekazywane do parametru content.

Jeśli jedynym przekazywanym parametrem jest wyrażenie lambda na końcu, czyli jeśli ostatni parametr jest wyrażeniem lambda i nie przekazujesz żadnych innych parametrów, możesz pominąć nawiasy. Załóżmy na przykład, że nie musisz przekazywać modyfikatora do Column. Możesz napisać kod 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ą określone w nawiasach klamrowych po wywołaniu funkcji.

Zakresy 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 unikać przypadkowego używania ich w miejscach, w których nie są odpowiednie.

Rozważmy przykład używany w Compose. Gdy wywołujesz funkcję kompozycyjną układu Row, lambda treści jest automatycznie wywoływana w RowScope. Dzięki temu Row może udostępniać funkcje, które są prawidłowe tylko w Row. Poniższy przykład pokazuje, jak Row udostępnił wartość specyficzną dla wiersza w przypadku 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ą wyrażenia lambda, które są wywoływane w zakresie odbiornika. Te wyrażenia lambda mają dostęp do właściwości i funkcji zdefiniowanych w innym miejscu 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 Kotlina w artykule o literałach funkcji z odbiornikiem.

Właściwości delegowane

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

class DelegatingClass {
    var name: String by nameGetterFunction()

    // ...
}

Inny kod może uzyskać dostęp do właściwości za pomocą takiego kodu:

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

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

Te właściwości delegowane są szczególnie przydatne, gdy pracujesz z właściwościami opartymi na stanie:

var showDialog by remember { mutableStateOf(false) }

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

remember

Destrukturyzacja klas danych

Jeśli zdefiniujesz klasę danych, możesz łatwo uzyskać dostęp do danych za pomocą deklaracji destrukturyzacji. 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ą takiego kodu:

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 udostępniają wiele innych przydatnych funkcji. Na przykład, gdy definiujesz klasę danych, kompilator automatycznie definiuje przydatne funkcje, takie jak equals() i 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ą i jedyną instancję. Te singletony są 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, shapes i typography zawierają wartości bieżącego motywu.

Budowniczowie z bezpieczeństwem typów i DSL

Kotlin umożliwia tworzenie języków specyficznych dla domeny (DSL) za pomocą budowniczych z bezpieczeństwem typów. DSL umożliwiają tworzenie złożonych hierarchicznych struktur danych w sposób bardziej czytelny i łatwiejszy w utrzymaniu.

Jetpack Compose używa DSL w przypadku niektórych interfejsów API, takich jak LazyRow i 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 gwarantuje budowniczych z bezpieczeństwem typów za pomocą literałów funkcji z odbiornikiem. Jeśli weźmiemy na przykład funkcję kompozycyjną Canvas, przyjmuje ona jako parametr funkcję z DrawScope jako odbiornikiem, onDraw: DrawScope.() -> Unit, co pozwala blokowi kodu wywoływać funkcje składowe zdefiniowane 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 z bezpieczeństwem typów i DSL znajdziesz w dokumentacji Kotlina.

Współprogramy Kotlin

Współprogramy oferują obsługę programowania asynchronicznego na poziomie języka w Kotlinie. Współprogramy mogą wstrzymywać wykonywanie bez blokowania wątków. Responsywny interfejs użytkownika jest z natury asynchroniczny, a Jetpack Compose rozwiązuje ten problem, stosując współprogramy na poziomie interfejsu API zamiast wywołań zwrotnych.

Jetpack Compose udostępnia interfejsy API, które umożliwiają bezpieczne korzystanie ze współprogramów w warstwie interfejsu użytkownika. Funkcja rememberCoroutineScope zwraca CoroutineScope, za pomocą którego możesz tworzyć współprogramy w procedurach obsługi zdarzeń i wywoływać interfejsy API wstrzymania Compose. Poniżej znajdziesz przykład użycia interfejsu API ScrollState's 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 współprogramy wykonują blok kodu sekwencyjnie. Działający współprogram, który wywołuje funkcję wstrzymania, wstrzymuje swoje wykonanie do momentu, aż funkcja wstrzymania zwróci wartość. Dzieje się tak nawet wtedy, gdy funkcja wstrzymania przenosi wykonanie do innego CoroutineDispatcher. W poprzednim przykładzie funkcja loadData nie zostanie wykonana, dopóki funkcja wstrzymania animateScrollTo nie zwróci wartości.

Aby wykonywać kod współbieżnie, trzeba utworzyć nowe współprogramy. W powyższym przykładzie, aby zrównoleglić przewijanie do góry ekranu i wczytywanie danych z viewModel, potrzebne są 2 współprogramy.

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

Współprogramy ułatwiają łączenie asynchronicznych interfejsów API. W poniższym przykładzie łączymy modyfikator pointerInput z interfejsami API animacji, aby animować położenie 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 and animate
                        // in the same block
                        awaitPointerEventScope {
                            val offset = 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 współprogramach znajdziesz w przewodniku Współprogramy Kotlin na Androidzie.