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

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

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

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

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

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

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

Простая компонуемая функция

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

Скриншот телефона, на котором виден текст

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

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

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

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

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

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

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

Например, предположим, что у вас есть такой код для рисования трех экранов в макете вкладок:

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

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

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

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
@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++ // Avoid! Side-effect of the column recomposing.
            }
        }
        Text("Count: $items")
    }
}

В этом примере items изменяются при каждой рекомпозиции. Это может быть каждый кадр анимации или обновление списка. В любом случае пользовательский интерфейс будет отображать неверный счетчик. По этой причине подобные записи не поддерживаются в 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)
        Divider()

        // 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 и компонуемых функциях, посетите следующие дополнительные ресурсы.

Видео

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