Состояние и Jetpack Compose

Состояние приложения – это любое значение, которое может меняться со временем. Это очень широкое определение, которое охватывает все, от базы данных Room до переменной в классе.

Все приложения для Android показывают пользователю свой статус. Вот несколько примеров состояния в приложениях Android:

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

Jetpack Compose помогает вам явно указывать, где и как вы храните и используете состояние в приложении Android. В этом руководстве рассказывается о связи между состоянием и композициями, а также об API, которые Jetpack Compose предлагает для работы с состоянием.

Состояние и состав

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

@Composable
private fun HelloContent() {
    Column(modifier = Modifier.padding(16.dp)) {
        Text(
            text = "Hello!",
            modifier = Modifier.padding(bottom = 8.dp),
            style = MaterialTheme.typography.bodyMedium
        )
        OutlinedTextField(
            value = "",
            onValueChange = { },
            label = { Text("Name") }
        )
    }
}

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

Первоначальная композиция. Создание композиции при первом запуске компонуемых функций.

Повторная композиция – повторный запуск функций, создающих композицию, для ее обновления при изменении данных.

Чтобы узнать больше о начальной и повторной композиции, ознакомьтесь со статьей Thinking in Compose.

Состояние в composable-функциях

Компонуемые функции могут использовать API remember для хранения объекта в памяти. Значение, вычисленное с помощью remember, сохраняется в Composition во время первоначальной композиции, а сохраненное значение возвращается во время повторной композиции. remember можно использовать для хранения как изменяемых, так и неизменяемых объектов.

mutableStateOf создает наблюдаемый объект MutableState<T>, который представляет собой тип, интегрированный со средой выполнения Compose.

interface MutableState<T> : State<T> {
    override var value: T
}

Любые изменения в value приводят к перекомпоновке расписания любых композитных функций, которые считывают value.

Объект MutableState в composable-функции можно объявить тремя способами:

  • val mutableState = remember { mutableStateOf(default) }
  • var value by remember { mutableStateOf(default) }
  • val (value, setValue) = remember { mutableStateOf(default) }

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

Для синтаксиса делегирования by требуются следующие импорты:

import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue

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

@Composable
fun HelloContent() {
    Column(modifier = Modifier.padding(16.dp)) {
        var name by remember { mutableStateOf("") }
        if (name.isNotEmpty()) {
            Text(
                text = "Hello, $name!",
                modifier = Modifier.padding(bottom = 8.dp),
                style = MaterialTheme.typography.bodyMedium
            )
        }
        OutlinedTextField(
            value = name,
            onValueChange = { name = it },
            label = { Text("Name") }
        )
    }
}

Хотя remember помогает сохранить состояние при повторной композиции, оно не сохраняется при изменении конфигурации. Для этого необходимо использовать синтаксис rememberSaveable. rememberSaveable автоматически сохраняет любое значение, которое можно сохранить в Bundle. Для других значений можно передать специальный объект заставки.

Другие поддерживаемые типы статусов

Compose не требует использовать MutableState<T> для хранения состояния. Он поддерживает другие наблюдаемые типы. Прежде чем использовать в Compose другой наблюдаемый тип, его нужно преобразовать в State<T>, чтобы при изменении состояния композиции автоматически перерисовывались.

Compose содержит функции для создания State<T> из распространенных наблюдаемых типов, используемых в приложениях Android. Прежде чем использовать эти интеграции, добавьте нужные артефакты, выполнив следующие действия:

  • Flow: collectAsStateWithLifecycle()

    collectAsStateWithLifecycle() собирает значения из Flow с учетом жизненного цикла, что позволяет приложению экономить ресурсы. Оно представляет собой последнее значение, полученное от функции Compose State. Этот API рекомендуется использовать для сбора данных о потоках в приложениях Android.

    В файле build.gradle должна быть указана следующая зависимость (версия 2.6.0-beta01 или более новая):

Kotlin

dependencies {
      ...
      implementation("androidx.lifecycle:lifecycle-runtime-compose:2.11.0")
}

Яркий

dependencies {
      ...
      implementation "androidx.lifecycle:lifecycle-runtime-compose:2.11.0"
}
  • Flow: collectAsState()

    collectAsState похож на collectAsStateWithLifecycle, поскольку также собирает значения из Flow и преобразует их в Compose State.

    Используйте collectAsState для кода, который можно использовать на разных платформах, а не collectAsStateWithLifecycle, который предназначен только для Android.

    Для collectAsState не требуются дополнительные зависимости, поскольку он доступен в compose-runtime.

  • LiveData: observeAsState()

    observeAsState() начинает отслеживать LiveData и представляет его значения с помощью State.

    В файле build.gradle должна быть указана следующая зависимость:

Kotlin

dependencies {
      ...
      implementation("androidx.compose.runtime:runtime-livedata:1.12.1")
}

Яркий

dependencies {
      ...
      implementation "androidx.compose.runtime:runtime-livedata:1.12.1"
}

