Мыслить в композиции

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

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

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

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

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

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

Пример составной функции

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

На экране телефона отображается текст "Hello World" и код составной функции, которая генерирует этот пользовательский интерфейс.
Рисунок 1. Составная функция, которой передаются данные, и которая использует их для отображения текстового виджета на экране.

Несколько важных моментов, касающихся этой функции:

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

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

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

Декларативный сдвиг парадигмы

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

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

Иллюстрация потока данных в пользовательском интерфейсе Compose, от высокоуровневых объектов до их дочерних элементов.
Рисунок 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 . Составной объект не должен сам считывать или записывать данные из SharedPreferences. Вместо этого, этот код переносит операции чтения и записи в ViewModel в фоновой сопрограмме. Логика приложения передает текущее значение с помощью функции обратного вызова для запуска обновления.

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

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

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

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

При рекомпозиции максимально возможное количество пропусков

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

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

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

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

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

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

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

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

Видео

{% verbatim %} {% endverbatim %} {% verbatim %} {% endverbatim %}