Jetpack Compose — это современный декларативный набор инструментов для разработки пользовательского интерфейса для Android. Compose упрощает написание и поддержку пользовательского интерфейса приложения, предоставляя декларативный API , позволяющий визуализировать пользовательский интерфейс приложения без императивной мутации представлений интерфейса. Эта терминология требует пояснений, но её значение важно для проектирования вашего приложения.
Парадигма декларативного программирования
Исторически иерархия представлений Android представлялась в виде дерева виджетов пользовательского интерфейса. Поскольку состояние приложения изменяется, например, из-за взаимодействия с пользователем, иерархию пользовательского интерфейса необходимо обновлять для отображения текущих данных. Наиболее распространённый способ обновления пользовательского интерфейса — это обход дерева с помощью функций, таких как findViewById()
, и изменение узлов с помощью методов, таких как button.setText(String)
, container.addChild(View)
или img.setImageBitmap(Bitmap)
. Эти методы изменяют внутреннее состояние виджета.
Ручное управление представлениями увеличивает вероятность ошибок. Если фрагмент данных отображается в нескольких местах, легко забыть обновить одно из представлений, в котором он отображается. Также легко создать недопустимые состояния, когда два обновления конфликтуют неожиданным образом. Например, обновление может попытаться установить значение узла, только что удалённого из пользовательского интерфейса. В целом, сложность обслуживания программного обеспечения растёт с увеличением количества представлений, требующих обновления.
В последние несколько лет вся индустрия начала переходить на декларативную модель пользовательского интерфейса, которая значительно упрощает разработку и обновление пользовательских интерфейсов. Этот метод заключается в концептуальной реконструкции всего экрана с нуля с последующим применением только необходимых изменений. Такой подход позволяет избежать сложного ручного обновления иерархии представлений с сохранением состояния. Compose — это декларативный фреймворк для разработки пользовательского интерфейса.
Одна из проблем, связанных с перерисовкой всего экрана, заключается в том, что это потенциально затратно с точки зрения времени, вычислительной мощности и расхода заряда батареи. Чтобы снизить эти затраты, Compose интеллектуально выбирает, какие части пользовательского интерфейса необходимо перерисовывать в любой момент времени. Это имеет определённые последствия для проектирования компонентов пользовательского интерфейса, как обсуждалось в разделе «Перерисовка» .
Простая компонуемая функция
Используя Compose, вы можете создать пользовательский интерфейс, определив набор компонуемых функций, которые принимают данные и выдают элементы пользовательского интерфейса. Простым примером является виджет Greeting
, который принимает String
и выдает виджет Text
, отображающий приветственное сообщение.
Рисунок 1. Простая компонуемая функция, которая получает данные и использует их для отображения текстового виджета на экране.
Несколько примечательных вещей об этой функции:
Функция аннотирована аннотацией
@Composable
. Все функции Composable должны иметь эту аннотацию; она сообщает компилятору Compose, что функция предназначена для преобразования данных в пользовательский интерфейс.Функция принимает данные. Компонуемые функции могут принимать параметры, позволяющие логике приложения описывать пользовательский интерфейс. В данном случае наш виджет принимает
String
, чтобы приветствовать пользователя по имени.Функция отображает текст в пользовательском интерфейсе. Это достигается путём вызова компонуемой функции
Text()
, которая фактически создаёт текстовый элемент пользовательского интерфейса. Компонуемые функции формируют иерархию пользовательского интерфейса, вызывая другие компонуемые функции.Функция ничего не возвращает. Функции Compose, генерирующие пользовательский интерфейс, не обязаны ничего возвращать, поскольку они описывают желаемое состояние экрана, а не создают виджеты пользовательского интерфейса.
Эта функция быстра, идемпотентна и не имеет побочных эффектов .
- Функция ведет себя одинаково при многократном вызове с одним и тем же аргументом и не использует другие значения, такие как глобальные переменные или вызовы
random()
. - Функция описывает пользовательский интерфейс без каких-либо побочных эффектов, таких как изменение свойств или глобальных переменных.
В общем случае все компонуемые функции должны быть написаны с этими свойствами по причинам, обсуждаемым в разделе «Перекомпозиция» .
- Функция ведет себя одинаково при многократном вызове с одним и тем же аргументом и не использует другие значения, такие как глобальные переменные или вызовы
Декларативный сдвиг парадигмы
Во многих императивных объектно-ориентированных инструментах для создания пользовательского интерфейса инициализация пользовательского интерфейса осуществляется путем создания дерева виджетов. Часто это делается путем расширения XML-файла макета. Каждый виджет поддерживает собственное внутреннее состояние и предоставляет методы получения и установки, позволяющие логике приложения взаимодействовать с виджетом.
В декларативном подходе Compose виджеты практически не имеют состояния и не предоставляют функции установки и получения данных. Фактически, виджеты не представлены как объекты. Пользовательский интерфейс обновляется путем вызова одной и той же компонуемой функции с разными аргументами. Это упрощает предоставление состояния архитектурным шаблонам, таким как ViewModel
, как описано в Руководстве по архитектуре приложений . Затем компонуемые элементы отвечают за преобразование текущего состояния приложения в пользовательский интерфейс при каждом обновлении наблюдаемых данных.
Рисунок 2. Логика приложения предоставляет данные компонуемой функции верхнего уровня. Эта функция использует эти данные для описания пользовательского интерфейса, вызывая другие компонуемые функции, и передаёт соответствующие данные этим компонуемым функциям и далее вниз по иерархии.
Когда пользователь взаимодействует с пользовательским интерфейсом, он генерирует события, такие как onClick
. Эти события должны уведомлять логику приложения, которая затем может изменить его состояние. При изменении состояния компонуемые функции вызываются снова с новыми данными. Это приводит к перерисовке элементов пользовательского интерфейса — этот процесс называется рекомпозицией .
Рисунок 3. Пользователь взаимодействует с элементом пользовательского интерфейса, что приводит к срабатыванию события. Логика приложения реагирует на событие, после чего компонуемые функции автоматически вызываются снова с новыми параметрами, если это необходимо.
Динамический контент
Поскольку компонуемые функции написаны на Kotlin, а не на XML, они могут быть такими же динамичными, как и любой другой код Kotlin. Например, предположим, что вы хотите создать пользовательский интерфейс, который приветствует список пользователей:
@Composable fun Greeting(names: List<String>) { for (name in names) { Text("Hello $name") } }
Эта функция принимает список имён и генерирует приветствие для каждого пользователя. Компонуемые функции могут быть весьма сложными. Вы можете использовать операторы if
, чтобы определить, нужно ли отображать определённый элемент пользовательского интерфейса. Вы можете использовать циклы. Вы можете вызывать вспомогательные функции. Вам доступна вся гибкость базового языка. Эта мощь и гибкость — одно из ключевых преимуществ Jetpack Compose.
Рекомпозиция
В императивной модели пользовательского интерфейса для изменения виджета необходимо вызвать сеттер виджета, чтобы изменить его внутреннее состояние. В Compose вы снова вызываете компонуемую функцию с новыми данными. Это приводит к перекомпоновке функции — создаваемые функцией виджеты при необходимости перерисовываются с новыми данными. Фреймворк Compose может интеллектуально перекомпоновывать только те компоненты, которые изменились.
Например, рассмотрим эту составную функцию, которая отображает кнопку:
@Composable fun ClickCounter(clicks: Int, onClick: () -> Unit) { Button(onClick = onClick) { Text("I've been clicked $clicks times") } }
При каждом нажатии кнопки вызывающий код обновляет значение clicks
. Compose снова вызывает лямбда-функцию с функцией Text
для отображения нового значения; этот процесс называется рекомпозицией . Другие функции, не зависящие от значения, не рекомпозируются.
Как мы уже обсуждали, перекомпоновка всего дерева пользовательского интерфейса может потребовать значительных вычислительных ресурсов, что приводит к расходу вычислительной мощности и расходу заряда батареи. Compose решает эту проблему с помощью интеллектуальной перекомпоновки .
Рекомпозиция — это процесс повторного вызова компонуемых функций при изменении входных данных. Это происходит при изменении входных данных функции. Когда Compose выполняет рекомпозицию на основе новых входных данных, он вызывает только те функции или лямбда-выражения, которые могли измениться, пропуская остальные. Пропуская все функции или лямбда-выражения, параметры которых не изменились, Compose может эффективно выполнять рекомпозицию.
Никогда не полагайтесь на побочные эффекты от выполнения компонуемых функций, поскольку перекомпоновка функции может быть пропущена. В противном случае пользователи могут столкнуться со странным и непредсказуемым поведением вашего приложения. Побочный эффект — это любое изменение, видимое для остальной части вашего приложения. Например, следующие действия являются опасными побочными эффектами:
- Запись в свойство общего объекта
- Обновление наблюдаемого объекта в
ViewModel
- Обновление общих настроек
Компонуемые функции могут повторно выполняться каждый кадр, например, при рендеринге анимации. Компонуемые функции должны быть быстрыми, чтобы избежать подтормаживаний во время анимации. Если вам нужно выполнить ресурсоёмкие операции, например, чтение из общих настроек, выполняйте их в фоновой сопрограмме и передайте результат компонуемой функции в качестве параметра.
Например, этот код создаёт компонуемый объект для обновления значения в SharedPreferences
. Компонуемый объект не должен самостоятельно выполнять чтение или запись из общих настроек. Вместо этого этот код переносит чтение и запись во ViewModel
в фоновой сопрограмме. Логика приложения передаёт текущее значение с помощью обратного вызова для запуска обновления.
@Composable fun SharedPrefsToggle( text: String, value: Boolean, onValueChanged: (Boolean) -> Unit ) { Row { Text(text) Checkbox(checked = value, onCheckedChange = onValueChanged) } }
В этом документе обсуждается ряд моментов, которые следует учитывать при использовании Compose:
- При рекомпозиции пропускается максимально возможное количество составных функций и лямбда-выражений.
- Пересмотр настроен оптимистично и может быть отменен.
- Компонуемая функция может запускаться довольно часто, так же часто, как каждый кадр анимации.
- Составные функции могут выполняться параллельно.
- Составные функции могут выполняться в любом порядке.
В следующих разделах мы рассмотрим, как создавать компонуемые функции для поддержки рекомпозиции. В любом случае, лучше всего, чтобы компонуемые функции были быстрыми, идемпотентными и не имели побочных эффектов.
Рекомпозиция пропускает как можно больше
Если части вашего пользовательского интерфейса недействительны, Compose делает всё возможное, чтобы перекомпоновать только те части, которые необходимо обновить. Это означает, что он может пропустить повторный запуск компонуемого объекта кнопки, не выполняя ни один из компонуемых объектов выше или ниже него в дереве пользовательского интерфейса.
Каждая компонуемая функция и лямбда-выражение может быть перекомпонована сама по себе. Вот пример, демонстрирующий, как перекомпоновка может пропускать некоторые элементы при рендеринге списка:
/** * 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) })) }
Каждая из этих областей может быть единственным объектом, который будет выполнен во время рекомпозиции. Compose может перейти к лямбда-функции Column
, не выполняя ни один из её родительских элементов при изменении header
. А при выполнении Column
Compose может пропустить элементы LazyColumn
, если names
не изменились.
Опять же, выполнение всех компонуемых функций или лямбда-выражений должно быть без побочных эффектов. Если вам нужно выполнить побочный эффект, вызовите его с помощью обратного вызова.
Рекомпозиция оптимистична
Рекомпозиция запускается всякий раз, когда Compose считает, что параметры компонуемого объекта могли измениться. Рекомпозиция является оптимистичной, то есть Compose ожидает завершения рекомпозиции до того, как параметры снова изменятся. Если параметр изменяется до завершения рекомпозиции, Compose может отменить рекомпозицию и перезапустить её с новым параметром.
При отмене рекомпозиции Compose удаляет дерево пользовательского интерфейса из рекомпозиции. Если у вас есть побочные эффекты, зависящие от отображаемого пользовательского интерфейса, они будут применены даже после отмены композиции. Это может привести к несогласованному состоянию приложения.
Убедитесь, что все компонуемые функции и лямбда-выражения являются идемпотентными и не имеют побочных эффектов для обработки оптимистической рекомпозиции.
Компонуемые функции могут выполняться довольно часто
В некоторых случаях компонуемая функция может выполняться для каждого кадра анимации пользовательского интерфейса. Если функция выполняет ресурсоёмкие операции, например, чтение из памяти устройства, она может вызывать подтормаживания в работе пользовательского интерфейса.
Например, если ваш виджет попытается прочитать настройки устройства, он потенциально может считывать эти настройки сотни раз в секунду, что может иметь катастрофические последствия для производительности вашего приложения.
Если вашей компонуемой функции требуются данные, она должна определить параметры для этих данных. Затем вы можете перенести ресурсоёмкую работу в другой поток за пределами композиции и передать данные в Compose с помощью mutableStateOf
или LiveData
.
Компонуемые функции могут выполняться параллельно
Compose может оптимизировать рекомпозицию, выполняя компонуемые функции параллельно. Это позволит Compose использовать преимущества нескольких ядер и запускать компонуемые функции, не отображаемые на экране, с более низким приоритетом.
Такая оптимизация означает, что компонуемая функция может выполняться в пуле фоновых потоков. Если компонуемая функция вызывает функцию ViewModel
, Compose может вызывать эту функцию из нескольких потоков одновременно.
Чтобы ваше приложение работало корректно, все компонуемые функции не должны иметь побочных эффектов. Вместо этого используйте обратные вызовы, такие как onClick
, которые всегда выполняются в потоке пользовательского интерфейса.
При вызове компонуемой функции вызов может происходить в потоке, отличном от потока вызывающего. Это означает, что следует избегать кода, изменяющего переменные в компонуемой лямбда-функции, как потому, что такой код не является потокобезопасным, так и потому, что это недопустимый побочный эффект компонуемой лямбда-функции.
Вот пример, демонстрирующий составной элемент, который отображает список и его количество:
@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; запрещая такие записи, мы позволяем фреймворку менять потоки для выполнения компонуемых лямбда-выражений.
Компонуемые функции могут выполняться в любом порядке.
Глядя на код компонуемой функции, можно предположить, что код выполняется в том порядке, в котором он указан. Но это не гарантируется. Если компонуемая функция содержит вызовы других компонуемых функций, эти функции могут выполняться в любом порядке. Compose может распознавать, что некоторые элементы пользовательского интерфейса имеют более высокий приоритет, чем другие, и отрисовывать их в первую очередь.
Например, предположим, что у вас есть такой код для отрисовки трех экранов в макете вкладок:
@Composable fun ButtonRow() { MyFancyNavigation { StartScreen() MiddleScreen() EndScreen() } }
Вызовы StartScreen
, MiddleScreen
и EndScreen
могут происходить в любом порядке. Это означает, что нельзя, например, заставить StartScreen()
установить какую-либо глобальную переменную (побочный эффект) и позволить MiddleScreen()
воспользоваться этим изменением. Вместо этого каждая из этих функций должна быть самодостаточной.
Узнать больше
Чтобы узнать больше о том, как мыслить в Compose и компонуемых функциях, ознакомьтесь со следующими дополнительными ресурсами.
Видео
{% дословно %}Рекомендовано для вас
- Примечание: текст ссылки отображается, когда JavaScript отключен.
- Kotlin для Jetpack Compose
- State и Jetpack Compose
- Архитектурное наложение Jetpack Compose