Всем привет! Недавно я начала изучать Jetpack Compose. Всё, что я изучаю по иностранным гайдам, я обычно перевожу, чтобы при повторном прочтении, мозг снова не тратил время на перевод. Мне кажется, этот фреймворк становится всё более популярен, поэтому хочу поделиться своим переводом Thinking in Compose с другими начинающими :)

Jetpack Compose — это современный декларативный UI Toolkit для Android, упрощающий написание и поддержку UI (пользовательского интерфейса) вашего приложения, и в этом гайде рассказывается за счёт чего это достигается.

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

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

Манипулирование вьюшками вручную увеличивает вероятность ошибок. Если часть данных отображается в нескольких местах, то легко забыть обновить одну из вью, в которой данные отображаются. Также легко создать illegal-состояния, когда два обновления вступают в неожиданный конфликт. Например, обновление может попытаться установить значение узла, который только что был удален из UI. В общем, сложность сопровождения программного обеспечения растет с увеличением числа вьюшек, требующих обновления.

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

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

Простая composable-функция

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

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

Несколько примечательных особенностей этой функции:

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

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

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

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

  • Эта функция быстрая, идемпотентная и не имеет side-эффектов (побочных эффектов). 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  • Изменение значения свойства общего объекта;

  • Обновление observable во ViewModel;

  • Обновление SharedPreferences

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

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

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

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

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

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

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

  • Рекомпозиция оптимистична и может быть отменена.

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

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

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

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

Например, у вас для отрисовки трех экранов в лайауте вкладок (tab layout) такой код:

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

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

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

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

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

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

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

Приведем пример composable-функции, которая выводит список и количество его элементов:

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

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

@Composable
@Deprecated("Example with bug")
fun ListWithBug(myList: List<String>) {
    var items = 0

    Row(horizontalArrangement = Arrangement.SpaceBetween) {
        Column {
            for (item in myList) {
                Text("Item: $item")
                items++ // Избегайте этого! Это side-эффект при рекомпозиции Column
            }
        }
        Text("Count: $items")
    }
}

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

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

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

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

/**
 * Вывод списка кликабельных имен с заголовком
 */
@Composable
fun NamePicker(
    header: String,
    names: List<String>,
    onNameClicked: (String) -> Unit
) {
    Column {
        // эта часть рекомпозируется, если изменится [header],
        // но не тогда, когда изменится [names]
        Text(header, style = MaterialTheme.typography.bodyLarge)
        Divider()

        // LazyColumn - это Compose версия RecyclerView.
        // Лямбда, передаваемая в items(), аналогична RecyclerView.ViewHolder.
        LazyColumn {
            items(names) { name ->
                // Когда обновляется [name] элемента, рекомпозируется адаптер этого
                // элемента. Но при изменении [header], эта часть не рекомпозируется
                NamePickerItem(name, onNameClicked)
            }
        }
    }
}

/** * Вывод отдельного имени, по которому пользователь может кликнуть
 */
@Composable
private fun NamePickerItem(name: String, onClicked: (String) -> Unit) {
    Text(name, Modifier.clickable(onClick = { onClicked(name) }))
}

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

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

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

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

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

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

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

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

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

Исходный код примеров

Комментарии (4)


  1. Beholder
    18.10.2023 08:54

    Можно где-нибудь прочитать про то, как это устроено "под капотом"? Насколько понимаю, функции с аннотацией @Composable компилируются как-то по-другому, подобно suspend-функциям.

    Можно ли приспособить эту технику для других UI библиотек, например Swing?


    1. Ellmi Автор
      18.10.2023 08:54

      Подробнее о том, как "под капотом", мне советовали почитать книгу Jetpack Compose Internals, но до неё я пока не дошла :)

      Точно не могу сказать, может в сторону Compose Multiplatform посмотреть


    1. B1ays
      18.10.2023 08:54
      +1

      Немало полезной информации о внутренней работе, можно найти в этой статье: Осознанная оптимизация Compose


    1. Rusrst
      18.10.2023 08:54

      Если целый компилятор напишете, то почему нет, можете. Для compose же пишут)