Котлин для Jetpack Compose

Jetpack Compose построен на базе Kotlin. В некоторых случаях Kotlin предоставляет специальные идиомы, которые упрощают написание хорошего кода Compose. Если вы думаете на другом языке программирования и мысленно переводите этот язык на Kotlin, вы, скорее всего, упустите некоторые преимущества Compose, и вам может быть трудно понять идиоматически написанный код Kotlin. Более глубокое знакомство со стилем Kotlin поможет вам избежать этих ошибок.

Аргументы по умолчанию

Когда вы пишете функцию Kotlin, вы можете указать значения по умолчанию для аргументов функции , которые будут использоваться, если вызывающая сторона не передает эти значения явно. Эта функция снижает потребность в перегруженных функциях.

Например, предположим, что вы хотите написать функцию, рисующую квадрат. Эта функция может иметь единственный обязательный параметрsideLength , определяющий длину каждой стороны. Он может иметь несколько дополнительных параметров, таких как толщина , EdgeColor и т. д.; если вызывающая сторона не указывает их, функция использует значения по умолчанию. На других языках вы можете написать несколько функций:

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

В Котлине вы можете написать одну функцию и указать для аргументов значения по умолчанию:

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

Эта функция не только избавляет вас от необходимости писать несколько избыточных функций, но и делает ваш код более понятным для чтения. Если вызывающая сторона не указывает значение аргумента, это означает, что она желает использовать значение по умолчанию. Кроме того, именованные параметры значительно облегчают понимание того, что происходит. Если вы посмотрите на код и увидите такой вызов функции, вы можете не знать, что означают параметры, без проверки кода drawSquare() :

drawSquare(30, 5, Color.Red);

Напротив, этот код является самодокументируемым:

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

Большинство библиотек Compose используют аргументы по умолчанию, и рекомендуется делать то же самое для составных функций, которые вы пишете. Эта практика делает ваши составные объекты настраиваемыми, но при этом упрощает вызов поведения по умолчанию. Так, например, вы можете создать простой текстовый элемент следующим образом:

Text(text = "Hello, Android!")

Этот код имеет тот же эффект, что и следующий, гораздо более подробный код, в котором явно задано больше параметров Text :

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

Первый фрагмент кода не только намного проще и легче читается, но и самодокументируется. Указывая только text параметр, вы документируете, что для всех остальных параметров вы хотите использовать значения по умолчанию. Напротив, второй фрагмент подразумевает, что вы хотите явно установить значения для этих других параметров, хотя заданные вами значения являются значениями по умолчанию для функции.

Функции высшего порядка и лямбда-выражения

Kotlin поддерживает функции высшего порядка , функции, которые получают другие функции в качестве параметров. Compose основан на этом подходе. Например, составная функция Button предоставляет лямбда-параметр onClick . Значением этого параметра является функция, которую вызывает кнопка, когда пользователь нажимает на нее:

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

Функции высшего порядка естественным образом сочетаются с лямбда-выражениями , выражениями, результатом которых является функция. Если вам нужна функция только один раз, вам не нужно определять ее где-то еще, чтобы передать ее функции более высокого порядка. Вместо этого вы можете просто определить функцию прямо здесь с помощью лямбда-выражения. В предыдущем примере предполагается, что myClickFunction() определен где-то еще. Но если вы используете здесь только эту функцию, проще просто определить функцию в строке с помощью лямбда-выражения:

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

Завершающие лямбды

Котлин предлагает специальный синтаксис для вызова функций высшего порядка, последний параметр которых — лямбда. Если вы хотите передать лямбда-выражение в качестве этого параметра, вы можете использовать синтаксис завершающего лямбда-выражения . Вместо того, чтобы помещать лямбда-выражение в круглые скобки, вы помещаете его позже. Это обычная ситуация в Compose, поэтому вам необходимо знать, как выглядит код.

Например, последний параметр всех макетов, таких как составная функция Column() , — это content — функция, которая генерирует дочерние элементы пользовательского интерфейса. Предположим, вы хотите создать столбец, содержащий три текстовых элемента, и вам нужно применить некоторое форматирование. Этот код будет работать, но он очень громоздкий:

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

Поскольку параметр content является последним в сигнатуре функции, и мы передаем его значение как лямбда-выражение, мы можем вытащить его из круглых скобок:

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

Оба примера имеют одно и то же значение. Фигурные скобки определяют лямбда-выражение, которое передается в параметр content .

Фактически, если единственным передаваемым параметром является конечная лямбда, то есть если последний параметр является лямбда, и вы не передаете никаких других параметров, вы можете вообще опустить круглые скобки. Итак, предположим, что вам не нужно передавать модификатор Column . Вы можете написать такой код:

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

Этот синтаксис довольно распространен в Compose, особенно для таких элементов макета, как Column . Последний параметр — это лямбда-выражение, определяющее дочерние элементы элемента, и эти дочерние элементы указываются в фигурных скобках после вызова функции.

