Kotlin dla Jetpack Compose

Jetpack Compose opiera się na Kotlinie. W niektórych przypadkach Kotlin udostępnia specjalne idiomy, które ułatwiają tworzenie dobrego kodu Compose. Jeśli myślisz w innym języku programowania i mentalnie tłumaczysz ten język na Kotlin, prawdopodobnie nie wykorzystasz w pełni zalet Compose i może Ci być trudno zrozumieć kod Kotlina napisany w stylu idiomatycznym. Lepsze poznanie stylu Kotlina może pomóc 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 są używane, gdy wywołujący nie przekazuje tych wartości w prosty sposób. Zmniejsza to potrzebę przeciążonych funkcji.

Załóżmy na przykład, że chcesz napisać funkcję, która rysuje kwadrat. Ta funkcja może mieć pojedynczy wymagany parametr sideLength, który określa długość każdego boku. Może mieć kilka parametrów opcjonalnych, takich jak thickness, edgeColor itp. Jeśli element wywołujący ich nie określi, funkcja użyje wartości domyślnych. W innych językach możesz użyć kilku 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ć domyślne wartości argumentów:

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

Dzięki tej funkcji nie musisz pisać wielu zbędnych funkcji, a Twój kod będzie znacznie czytelniejszy. Jeśli wywołujący nie poda wartości argumentu, oznacza to, że chce użyć wartości domyślnej. Nazwa parametrów ułatwia też znacznie szybsze sprawdzanie, co się dzieje. Jeśli w kodzie znajdziesz takie wywołanie funkcji, możesz nie wiedzieć, co oznaczają poszczególne parametry, bez sprawdzenia kodu drawSquare():

drawSquare(30, 5, Color.Red);

Ten kod jest natomiast samoudokumentujący się:

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 kompozytowych, które piszesz. Dzięki temu możesz dostosowywać swoje komponenty, ale nadal możesz łatwo wywołać domyślne zachowanie. Możesz na przykład utworzyć prosty element tekstowy:

Text(text = "Hello, Android!")

Ten kod ma taki sam efekt jak poniższy, znacznie bardziej obszerny 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ż samodokumentujący. Określając tylko parametr text, wskazujesz, że w przypadku wszystkich pozostałych parametrów chcesz użyć wartości domyślnych. Z kolei drugi fragment oznacza, że chcesz bezpośrednio ustawić wartości tych innych parametrów, ale ustawione przez Ciebie wartości są wartościami domyślnymi dla funkcji.

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

Kotlin obsługuje funkcje wyższego rzędu, czyli takie, które otrzymują inne funkcje jako parametry. Narzędzie Compose opiera się na tym podejściu. Na przykład funkcja składana Button udostępnia parametr lambda onClick. Wartość tego parametru to funkcja, którą przycisk wywołuje po kliknięciu przez użytkownika:

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

Funkcje wyższego rzędu naturalnie łączą się z wyrażeniami lambda, które są obliczane jako funkcje. Jeśli funkcja jest potrzebna tylko raz, nie musisz jej definiować w innym miejscu, aby przekazać ją funkcji wyższego rzędu. Możesz po prostu zdefiniować funkcję od razu za pomocą wyrażenia lambda. W poprzednim przykładzie zakładamy, że myClickFunction() jest zdefiniowany gdzie indziej. Jeśli jednak używasz tej funkcji tylko w tym miejscu, łatwiej jest zdefiniować ją w ramach wyrażenia lambda:

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

Wyniki lambda

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

Na przykład ostatni parametr wszystkich układów, takich jak funkcja kompozytowa Column(), to content, czyli funkcja, która emituje podrzędne elementy interfejsu użytkownika. Załóżmy, że chcesz utworzyć kolumnę zawierającą 3 elementy tekstowe, do których chcesz zastosować formatowanie. Ten kod zadziała, ale jest bardzo kłopotliwy:

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

Ponieważ parametr content jest ostatnim w podpisie funkcji, a jego wartość jest wyrażeniem lambda, możemy go wyjąć 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. Zwięzły określa wyrażenie lambda przekazywane do parametru content.

Jeśli jedynym parametrem, który przekazujesz, jest ta ostatnia funkcja lambda, czyli jeśli ostatni parametr to funkcja lambda i nie przekazujesz żadnych innych parametrów, możesz całkowicie pominąć nawiasy. Załóżmy na przykład, że nie musisz przekazywać modyfikatora do funkcji 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 podrzędne elementu. Te podrzędne są określone w nawiasach klamrowych po wywołaniu funkcji.

Zakresy i odbiorcy

