Kotlin dla Jetpack Compose

Gra Jetpack Compose opiera się na Kotlin. W niektórych przypadkach Kotlin udostępnia specjalne identyfikatory, które ułatwiają pisanie dobrego kodu w komponencie. Jeśli myślisz o innym języku programowania i tłumaczysz myślowo ten język na Kotlin, to prawdopodobnie stracisz część możliwości Compose i zrozumienie idomatycznie napisanego kodu Kotlina może okazać się trudne. Lepsze poznanie stylu Kotlina może pomóc uniknąć tych pułapek.

Argumenty domyślne

Podczas pisania funkcji Kotlin możesz określić wartości domyślne argumentów funkcji, które będą używane, jeśli element wywołujący ich nie przekaże. Ta funkcja ogranicza konieczność używania przeciążonych funkcji.

Załóżmy na przykład, że chcesz napisać funkcję, która rysuje kwadrat. Funkcja ta może mieć 1 wymagany parametr sideLength, który określa długość boku. Może mieć kilka parametrów opcjonalnych, np. thickness, edgeColor. Jeśli element wywołujący ich nie określa, funkcja używa wartości domyślnych. W innych językach możesz 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 Kotlin możesz napisać pojedynczą funkcję i podać domyślne wartości argumentów:

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

Poza tym, że nie musisz pisać wielu zbędnych funkcji, ta funkcja znacznie ułatwia czytanie kodu. Jeśli element wywołujący nie określa wartości argumentu, oznacza to, że chce użyć wartości domyślnej. Parametry nazwane też znacznie ułatwiają obserwowanie, co się dzieje. Jeśli po spojrzeniu na kod zobaczysz takie wywołanie funkcji, możesz nie wiedzieć, co oznaczają poszczególne parametry, bez sprawdzenia kodu drawSquare():

drawSquare(30, 5, Color.Red);

Z kolei ten kod sam się dokumentuje:

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

Większość bibliotek funkcji Compose używa argumentów domyślnych. Dobrym zwyczajem jest robienie tego samego w przypadku funkcji kompozycyjnych, które piszesz. Ułatwia to dostosowywanie funkcji kompozycyjnych, ale jednocześnie ułatwia wywoływanie domyślnego działania. Możesz więc na przykład utworzyć prosty element tekstowy podobny do tego:

Text(text = "Hello, Android!")

Ten kod ma taki sam efekt jak ten, o wiele bardziej szczegółowym kodu, w którym więcej parametrów Text jest ustawianych jawnie:

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

Pierwszy fragment kodu jest nie tylko o wiele prostszy i czytelniejszy, ale także sam dokumentuje się. Określając tylko parametr text, potwierdzasz, że w przypadku pozostałych parametrów chcesz używać wartości domyślnych. Z kolei drugi fragment kodu sugeruje, że chcesz jawnie ustawić wartości pozostałych parametrów, chociaż ustawione przez Ciebie wartości są wartościami domyślnymi tej funkcji.

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

Kotlin obsługuje funkcje wyższego rzędu, czyli funkcje, które otrzymują jako parametry inne funkcje. Takie podejście opiera się na tworzeniu treści. Na przykład funkcja kompozycyjna Button udostępnia parametr lambda onClick. Jego wartością jest funkcja, która jest wywoływana po kliknięciu przycisku przez użytkownika:

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

Funkcje wyższego rzędu w naturalny sposób łączą się z wyrażeniami lambda, czyli wyrażeniami, które zwracają uwagę na funkcję. Jeśli potrzebujesz tej funkcji tylko raz, nie musisz jej definiować w innym miejscu, by przekazać ją do funkcji wyższego rzędu. Zamiast tego możesz od razu zdefiniować funkcję za pomocą wyrażenia lambda. W poprzednim przykładzie przyjęto założenie, że element myClickFunction() jest zdefiniowany w innym miejscu. Jeśli jednak użyjesz tylko tej funkcji, łatwiej jest zdefiniować ją w tekście wyrażenia lambda:

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

Lambda na końcu

Kotlin udostępnia specjalną składnię do wywoływania funkcji wyższego rzędu, których ostatni parametr to lambda. Jeśli chcesz przekazać wyrażenie lambda jako ten parametr, możesz użyć składni lambda prowadzącej. Wyrażenia lambda nie można umieszczać w nawiasach, umieszczasz je później. Jest to typowa sytuacja w przypadku tworzenia wiadomości, więc musisz zobaczyć, jak wygląda kod.

Na przykład ostatnim parametrem wszystkich układów, takich jak funkcja kompozycyjna Column(), jest content – funkcja, która wyświetla podrzędne elementy interfejsu. Załóżmy, że chcesz utworzyć kolumnę zawierającą 3 elementy i zastosować formatowanie. Taki 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")
    }
)

Parametr content jest ostatnim parametrem w podpisie funkcji i przekazujemy jego wartość jako wyrażenie lambda, więc 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 określają wyrażenie lambda przekazywane do parametru content.

