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

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

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

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

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

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

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

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

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

Скриншот телефона, на котором отображается текст «Hello World», и код для простой функции Composable, которая генерирует этот пользовательский интерфейс

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

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

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

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

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

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

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

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

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

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

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

В декларативном подходе Compose виджеты относительно не имеют состояния и не раскрывают функции setter или getter. Фактически, виджеты не раскрываются как объекты. Вы обновляете пользовательский интерфейс, вызывая одну и ту же составную функцию с разными аргументами. Это упрощает предоставление состояния архитектурным шаблонам, таким как 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 для отображения нового значения; этот процесс называется recomposition . Другие функции, которые не зависят от значения, не перекомпоновываются.

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

Видео

{% дословно %} {% endverbatim %} {% дословно %} {% endverbatim %}