Kotlin

dependencies {
      ...
      implementation("androidx.compose.runtime:runtime-rxjava2:1.12.1")
}

Яркий

dependencies {
      ...
      implementation "androidx.compose.runtime:runtime-rxjava2:1.12.1"
}

Kotlin

dependencies {
      ...
      implementation("androidx.compose.runtime:runtime-rxjava3:1.12.1")
}

Яркий

dependencies {
      ...
      implementation "androidx.compose.runtime:runtime-rxjava3:1.12.1"
}

С отслеживанием состояния и без него

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

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

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

Передача состояния

Подъем состояния в Compose – это перенос состояния в вызывающий компонент, чтобы сделать компонент без состояния. Общий принцип поднятия состояния в Jetpack Compose заключается в том, чтобы заменить переменную состояния двумя параметрами:

  • value: T – текущее значение для показа.
  • onValueChange: (T) -> Unit – событие, которое запрашивает изменение значения, где T – новое значение.

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

Состояние, поднятое таким образом, имеет следующие важные свойства:

  • Единый источник достоверных данных. Перемещая состояние вместо его дублирования, мы обеспечиваем наличие только одного источника достоверных данных. Это поможет избежать ошибок.
  • Инкапсулированность. Изменять состояние могут только композиции с состоянием. Это полностью внутренний процесс.
  • Возможность совместного использования. Поднятое состояние можно использовать в нескольких композируемых функциях. Если вы хотите прочитать name в другом компоненте, то сможете сделать это с помощью подъема.
  • Перехватываемые. Вызывающие функции без состояния могут игнорировать или изменять события до изменения состояния.
  • Отдельное состояние. Состояние для composable-функций без отслеживания состояния может храниться где угодно. Например, теперь можно переместить name в ViewModel.

В примере вы извлекаете name и onValueChange из HelloContent и перемещаете их вверх по дереву в composable-функцию HelloScreen, которая вызывает HelloContent.

@Composable
fun HelloScreen() {
    var name by rememberSaveable { mutableStateOf("") }

    HelloContent(name = name, onNameChange = { name = it })
}

@Composable
fun HelloContent(name: String, onNameChange: (String) -> Unit) {
    Column(modifier = Modifier.padding(16.dp)) {
        Text(
            text = "Hello, $name",
            modifier = Modifier.padding(bottom = 8.dp),
            style = MaterialTheme.typography.bodyMedium
        )
        OutlinedTextField(value = name, onValueChange = onNameChange, label = { Text("Name") })
    }
}

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

Однонаправленный поток данных

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

  1. Состояние должно быть поднято как минимум до наименьшего общего родительского элемента всех композиций, которые используют состояние (читают его).
  2. Состояние должно быть поднято как минимум до самого высокого уровня, на котором его можно изменить (записать).
  3. Если два состояния изменяются в ответ на одни и те же события, их следует поднимать вместе.

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

Подробнее о том, куда переносить состояние…

Восстановление состояния в Compose

rememberSaveable API работает так же, как remember, поскольку сохраняет состояние при повторной композиции, а также при воссоздании активности или процесса с помощью сохраненного состояния экземпляра. Например, это происходит при повороте экрана.

Способы хранения состояния

Все типы данных, добавленные в Bundle, сохраняются автоматически. Если вы хотите сохранить что-то, что нельзя добавить в Bundle, есть несколько вариантов.

Parcelize

Самое простое решение – добавить к объекту аннотацию @Parcelize. Объект становится пакетируемым и может быть включен в пакет. Например, этот код создает тип данных City, который можно передавать между компонентами, и сохраняет его в состоянии.

@Parcelize
data class City(val name: String, val country: String) : Parcelable

@Composable
fun CityScreen() {
    var selectedCity = rememberSaveable {
        mutableStateOf(City("Madrid", "Spain"))
    }
}

MapSaver

Если по какой-либо причине @Parcelize не подходит, вы можете использовать mapSaver, чтобы определить собственное правило преобразования объекта в набор значений, которые система может сохранить в Bundle.

data class City(val name: String, val country: String)

val CitySaver = run {
    val nameKey = "Name"
    val countryKey = "Country"
    mapSaver(
        save = { mapOf(nameKey to it.name, countryKey to it.country) },
        restore = { City(it[nameKey] as String, it[countryKey] as String) }
    )
}

@Composable
fun CityScreen() {
    var selectedCity = rememberSaveable(stateSaver = CitySaver) {
        mutableStateOf(City("Madrid", "Spain"))
    }
}

ListSaver

Чтобы не определять ключи для карты, можно также использовать listSaver и его индексы в качестве ключей:

data class City(val name: String, val country: String)

val CitySaver = listSaver<City, Any>(
    save = { listOf(it.name, it.country) },
    restore = { City(it[0] as String, it[1] as String) }
)

@Composable
fun CityScreen() {
    var selectedCity = rememberSaveable(stateSaver = CitySaver) {
        mutableStateOf(City("Madrid", "Spain"))
    }
}

