Котлин для 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 composable в качестве примера, он принимает в качестве параметра функцию с 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 's 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()
        }
    }
) { /* ... */ }

Coroutines выполняют блок кода последовательно по умолчанию. Выполняющаяся coroutine, которая вызывает функцию suspend, приостанавливает свое выполнение до тех пор, пока функция suspend не вернет управление. Это верно даже если функция suspend перемещает выполнение в другой CoroutineDispatcher . В предыдущем примере loadData не будет выполнена до тех пор, пока функция suspend не вернет управление 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 %} {% дословно %} {% endverbatim %} ,

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 composable в качестве примера, он принимает в качестве параметра функцию с 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 's 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()
        }
    }
) { /* ... */ }

Coroutines выполняют блок кода последовательно по умолчанию. Выполняющаяся coroutine, которая вызывает функцию suspend, приостанавливает свое выполнение до тех пор, пока функция suspend не вернет управление. Это верно даже если функция suspend перемещает выполнение в другой CoroutineDispatcher . В предыдущем примере loadData не будет выполнена до тех пор, пока функция suspend не вернет управление 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 %} {% дословно %} {% endverbatim %} ,

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 composable в качестве примера, он принимает в качестве параметра функцию с 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 's 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()
        }
    }
) { /* ... */ }

Coroutines выполняют блок кода последовательно по умолчанию. Выполняющаяся coroutine, которая вызывает функцию suspend, приостанавливает свое выполнение до тех пор, пока функция suspend не вернет управление. Это верно даже если функция suspend перемещает выполнение в другой CoroutineDispatcher . В предыдущем примере loadData не будет выполнена до тех пор, пока функция suspend не вернет управление 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 %} {% дословно %} {% endverbatim %}