Kotlin для 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 основан на этом подходе. Например, composable-функция Button предоставляет параметр лямбда-функции onClick. Значение этого параметра – функция, которую кнопка вызывает, когда пользователь нажимает на нее:

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

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

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

Замыкающие лямбда-выражения

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

Например, последний параметр всех макетов, таких как composable-функция 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. Когда вы вызываете composable-функцию макета 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(). Подробную информацию можно найти в документации по классам данных.

Объекты Singleton

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

Безопасные по типам конструкторы и DSL

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

В Jetpack Compose для некоторых API, например LazyRow и LazyColumn, используются DSL.

@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 можно создавать безопасные по типам конструкторы, используя литералы функций с получателем. Возьмем в качестве примера composable-функцию 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 suspend. Ниже приведен пример использования API ScrollState animateScrollTo.

// 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 переносит выполнение в другой 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.