Хранилища состояния в Compose

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

Обратите внимание, что в других материалах держатели состояния также называются поднятыми объектами состояния.

Подробнее о подъеме состояния в Compose и хранителях состояния и состоянии интерфейса рассказывается в руководстве по архитектуре.

Повторно запускать расчеты запоминания при изменении ключей

API remember часто используется вместе с MutableState:

var name by remember { mutableStateOf("") }

В этом случае использование функции remember позволяет сохранить значение MutableState при повторном создании.

В общем случае функция remember принимает параметр лямбда-функции calculation. При первом запуске функции remember вызывается лямбда-функция calculation и сохраняется ее результат. Во время повторной композиции remember возвращает последнее сохраненное значение.

Помимо кэширования состояния, remember можно использовать для хранения любого объекта или результата операции в композиции, инициализация или вычисление которого требует больших затрат. Вам может не понадобиться повторять это вычисление при каждой рекомпозиции. Примером может служить создание объекта ShaderBrush, которое требует значительных ресурсов:

val brush = remember {
    ShaderBrush(
        BitmapShader(
            ImageBitmap.imageResource(res, avatarRes).asAndroidBitmap(),
            Shader.TileMode.REPEAT,
            Shader.TileMode.REPEAT
        )
    )
}

remember сохраняет значение, пока оно не будет удалено из композиции. Однако есть способ сделать кешированное значение недействительным. API remember также принимает параметр key или keys. Если какой-либо из этих ключей изменится, при следующем повторном создании функцииremember кеш будет признан недействительным и блок лямбда-выражения будет выполнен снова. Этот механизм позволяет контролировать время существования объекта в композиции. Расчет остается действительным, пока не изменятся входные данные, а не пока запомненное значение не будет удалено из композиции.

Ниже приведены примеры того, как работает этот механизм.

В этом фрагменте кода создается объект ShaderBrush, который используется в качестве фона для composable-функции Box. remember сохраняет экземпляр ShaderBrush, поскольку его воссоздание требует больших затрат, как уже говорилось ранее. remember принимает avatarRes в качестве параметра key1, который представляет собой выбранное фоновое изображение. Если изменить avatarRes, кисть будет перекомпонована с новым изображением и снова применена к Box. Это может произойти, когда пользователь выбирает другое изображение в качестве фона.

@Composable
private fun BackgroundBanner(
    @DrawableRes avatarRes: Int,
    modifier: Modifier = Modifier,
    res: Resources = LocalContext.current.resources
) {
    val brush = remember(key1 = avatarRes) {
        ShaderBrush(
            BitmapShader(
                ImageBitmap.imageResource(res, avatarRes).asAndroidBitmap(),
                Shader.TileMode.REPEAT,
                Shader.TileMode.REPEAT
            )
        )
    }

    Box(
        modifier = modifier.background(brush)
    ) {
        /* ... */
    }
}

В следующем фрагменте кода состояние перенесено в обычный класс для хранения состояния MyAppState. Он предоставляет функцию rememberMyAppState для инициализации экземпляра класса с помощью remember. Предоставление таких функций для создания экземпляра, который сохраняется после перекомпоновки, – распространенный подход в Compose. Функция rememberMyAppState получает значение windowSizeClass, которое используется в качестве параметра key для функции remember. Если этот параметр изменится, приложению нужно будет воссоздать класс держателя простого состояния с последним значением. Это может произойти, например, если пользователь повернет устройство.

@Composable
private fun rememberMyAppState(
    windowSizeClass: WindowSizeClass
): MyAppState {
    return remember(windowSizeClass) {
        MyAppState(windowSizeClass)
    }
}

@Stable
class MyAppState(
    private val windowSizeClass: WindowSizeClass
) { /* ... */ }

Compose использует реализацию метода equals класса, чтобы определить, изменился ли ключ, и аннулировать сохраненное значение.

Как хранить состояние с помощью ключей после повторной композиции

API rememberSaveable – это оболочка для remember, которая может хранить данные в Bundle. Этот API позволяет сохранять состояние не только при повторной композиции, но и при воссоздании активности и завершении процесса, инициированном системой. rememberSaveable получает параметры input для той же цели, что и remember получает keys. Кеш становится недействительным, если изменяется хотя бы один из входных аргументов. При следующем повторном создании функции rememberSaveable снова выполнит лямбда-блок вычислений.

В приведенном ниже примере rememberSaveable хранит userTypedQuery, пока не изменится typedQuery:

var userTypedQuery by rememberSaveable(typedQuery, stateSaver = TextFieldValue.Saver) {
    mutableStateOf(
        TextFieldValue(text = typedQuery, selection = TextRange(typedQuery.length))
    )
}

Подробнее…

Чтобы узнать больше о состоянии и Jetpack Compose, ознакомьтесь с дополнительными ресурсами.

Образцы

Практические работы

Видео

Блоги