Котлин для Jetpack Compose

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

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

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

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

Для получения более подробной информации см. раздел «Функциональные литералы с помощью receiver» в документации 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()
        }
    }
) { /* ... */ }

Корутины по умолчанию выполняют блок кода последовательно . Выполняемая корутина, вызывающая функцию suspend , приостанавливает свое выполнение до тех пор, пока эта функция не вернет результат. Это справедливо даже в том случае, если функция suspend переносит выполнение к другому 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 and animate
                        // in the same block
                        awaitPointerEventScope {
                            val offset = 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 .

{% verbatim %} {% endverbatim %} {% verbatim %} {% endverbatim %}