Kotlin dla Jetpack Compose

Jetpack Compose jest oparty 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 zaznajomienie się ze stylem 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 są używane, gdy wywołujący nie przekazuje tych wartości w prosty sposób. 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ć jeden wymagany parametr sideLength, który określa długość każdej ze stron. Może ona 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 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 łatwiejszy do odczytania. Jeśli wywołujący nie poda wartości argumentu, oznacza to, że chce użyć wartości domyślnej. Ponadto parametry o nazwach znacznie ułatwiają sprawdzanie, co się dzieje. Jeśli w kodzie zobaczysz takie wywołanie funkcji, bez sprawdzenia kodu drawSquare() możesz nie wiedzieć, co oznaczają parametry:

drawSquare(30, 5, Color.Red);

Ten kod zawiera swój własny opis:

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 szczegółowy 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. Drugi fragment kodu sugeruje natomiast, że chcesz jawnie ustawić wartości tych innych parametrów, choć są one domyślnymi wartościami 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. Zamiast tego możesz zdefiniować funkcję 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
    }
) { /* ... */ }

Lambda na końcu

Kotlin oferuje specjalną składnię do wywoływania funkcji wyższego rzędu, których ostatni parametr jest funkcją lambda. Jeśli chcesz przekazać wyrażenie lambda jako parametr, możesz użyć składni funkcji lambda. Zamiast umieszczać wyrażenie lambda w nawiasach, umieszczasz 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 takie 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że wyglądać tak:

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 korzystania z niej w nieodpowiednich sytuacjach.

Rozważ przykład użyty w Compose. Gdy wywołasz kompozytywny układ Row, lambda treści jest automatycznie wywoływana w ramach RowScope. Umożliwia to Row udostępnienie funkcji, które są ważne tylko w ramach 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. Te funkcje 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 na temat literaló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ą kodu takiego jak ten:

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

Gdy funkcja println() zostanie wykonana, wywołana zostanie funkcja nameGetterFunction(), która zwróci wartość ciągu znaków.

Te usługi delegowane są szczególnie przydatne, gdy pracujesz z usługami obsługiwanymi 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. Na przykład załóżmy, ż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

Taki kod często pojawia się 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 dotyczącej klas danych.

Obiekty pojedyncze

W Kotlinie łatwo zadeklarować klasy singleton, które zawsze mają tylko 1 wystąpieni. Te pojedyncze wartości 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żącej tematyki.

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. Języki DSL umożliwiają tworzenie złożonych hierarchicznych struktur danych w bardziej czytelny i łatwy 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 bezpieczne pod względem typów konstruktory, używając funkcji literalnych z parametrem odbiorczym. Jeśli weźmiemy na przykład kompozyt Canvas, zobaczymy, że przyjmuje on jako parametr funkcję z DrawScope jako odbiornikiem, onDraw: DrawScope.() -> Unit, co pozwala blokowi kodu wywoływać funkcje członkowskie 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 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. Interfejs użytkownika oparty na korespondencji jest z zasady asynchroniczny, a Jetpack Compose rozwiązuje ten problem, stosując coroutine na poziomie interfejsu API zamiast wywołań zwrotnych.

Jetpack Compose udostępnia interfejsy API, które umożliwiają bezpieczne używanie łańcuchów w warstwie interfejsu użytkownika. 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. Poniżej znajdziesz przykład użycia interfejsu animateScrollTo API usługi 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 coroutine, która wywołuje funkcję zawieszania, zawiesza swoje wykonanie do momentu, aż zwróci się funkcja zawieszania. Dzieje się tak nawet wtedy, gdy funkcja zawieszania 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, należy utworzyć nowe coroutine. W tym przykładzie, 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.