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

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

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

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

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

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

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

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

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

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

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

Обратите внимание на следующее:

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

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

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

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

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

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

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

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

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

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

Поскольку composable-функции написаны на 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 решает эту проблему с помощью интеллектуальной рекомпозиции.

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

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

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

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

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

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

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

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

В следующих разделах мы расскажем, как создавать 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) }))
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Видео