Котлин для Jetpack Compose

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

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

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

Например, предположим, что вы хотите написать функцию, рисующую квадрат. Эта функция может иметь один обязательный параметр sideLength , задающий длину каждой стороны. У неё может быть несколько необязательных параметров, таких как thick , 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) { }

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

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

Конечные лямбды

Kotlin предлагает специальный синтаксис для вызова функций высшего порядка, последним параметром которых является лямбда-выражение. Если вы хотите передать лямбда-выражение в качестве этого параметра, вы можете использовать синтаксис завершающего лямбда-выражения . Вместо того, чтобы помещать лямбда-выражение в скобки, вы добавляете его после. Это распространённая ситуация в 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 .

Корутины Kotlin

Корутины обеспечивают поддержку асинхронного программирования на уровне языка 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 .

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