Принципы работы с Compose

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

Декларативная парадигма программирования

Исторически иерархия View Android могла быть представлена в виде дерева виджетов пользовательского интерфейса. Поскольку состояние приложения меняется из-за действий пользователя, иерархию интерфейса необходимо обновлять, чтобы отображать актуальные данные. Чаще всего для обновления интерфейса используется обход дерева с помощью таких функций, как findViewById(), и изменение узлов с помощью таких методов, как button.setText(String), container.addChild(View) или img.setImageBitmap(Bitmap). Эти методы изменяют внутреннее состояние виджета.

При ручном изменении представлений возрастает вероятность ошибок. Если один и тот же фрагмент данных отображается в нескольких местах, вы можете забыть обновить одно из представлений, в котором он показывается. Это также может привести к незаконным состояниям, когда два обновления конфликтуют неожиданным образом. Например, при обновлении может быть предпринята попытка задать значение узла, который только что был удален из интерфейса. Как правило, сложность обслуживания ПО возрастает с увеличением количества представлений, которые необходимо обновить.

В последние несколько лет вся индустрия переходит на декларативную модель интерфейса, которая упрощает создание и обновление пользовательских интерфейсов.

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

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

Пример composable-функции

С помощью Compose можно создавать пользовательский интерфейс, определяя набор компонуемых функций, которые принимают данные и выводят элементы интерфейса. Примером может служить виджет Greeting, который принимает виджет String и возвращает виджет Text, отображающий приветственное сообщение.

Телефон с текстом "Hello World" и кодом composable-функции, которая генерирует этот интерфейс.
Рисунок 1. Компонуемая функция, которая получает данные и использует их для отрисовки текстового виджета на экране.

Вот что важно знать об этой функции:

  • Аннотация. Функция аннотирована с помощью аннотации @Composable. Все функции, которые можно использовать в композиции, должны иметь эту аннотацию. Эта аннотация сообщает компилятору Compose, что функция предназначена для преобразования данных в интерфейс.
  • Входные данные. Функция принимает данные. Компонуемые функции могут принимать параметры, которые позволяют логике приложения описывать интерфейс. В этом случае наш виджет принимает String, чтобы обращаться к пользователю по имени.
  • Отображение в интерфейсе. Функция показывает текст в интерфейсе. Для этого вызывается composable-функция Text(), которая создает текстовый элемент интерфейса. Composable-функции создают иерархию интерфейса, вызывая другие composable-функции.
  • Нет возвращаемого значения. Функция ничего не возвращает. Функции Compose, которые создают элементы интерфейса, не должны ничего возвращать, поскольку они описывают целевое состояние экрана, а не создают виджеты.
  • Свойства. Эта функция выполняется быстро, идемпотентна и не имеет побочных эффектов.

    • Функция ведет себя одинаково при многократном вызове с одним и тем же аргументом и не использует другие значения, например глобальные переменные или вызовы random().
    • Функция описывает интерфейс без побочных эффектов, например не изменяет свойства или глобальные переменные.

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

Переход к декларативной парадигме

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

При декларативном подходе Compose виджеты относительно не имеют состояния и не предоставляют функции установки или получения. На самом деле виджеты не представлены как объекты. Вы обновляете интерфейс, вызывая ту же composable-функцию, но с другими аргументами. Это упрощает передачу состояния в архитектурные шаблоны, такие как ViewModel, как описано в руководстве по архитектуре приложений. Затем ваши функции, созданные с помощью Compose, преобразуют текущее состояние приложения в интерфейс каждый раз, когда обновляются наблюдаемые данные.

Иллюстрация потока данных в интерфейсе Compose, от объектов верхнего уровня до их дочерних элементов.
Рисунок 2. Логика приложения предоставляет данные для composable-функции верхнего уровня. Эта функция использует данные для описания интерфейса, вызывая другие функции, и передает им подходящие данные, и так далее по иерархии.

Когда пользователь взаимодействует с интерфейсом, он создает события, например onClick. Эти события должны уведомлять логику приложения, которая затем может изменить состояние приложения. Когда состояние меняется, вызываются композитные функции с новыми данными. В результате элементы интерфейса перерисовываются. Этот процесс называется рекомпозицией.

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

Динамический контент

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

@Composable
fun Greeting(names: List<String>) {
    for (name in names) {
        Text("Hello $name")
    }
}

Эта функция принимает список имен и создает приветствие для каждого пользователя. Composable-функции могут быть довольно сложными. С помощью выражений if можно определить, нужно ли показывать определенный элемент интерфейса. Вы можете использовать циклы. Вы можете вызывать вспомогательные функции. Вы можете использовать все возможности языка. Такая гибкость и возможности – одно из ключевых преимуществ Jetpack Compose.

Рекомпозиция

В императивной модели интерфейса, чтобы изменить виджет, вы вызываете для него метод установки, который меняет его внутреннее состояние. В Compose вы снова вызываете composable-функцию с новыми данными. В результате функция перекомпоновывается: виджеты, созданные функцией, при необходимости перерисовываются с новыми данными. Фреймворк Compose может интеллектуально перекомпоновать только те компоненты, которые изменились.

Например, рассмотрим composable-функцию, которая отображает кнопку:

@Composable
fun ClickCounter(clicks: Int, onClick: () -> Unit) {
    Button(onClick = onClick) {
        Text("I've been clicked $clicks times")
    }
}

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

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

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

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

  • Запись в свойство общего объекта
  • Как изменить наблюдаемый объект в ViewModel
  • Обновление общих настроек

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