Jeśli jedynym przekazywanym parametrem jest lambda na końcu (tj. jeśli końcowym parametrem jest lambda i nie przekazujesz żadnych innych parametrów), możesz całkowicie pominąć nawiasy. Załóżmy np., że nie trzeba przekazywać modyfikatora do funkcji Column. Możesz napisać następujący kod:

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

Ta składnia jest często spotykana w przypadku tworzenia wiadomości, zwłaszcza w przypadku elementów układu, takich jak Column. Ostatni parametr to wyrażenie lambda definiujące elementy podrzędne elementu. Te elementy podrzędne są określane 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 jest to konieczne, i unikać przypadkowego użycia ich w nieodpowiednich przypadkach.

Przeanalizujmy przykład użyty w sekcji Tworzenie. Wywołanie funkcji kompozycyjnej Row powoduje automatyczne wywołanie funkcji lambda w elemencie RowScope. Dzięki temu Row może udostępniać funkcje, które są dostępne tylko w obrębie Row. Przykład poniżej pokazuje, jak Row ujawnił wartość modyfikatora align w konkretnym wierszu:

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ą lambda, które są wywoływane w zakresie odbiorcy. Te 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 sekcji dotyczącej literali funkcji z odbiornikiem w dokumentacji Kotlin.

Właściwości delegowane

Kotlin obsługuje usługi przekazane. Właściwości te są wywoływane tak, jakby były polami, ale ich wartość jest określana dynamicznie przez ocenę 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ą takiego kodu:

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

Podczas wykonywania println() następuje wywołanie metody nameGetterFunction() w celu zwrócenia wartości ciągu znaków.

Te właściwości z przekazanym dostępem są szczególnie przydatne podczas pracy z usługami opartymi na stanie:

var showDialog by remember { mutableStateOf(false) }

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

Niszczenie klas danych

Jeśli zdefiniujesz klasę danych, możesz łatwo uzyskać dostęp do danych za pomocą deklaracji dotyczącej zniszczenia. 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 w ten sposób:

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 mają wiele innych przydatnych funkcji. Gdy na przykład zdefiniujesz 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, czyli klas, które zawsze mają tylko 1 instancję. Takie single są zadeklarowane za pomocą słowa kluczowego object. Takie obiekty są często stosowane w funkcji tworzenia. Na przykład obiekt MaterialTheme jest zdefiniowany jako obiekt typu singleton, a właściwości MaterialTheme.colors, shapes i typography zawierają wartości bieżącego motywu.

Kreatory i DSL bezpieczne dla typu

Kotlin umożliwia tworzenie języków specyficznych dla domeny (DSL) za pomocą kreatorów bezpiecznych dla typu. Platformy DSL umożliwiają tworzenie złożonych hierarchicznych struktur danych w łatwiejszy i czytelniejszy sposób.

Jetpack Compose używa DSL w niektórych interfejsach API, takich jak LazyRow czy 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 bezpieczne typy konstruktorów z wykorzystaniem literałów funkcji z odbiornikiem. Jeśli użyjemy jako przykładu funkcji Canvas kompozycyjnej, przyjmuje on jako parametr funkcję z DrawScope odbiornikiem onDraw: DrawScope.() -> Unit, co umożliwia blokowi wywoływania funkcji składowych zdefiniowanych w elemencie 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 konstrukcjach i DSL, które są bezpieczne dla typu, znajdziesz w dokumentacji Kotlina.

współprogramy Kotlin

Korutyny oferują obsługę programowania asynchronicznego na poziomie języka w języku Kotlin. Korutyny mogą zawiesić wykonanie bez blokowania wątków. Elastyczny interfejs użytkownika jest z natury asynchroniczny. Jetpack Compose rozwiązuje ten problem, stosując współprogramy na poziomie interfejsu API, zamiast używać wywołań zwrotnych.

Jetpack Compose udostępnia interfejsy API, które sprawiają, że korzystanie z współprogramów jest bezpieczne w warstwie UI. Funkcja rememberCoroutineScope zwraca wartość CoroutineScope, za pomocą której możesz tworzyć współprogramy w modułach obsługi zdarzeń i wywoływać interfejsy API zawieszania tworzenia. Zapoznaj się z poniższym przykładem korzystania z interfejsu API animateScrollTo w 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()
        }
    }
) { /* ... */ }

Korutyny domyślnie uruchamiają blok kodu kolejnie. Uruchomiona korekta, która wywołuje funkcję zawieszania, zawiesza jej wykonanie do czasu zwrócenia tej funkcji. Dzieje się tak nawet wtedy, gdy funkcja zawieszenia przenosi wykonanie do innego obiektu CoroutineDispatcher. W poprzednim przykładzie funkcja loadData nie zostanie wykonana, dopóki nie zwróci funkcji zawieszenia animateScrollTo.

Aby równolegle uruchamiać kod, należy utworzyć nowe współprogramy. W przykładzie powyżej, aby równolegle przewijać ekran do góry i wczytywać dane z instancji viewModel, potrzebne są 2 korekty.

// 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 tym przykładzie łączymy modyfikator pointerInput z interfejsami API animacji, aby animować pozycję elementu po kliknięciu ekranu przez użytkownika.

@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 kortynach znajdziesz w przewodniku Kotlin na Androidzie.