Прицелы и приемники

Некоторые методы и свойства доступны только в определенной области. Ограниченная область применения позволяет предлагать функциональность там, где она необходима, и избегать случайного использования этой функциональности там, где она неуместна.

Рассмотрим пример, используемый в Compose. Когда вы вызываете компонуемый макет Row , ваша лямбда-контент автоматически вызывается внутри RowScope . Это позволяет Row предоставлять функциональные возможности, которые действительны только внутри Row . В приведенном ниже примере показано, как Row предоставил специфичное для строки значение модификатора 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)
    )
}

Некоторые API принимают лямбда-выражения, которые вызываются в области получателя . Эти лямбды имеют доступ к свойствам и функциям, которые определены в другом месте на основе объявления параметра:

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

Дополнительную информацию см. в разделе «Функциональные литералы с получателем» в документации Kotlin.

Делегированные свойства

Kotlin поддерживает делегированные свойства . Эти свойства вызываются так, как если бы они были полями, но их значение определяется динамически путем вычисления выражения. Вы можете распознать эти свойства по синтаксису by :

class DelegatingClass {
    var name: String by nameGetterFunction()

    // ...
}

Другой код может получить доступ к свойству с помощью такого кода:

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

При выполнении println() вызывается nameGetterFunction() для возврата значения строки.

Эти делегированные свойства особенно полезны при работе со свойствами, поддерживаемыми состоянием:

var showDialog by remember { mutableStateOf(false) }

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

Деструктуризация классов данных

Если вы определяете класс данных , вы можете легко получить доступ к данным с помощью объявления деструктуризации . Например, предположим, что вы определяете класс Person :

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

Если у вас есть объект этого типа, вы можете получить доступ к его значениям с помощью такого кода:

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

// ...

val (name, age) = mary

Вы часто будете видеть такой код в функциях 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.

    // ...
}

Классы данных предоставляют множество других полезных функций. Например, когда вы определяете класс данных, компилятор автоматически определяет полезные функции, такие как equals() и copy() . Дополнительную информацию можно найти в документации по классам данных .

Одиночные объекты

Kotlin позволяет легко объявлять синглтоны — классы, которые всегда имеют один и только один экземпляр. Эти синглтоны объявляются с помощью ключевого слова object . Compose часто использует такие объекты. Например, MaterialTheme определяется как одноэлементный объект; Свойства MaterialTheme.colors , shapes и typography содержат значения для текущей темы.

Типобезопасные сборщики и DSL

Kotlin позволяет создавать предметно-ориентированные языки (DSL) с помощью типобезопасных сборщиков. DSL позволяют создавать сложные иерархические структуры данных более удобным для обслуживания и чтения способом.

Jetpack Compose использует DSL для некоторых API, таких как LazyRow и 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 гарантирует типобезопасность сборщиков, использующих функциональные литералы с получателем . Если мы возьмем в качестве примера компонуемый Canvas , он принимает в качестве параметра функцию с DrawScope в качестве получателя, onDraw: DrawScope.() -> Unit , что позволяет блоку кода вызывать функции-члены, определенные в 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)
        }
    }
}

Узнайте больше о типобезопасных сборщиках и DSL в документации Kotlin .

Сопрограммы Котлина

Сопрограммы предлагают поддержку асинхронного программирования на уровне языка в Котлине. Сопрограммы могут приостанавливать выполнение, не блокируя потоки. Адаптивный пользовательский интерфейс по своей сути асинхронен, и Jetpack Compose решает эту проблему, используя сопрограммы на уровне API вместо использования обратных вызовов.

Jetpack Compose предлагает API, которые делают использование сопрограмм безопасным на уровне пользовательского интерфейса. Функция rememberCoroutineScope возвращает CoroutineScope , с помощью которого вы можете создавать сопрограммы в обработчиках событий и вызывать API приостановки Compose. См. пример ниже с использованием API animateScrollTo 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()
        }
    }
) { /* ... */ }

По умолчанию сопрограммы выполняют блок кода последовательно . Запущенная сопрограмма, вызывающая функцию приостановки , приостанавливает свое выполнение до тех пор, пока функция приостановки не вернется. Это верно, даже если функция приостановки перемещает выполнение в другой CoroutineDispatcher . В предыдущем примере loadData не будет выполняться до тех пор, пока не вернется функция приостановки animateScrollTo .

Для одновременного выполнения кода необходимо создать новые сопрограммы. В приведенном выше примере для распараллеливания прокрутки к верху экрана и загрузки данных из viewModel необходимы две сопрограммы.

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

Сопрограммы упрощают объединение асинхронных API. В следующем примере мы объединяем модификатор pointerInput с API-интерфейсами анимации, чтобы анимировать положение элемента, когда пользователь касается экрана.

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

Чтобы узнать больше о сопрограммах, ознакомьтесь с руководством по сопрограммам Kotlin на Android .

{% дословно %} {% дословно %} {% дословно %} {% дословно %}