Например, этот код создает composable-функцию для обновления значения в SharedPreferences. Компонуемый элемент не должен самостоятельно считывать и записывать данные в общие настройки. Вместо этого код переносит операции чтения и записи в ViewModel в фоновой сопрограмме. Логика приложения передает текущее значение с обратным вызовом, чтобы запустить обновление.

@Composable
fun SharedPrefsToggle(
    text: String,
    value: Boolean,
    onValueChanged: (Boolean) -> Unit
) {
    Row {
        Text(text)
        Checkbox(checked = value, onCheckedChange = onValueChanged)
    }
}

В этой статье рассказывается о том, что нужно учитывать при использовании Compose:

  • При повторной композиции пропускаются все возможные функции и лямбда-выражения.
  • Рекомпозиция выполняется с оптимизмом и может быть отменена.
  • Composable-функции могут выполняться довольно часто, например каждый кадр анимации.
  • Composable-функции могут выполняться параллельно.
  • Комбинируемые функции могут выполняться в любом порядке.

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

Рекомпозиция пропускает как можно больше фрагментов.

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

Каждая composable-функция и лямбда-выражение могут быть перекомпонованы по отдельности. В примере ниже показано, как при повторной композиции некоторые элементы могут быть пропущены при отрисовке списка:

/**
 * Display a list of names the user can click with a header
 */
@Composable
fun NamePicker(
    header: String,
    names: List<String>,
    onNameClicked: (String) -> Unit
) {
    Column {
        // this will recompose when [header] changes, but not when [names] changes
        Text(header, style = MaterialTheme.typography.bodyLarge)
        HorizontalDivider()

        // LazyColumn is the Compose version of a RecyclerView.
        // The lambda passed to items() is similar to a RecyclerView.ViewHolder.
        LazyColumn {
            items(names) { name ->
                // When an item's [name] updates, the adapter for that item
                // will recompose. This will not recompose when [header] changes
                NamePickerItem(name, onNameClicked)
            }
        }
    }
}

/**
 * Display a single name the user can click.
 */
@Composable
private fun NamePickerItem(name: String, onClicked: (String) -> Unit) {
    Text(name, Modifier.clickable(onClick = { onClicked(name) }))
}

Каждая из этих областей действия может быть единственным элементом, который будет выполнен во время рекомпозиции. При изменении header функция Compose может перейти к лямбда-выражению Column, не выполняя родительские функции. При выполнении Column Compose может пропустить элементы LazyColumn, если names не изменился.

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

Рекомпозиция оптимистична

Рекомпозиция начинается, когда Compose считает, что параметры composable-функции могли измениться. Повторная композиция оптимистична, то есть Compose ожидает, что она будет завершена до того, как параметры снова изменятся. Если параметр изменится до завершения рекомпозиции, Compose может отменить ее и начать заново с новым параметром.

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

Убедитесь, что все функции, которые можно комбинировать, и лямбда-выражения идемпотентны и не имеют побочных эффектов, чтобы обеспечить оптимистическую повторную композицию.

Composable-функции могут выполняться довольно часто

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

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

Если composable-функции нужны данные, определите параметры для этих данных. Затем вы можете перенести ресурсоемкие задачи в другой поток, за пределы композиции, и передать полученное значение в composable-функцию в качестве параметра, используя mutableStateOf или LiveData.

Composable-функции могут выполняться параллельно

Compose может оптимизировать рекомпозицию, выполняя composable-функции параллельно. Это позволит Compose использовать несколько ядер и выполнять функции, не относящиеся к экрану, с более низким приоритетом.

Это означает, что composable-функция может выполняться в пуле фоновых потоков. Если composable-функция вызывает функцию в ViewModel, Compose может вызвать эту функцию из нескольких потоков одновременно.

Чтобы убедиться, что приложение работает правильно, все функции, которые можно составить, не должны иметь побочных эффектов. Вместо этого запускайте побочные эффекты из обратных вызовов, например onClick, которые всегда выполняются в потоке UI.

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

Ниже приведен пример composable-функции, которая показывает список и его размер:

@Composable
fun ListComposable(myList: List<String>) {
    Row(horizontalArrangement = Arrangement.SpaceBetween) {
        Column {
            for (item in myList) {
                Text("Item: $item")
            }
        }
        Text("Count: ${myList.size}")
    }
}

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

@Composable
fun ListWithBug(myList: List<String>) {
    var items = 0

    Row(horizontalArrangement = Arrangement.SpaceBetween) {
        Column {
            for (item in myList) {
                Card {
                    Text("Item: $item")
                    items++ // Avoid! Side-effect of the column recomposing.
                }
            }
        }
        Text("Count: $items")
    }
}

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

Composable-функции могут выполняться в любом порядке

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

Предположим, у вас есть код, который рисует три экрана в макете с вкладками:

@Composable
fun ButtonRow() {
    MyFancyNavigation {
        StartScreen()
        MiddleScreen()
        EndScreen()
    }
}

Вызовы StartScreen, MiddleScreen и EndScreen могут выполняться в любом порядке. Это означает, что, например, функция StartScreen() не может задать какую-либо глобальную переменную (побочный эффект), а функция MiddleScreen() не может воспользоваться этим изменением. Вместо этого каждая функция должна быть самодостаточной.

Подробнее…

Чтобы узнать больше о том, как работать с Compose и функциями, созданными с помощью этого инструмента, ознакомьтесь со следующими ресурсами:

Видео