Архитектура интерфейса Compose

В Compose пользовательский интерфейс неизменяем – его нельзя обновить после отрисовки. Вы можете контролировать состояние пользовательского интерфейса. Каждый раз, когда состояние пользовательского интерфейса меняется, Compose воссоздает его части, которые изменились. Composable-функции могут принимать состояние и предоставлять события. Например, TextField принимает значение и предоставляет обратный вызов onValueChange, который запрашивает у обработчика обратных вызовов изменение значения.

var name by remember { mutableStateOf("") }
OutlinedTextField(
    value = name,
    onValueChange = { name = it },
    label = { Text("Name") }
)

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

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

Однонаправленный поток данных (UDF) – это шаблон проектирования, в котором состояние передается вниз, а события – вверх. Следуя однонаправленному потоку данных, вы можете отделить composable-функции, которые отображают состояние в пользовательском интерфейсе, от частей приложения, которые хранят и изменяют состояние.

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

  1. Событие. Часть пользовательского интерфейса генерирует событие и передает его вверх, например нажатие кнопки передается в ViewModel для обработки. Или событие передается из других слоев вашего приложения, например, указывая на то, что сеанс пользователя истек.
  2. Обновление состояния. Обработчик событий может изменить состояние.
  3. Отображение состояния. Хранилище состояния передает состояние, а пользовательский интерфейс отображает его.
События передаются из пользовательского интерфейса в хранилище состояния, а состояние – из хранилища в пользовательский интерфейс.
Рисунок 1. Однонаправленный поток данных.

Следование этому шаблону при использовании Jetpack Compose дает несколько преимуществ:

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

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

Работа composable-функций основана на состояниях и событиях. Например, TextField обновляется только при обновлении параметра value и предоставляет обратный вызов onValueChange – событие, которое запрашивает изменение значения на новое. Compose определяет объект State как держатель значения, и изменения значения состояния запускают рекомпозицию. Вы можете хранить состояние в remember { mutableStateOf(value) } или rememberSaveable { mutableStateOf(value) } в зависимости от того, как долго вам нужно помнить значение.

Тип значения composable-функции TextField – String, поэтому оно может быть получено откуда угодно: из заданного в коде значения, из ViewModel или из родительской composable-функции. Необязательно хранить его в объекте State, но нужно обновлять значение при вызове onValueChange.

  • mutableStateOf(value) создает MutableState – наблюдаемый тип в Compose. Любые изменения его значения планируют рекомпозицию любых composable-функций, которые считывают это значение.
  • remember сохраняет объекты в композиции и забывает объект, когда composable-функция, которая вызвала remember, удаляется из композиции.
  • rememberSaveable сохраняет состояние при изменении конфигурации, записывая его в Bundle.

Определите параметры composable-функции

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

  • Насколько composable-функция гибкая и подходит для повторного использования?
  • Как параметры состояния влияют на производительность этой composable-функции?

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

@Composable
fun Header(title: String, subtitle: String) {
    // Recomposes when title or subtitle have changed.
}

@Composable
fun Header(news: News) {
    // Recomposes when a new instance of News is passed in.
}

Иногда использование отдельных параметров также повышает производительность. Например, если News содержит не только title и subtitle, то при каждой передаче нового экземпляра News в Header(news) composable-функция будет перекомпонована, даже если title и subtitle не изменились.

Тщательно продумайте, сколько параметров вы передаете. Функция со слишком большим количеством параметров менее удобна, поэтому в этом случае предпочтительно сгруппировать их в класс.

События в Compose

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

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

Предпочитайте передавать неизменяемые значения для лямбда-выражений состояния и обработчика событий. У этого подхода есть следующие преимущества:

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

Например, composable-функцию, которая принимает в качестве параметров String и лямбда-выражение, можно вызывать из разных контекстов и использовать многократно. Предположим, что верхняя панель приложения в вашем приложении всегда отображает текст и имеет кнопку «Назад». Вы можете определить более общую composable-функцию MyAppTopAppBar, которая получает текст и обработчик кнопки "Назад" в качестве параметров:

@Composable
fun MyAppTopAppBar(topAppBarText: String, onBackPressed: () -> Unit) {
    TopAppBar(
        title = {
            Text(
                text = topAppBarText,
                textAlign = TextAlign.Center,
                modifier = Modifier
                    .fillMaxSize()
                    .wrapContentSize(Alignment.Center)
            )
        },
        navigationIcon = {
            IconButton(onClick = onBackPressed) {
                Icon(
                    Icons.AutoMirrored.Filled.ArrowBack,
                    contentDescription = localizedString
                )
            }
        },
        // ...
    )
}

ViewModel, состояния и события: пример

Используя ViewModel и mutableStateOf, вы также можете внедрить в приложение однонаправленный поток данных, если выполняется одно из следующих условий:

  • Состояние пользовательского интерфейса отображается с помощью наблюдаемых хранилищ состояния, таких как StateFlow или LiveData.
  • ViewModel обрабатывает события, поступающие из пользовательского интерфейса или других уровней приложения, и обновляет хранилище состояния на основе этих событий.

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

У экрана есть четыре состояния:

  • Выход из аккаунта. Пользователь ещё не вошел в аккаунт.
  • In progress (Выполняется). Приложение пытается выполнить вход пользователя, отправляя сетевой вызов.
  • Ошибка – при входе произошла ошибка.
  • Выполнен вход. Пользователь вошел в аккаунт.

Вы можете смоделировать эти состояния как изолированный класс. ViewModel предоставляет состояние как State, задает начальное состояние и обновляет его по мере необходимости. Класс ViewModel также обрабатывает событие входа, предоставляя метод onSignIn().

class MyViewModel : ViewModel() {
    private val _uiState = mutableStateOf<UiState>(UiState.SignedOut)
    val uiState: State<UiState>
        get() = _uiState

    // ...
}

Помимо API mutableStateOf, Compose предоставляет расширения для LiveData, Flow и Observable, чтобы регистрировать их в качестве слушателей и представлять значение как состояние.

class MyViewModel : ViewModel() {
    private val _uiState = MutableLiveData<UiState>(UiState.SignedOut)
    val uiState: LiveData<UiState>
        get() = _uiState

    // ...
}

@Composable
fun MyComposable(viewModel: MyViewModel) {
    val uiState = viewModel.uiState.observeAsState()
    // ...
}

Подробнее…

Дополнительную информацию об архитектуре в Jetpack Compose можно найти в следующих ресурсах:

Образцы