Niektóre metody i właściwości są dostępne tylko w określonym zakresie. Ograniczony zakres umożliwia oferowanie funkcji tam, gdzie jest to potrzebne, i unikanie przypadkowego używania tej funkcji w nieodpowiednich sytuacjach.

Rozważ przykład użyty w Compose. Gdy wywołujesz funkcję kompozycyjną Row, funkcja lambda treści jest automatycznie wywoływana w elemencie RowScope. Dzięki temu Row może udostępniać funkcje, które działają tylko w obrębie Row. Przykład poniżej pokazuje, jak funkcja Row udostępnia wartość 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ą funkcje lambda wywoływane w zakresie odbiornika. Na podstawie deklaracji parametrów te elementy lambda mają dostęp do właściwości i funkcji zdefiniowanych w innym miejscu:

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 na temat literałów funkcji z odbiorcą.

Właściwości delegowane

Kotlin obsługuje delegowane właściwości. Te właściwości są wywoływane tak, jakby były polami, ale ich wartość jest określana dynamicznie przez wykonanie wyrażenia. Możesz rozpoznać te właściwości 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ą takiego kodu:

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

Po wykonaniu funkcji println() wywoływana jest funkcja nameGetterFunction(), która zwraca wartość ciągu.

Te usługi delegowane są szczególnie przydatne w przypadku usług obsługiwanych przez stan:

var showDialog by remember { mutableStateOf(false) }

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

Przekształcanie 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 zdefiniujesz 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

W funkcjach tworzenia wiadomości często można napotkać taki kod:

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 dotyczącej klas danych.

Obiekty pojedyncze

Kotlin ułatwia deklarowanie klas singleton, które zawsze mają tylko 1 występowanie. Te singleton są deklarowane za pomocą słowa kluczowego object. Compose często korzysta z takich obiektów. Na przykład obiekt MaterialTheme jest zdefiniowany jako obiekt pojedynczy; właściwości MaterialTheme.colors, shapes i typography zawierają wartości bieżącego motywu.

Typowo bezpieczne kreatory i języki opisu danych

Kotlin umożliwia tworzenie języków specyficznych dla danej dziedziny (DSL) za pomocą konstruktorów bezpiecznych pod względem typów. Platformy DSL umożliwiają tworzenie złożonych hierarchicznych struktur danych w bardziej łatwy w utrzymaniu i czytelny 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 bezpieczne pod względem typów konstruktory, używając funkcji literalnych z parametrem odbiorczym. Jeśli weźmiemy funkcję kompozycyjną Canvas jako parametr, jako parametr przyjmuje się funkcję, której odbiornikiem jest 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 bezpiecznych konstruktorach i językach DSL znajdziesz w dokumentacji Kotlina.

współprogramy Kotlina

W Kotlinie coroutines zapewniają obsługę programowania asynchronicznego na poziomie języka. Coroutines może wstrzymać wykonywanie bez blokowania wątków. Elastyczny interfejs jest z natury asynchroniczny, a Jetpack Compose rozwiązuje ten problem, stosując współrzędne na poziomie interfejsu API, zamiast korzystać z wywołań zwrotnych.

Jetpack Compose udostępnia interfejsy API, które zapewniają bezpieczne używanie współrzędnych w warstwie interfejsu. Funkcja rememberCoroutineScope zwraca obiekt CoroutineScope, za pomocą którego możesz tworzyć łańcuchy w metodach obsługi zdarzeń i wywoływać zawieszone interfejsy Compose. Zobacz przykład poniżej. Użyjesz interfejsu API animateScrollTo interfejsu ScrollState.

// 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 uruchamiają one blok kodu sekwencyjnie. Bieżąca koordynatowa, która wywołuje funkcję zawieszania, wstrzymuje swoje wykonanie do momentu, aż zwróci się funkcja zawieszania. Dzieje się tak nawet wtedy, gdy funkcja zawieszenia przenosi wykonanie do innego CoroutineDispatcher. W poprzednim przykładzie instrukcja loadData nie zostanie wykonana, dopóki nie zwróci wartości funkcja zawieszania animateScrollTo.

Aby kod był wykonywany równolegle, trzeba utworzyć nowe coroutine. W przykładzie powyżej, aby równolegle przewijać do góry ekranu i wczytywać dane z viewModel, potrzebne są 2 korobony.

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

Dzięki nim łatwiej jest łączyć asynchroniczne interfejsy API. W tym przykładzie łączymy modyfikator pointerInput z interfejsami API animacji, aby animować pozycję elementu, gdy użytkownik kliknie ekran.

@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 korobocjach znajdziesz w artykule Korobocje w Kotlinie na Androida.