Владельцы состояний и состояние пользовательского интерфейса

В руководстве по уровню пользовательского интерфейса рассматривается однонаправленный поток данных (UDF) как средство создания и управления состоянием пользовательского интерфейса для уровня пользовательского интерфейса.

Данные передаются однонаправленно от уровня данных к пользовательскому интерфейсу.
Рисунок 1. Однонаправленный поток данных.

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

К концу этого документа вы должны понимать, как управлять состоянием приложения на уровне пользовательского интерфейса (UI), то есть на конвейере создания состояний пользовательского интерфейса. Вы должны понимать и знать следующее:

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

Элементы конвейера состояния пользовательского интерфейса

Состояние пользовательского интерфейса и логика, которая его создает, определяют уровень пользовательского интерфейса.

Состояние пользовательского интерфейса

Состояние пользовательского интерфейса (UI) — это свойство, описывающее пользовательский интерфейс. Существует два типа состояний пользовательского интерфейса:

  • Состояние пользовательского интерфейса экрана — это то, что необходимо отобразить на экране. Например, класс NewsUiState может содержать новостные статьи и другую информацию, необходимую для отображения пользовательского интерфейса. Это состояние обычно связано с другими уровнями иерархии, поскольку содержит данные приложения.
  • Состояние элемента пользовательского интерфейса относится к свойствам, присущим элементам пользовательского интерфейса и влияющим на их отображение. Элемент пользовательского интерфейса может быть отображен или скрыт, а также может иметь определённый шрифт, размер или цвет шрифта. В представлениях Android представление само управляет этим состоянием, поскольку оно по своей природе обладает состоянием, предоставляя методы для изменения или запроса своего состояния. Примером этого являются методы get и set класса TextView для его текста. В Jetpack Compose состояние является внешним по отношению к компонуемому объекту, и вы даже можете перенести его из непосредственной близости к компонуемому объекту в вызывающую функцию компонуемого объекта или в держатель состояния. Примером этого является ScaffoldState для компонуемого объекта Scaffold .

Логика

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

Логика создает состояние пользовательского интерфейса
Рисунок 2. Логика как производитель состояния пользовательского интерфейса.

Логика в приложении может быть либо бизнес-логикой, либо логикой пользовательского интерфейса:

  • Бизнес-логика — это реализация требований продукта к данным приложения. Например, добавление статьи в закладки в приложении для чтения новостей при нажатии кнопки. Логика сохранения закладки в файл или базу данных обычно размещается на уровнях домена или данных. Владелец состояния обычно делегирует эту логику этим уровням, вызывая предоставляемые ими методы.
  • Логика пользовательского интерфейса связана с тем, как отображать состояние пользовательского интерфейса на экране. Например, получение правильной подсказки в строке поиска при выборе категории, прокрутка к определённому элементу списка или логика навигации к определённому экрану при нажатии пользователем кнопки.

Жизненный цикл Android и типы состояний и логики пользовательского интерфейса

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

  • Не зависит от жизненного цикла пользовательского интерфейса : эта часть уровня пользовательского интерфейса связана со слоями, генерирующими данные приложения (слоями данных или предметной области), и определяется бизнес-логикой. Жизненный цикл, изменения конфигурации и воссоздание Activity в пользовательском интерфейсе могут влиять на активность конвейера создания состояний пользовательского интерфейса, но не влияют на достоверность создаваемых данных.
  • Зависит от жизненного цикла пользовательского интерфейса : эта часть уровня пользовательского интерфейса отвечает за логику пользовательского интерфейса и напрямую зависит от изменений жизненного цикла или конфигурации. Эти изменения напрямую влияют на корректность источников данных, считываемых внутри него, и, как следствие, его состояние может меняться только во время активности жизненного цикла. Примерами этого являются разрешения времени выполнения и получение ресурсов, зависящих от конфигурации, таких как локализованные строки.

Вышесказанное можно суммировать с помощью таблицы ниже:

Независимый от жизненного цикла пользовательского интерфейса Зависит от жизненного цикла пользовательского интерфейса
Бизнес-логика Логика пользовательского интерфейса
Состояние экрана пользовательского интерфейса

Конвейер производства состояний пользовательского интерфейса

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

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

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

    @Composable
    fun Counter() {
        // The UI state is managed by the UI itself
        var count by remember { mutableStateOf(0) }
        Row {
            Button(onClick = { ++count }) {
                Text(text = "Increment")
            }
            Button(onClick = { --count }) {
                Text(text = "Decrement")
            }
        }
    }
    
  • Логика пользовательского интерфейса → Пользовательский интерфейс. Например, отображение или скрытие кнопки, позволяющей пользователю перейти к началу списка.

    @Composable
    fun ContactsList(contacts: List<Contact>) {
        val listState = rememberLazyListState()
        val isAtTopOfList by remember {
            derivedStateOf {
                listState.firstVisibleItemIndex < 3
            }
        }
    
        // Create the LazyColumn with the lazyListState
        ...
    
        // Show or hide the button (UI logic) based on the list scroll position
        AnimatedVisibility(visible = !isAtTopOfList) {
            ScrollToTopButton()
        }
    }
    
  • Бизнес-логика → Пользовательский интерфейс. Элемент пользовательского интерфейса, отображающий фотографию текущего пользователя на экране.

    @Composable
    fun UserProfileScreen(viewModel: UserProfileViewModel = hiltViewModel()) {
        // Read screen UI state from the business logic state holder
        val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    
        // Call on the UserAvatar Composable to display the photo
        UserAvatar(picture = uiState.profilePicture)
    }
    
  • Бизнес-логика → Логика пользовательского интерфейса → пользовательский интерфейс. Элемент пользовательского интерфейса, который прокручивается для отображения на экране нужной информации для заданного состояния пользовательского интерфейса.

    @Composable
    fun ContactsList(viewModel: ContactsViewModel = hiltViewModel()) {
        // Read screen UI state from the business logic state holder
        val uiState by viewModel.uiState.collectAsStateWithLifecycle()
        val contacts = uiState.contacts
        val deepLinkedContact = uiState.deepLinkedContact
    
        val listState = rememberLazyListState()
    
        // Create the LazyColumn with the lazyListState
        ...
    
        // Perform UI logic that depends on information from business logic
        if (deepLinkedContact != null && contacts.isNotEmpty()) {
            LaunchedEffect(listState, deepLinkedContact, contacts) {
                val deepLinkedContactIndex = contacts.indexOf(deepLinkedContact)
                if (deepLinkedContactIndex >= 0) {
                  // Scroll to deep linked item
                  listState.animateScrollToItem(deepLinkedContactIndex)
                }
            }
        }
    }
    

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

Данные передаются из слоя создания данных в пользовательский интерфейс
Рисунок 3. Применение логики на уровне пользовательского интерфейса.

Государственные держатели и их обязанности

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

Это дает следующие преимущества:

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

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

Типы держателей государственных акций

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

  • Держатель состояния бизнес-логики.
  • Держатель состояния логики пользовательского интерфейса.

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

Бизнес-логика и ее держатель состояния

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

Свойство Деталь
Производит состояние пользовательского интерфейса Владельцы состояний бизнес-логики отвечают за формирование состояния пользовательского интерфейса. Это состояние часто является результатом обработки пользовательских событий и чтения данных из домена и слоёв данных.
Сохраняется посредством активного отдыха Владельцы состояний бизнес-логики сохраняют своё состояние и конвейеры обработки состояний при воссоздании Activity , обеспечивая бесперебойный пользовательский опыт. В случаях, когда владелец состояния не может быть сохранён и создаётся заново (обычно после завершения процесса ), владелец состояния должен иметь возможность легко воссоздать своё последнее состояние, чтобы обеспечить согласованный пользовательский опыт.
Обладают долгоживущим состоянием Держатели состояний бизнес-логики часто используются для управления состоянием пунктов назначения навигации. В результате они часто сохраняют своё состояние при изменении навигации до тех пор, пока не будут удалены из навигационного графа.
Уникален в своем пользовательском интерфейсе и не подлежит повторному использованию. Держатели состояний бизнес-логики обычно создают состояние для определённой функции приложения, например, TaskEditViewModel или TaskListViewModel , и, следовательно, применимы только к этой функции приложения. Один и тот же держатель состояний может поддерживать эти функции приложения в различных форм-факторах. Например, версии приложения для мобильных устройств, телевизоров и планшетов могут повторно использовать один и тот же держатель состояний бизнес-логики.

Например, рассмотрим пункт назначения навигации автора в приложении «Теперь на Android »:

Приложение Now в Android демонстрирует, как навигационная цель, представляющая основную функцию приложения, должна иметь собственный уникальный держатель состояния бизнес-логики.
Рисунок 4. Приложение Now в Android.

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

@HiltViewModel
class AuthorViewModel @Inject constructor(
    savedStateHandle: SavedStateHandle,
    private val authorsRepository: AuthorsRepository,
    newsRepository: NewsRepository
) : ViewModel() {

    val uiState: StateFlow<AuthorScreenUiState> = 

    // Business logic
    fun followAuthor(followed: Boolean) {
      
    }
}

Обратите внимание, что AuthorViewModel имеет атрибуты, описанные ранее:

Свойство Деталь
Создает AuthorScreenUiState AuthorViewModel считывает данные из AuthorsRepository и NewsRepository и использует их для создания AuthorScreenUiState . Он также применяет бизнес-логику, когда пользователь хочет подписаться на Author или отписаться от него, делегируя полномочия AuthorsRepository .
Имеет доступ к уровню данных Экземпляры AuthorsRepository и NewsRepository передаются ему в конструкторе, что позволяет реализовать бизнес-логику подписки на Author .
Выживает Activity отдых Поскольку он реализован с помощью ViewModel , он будет сохранен при быстром восстановлении Activity . В случае завершения процесса объект SavedStateHandle можно прочитать, чтобы получить минимальный объем информации, необходимый для восстановления состояния пользовательского интерфейса из слоя данных.
Обладает долгоживущим состоянием Областью действия ViewModel является граф навигации, поэтому, если пункт назначения автора не будет удалён из графа навигации, состояние пользовательского интерфейса в uiState StateFlow остаётся в памяти. Использование StateFlow также добавляет преимущество, делая приложение бизнес-логики, создающей состояние, ленивым, поскольку состояние создаётся только при наличии сборщика состояния пользовательского интерфейса.
Уникален своим пользовательским интерфейсом AuthorViewModel применим только к навигационному пункту «Автор» и не может быть использован повторно где-либо ещё. Если какая-либо бизнес-логика используется повторно в разных навигационных пунктах, она должна быть инкапсулирована в компоненте, область действия которого ограничена уровнем данных или уровнем предметной области.

ViewModel как держатель состояния бизнес-логики

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

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

Логика пользовательского интерфейса и ее держатель состояния

Логика пользовательского интерфейса (UI) — это логика, работающая с данными, предоставляемыми самим пользовательским интерфейсом. Это может быть состояние элементов пользовательского интерфейса или источники данных пользовательского интерфейса, такие как API разрешений или Resources . Владельцы состояний, использующие логику пользовательского интерфейса, обычно обладают следующими свойствами:

  • Создает состояние пользовательского интерфейса и управляет состоянием элементов пользовательского интерфейса .
  • Не сохраняется при восстановлении Activity : держатели состояний, размещенные в логике пользовательского интерфейса, часто зависят от источников данных из самого пользовательского интерфейса, и попытка сохранить эту информацию при изменении конфигурации зачастую приводит к утечке памяти. Если держателям состояний необходимо сохранять данные при изменении конфигурации, их необходимо делегировать другому компоненту, более подходящему для сохранения после восстановления Activity . Например, в Jetpack Compose состояния элементов компонуемого пользовательского интерфейса, созданные с помощью remembered функций, часто делегируются rememberSaveable для сохранения состояния при восстановлении Activity . Примерами таких функций являются rememberScaffoldState() и rememberLazyListState() .
  • Имеет ссылки на источники данных в области пользовательского интерфейса : на такие источники данных, как API жизненного цикла и ресурсы, можно безопасно ссылаться и читать их, поскольку держатель состояния логики пользовательского интерфейса имеет тот же жизненный цикл, что и сам пользовательский интерфейс.
  • Возможность повторного использования в разных пользовательских интерфейсах : различные экземпляры одного и того же объекта состояния логики пользовательского интерфейса могут быть повторно использованы в разных частях приложения. Например, объект состояния для управления событиями пользовательского ввода для группы чипов может использоваться на странице поиска для чипов фильтров, а также для поля «Кому» для получателей электронных писем.

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

Вышеизложенное можно проиллюстрировать в следующем примере из образца Now in Android :

Теперь в Android используется простой класс-держатель состояния для управления логикой пользовательского интерфейса.
Рисунок 5. Пример приложения Now в Android.

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

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

@Stable
class NiaAppState(
    val navController: NavHostController,
    val windowSizeClass: WindowSizeClass
) {

    // UI logic
    val shouldShowBottomBar: Boolean
        get() = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Compact ||
            windowSizeClass.heightSizeClass == WindowHeightSizeClass.Compact

    // UI logic
    val shouldShowNavRail: Boolean
        get() = !shouldShowBottomBar

   // UI State
    val currentDestination: NavDestination?
        @Composable get() = navController
            .currentBackStackEntryAsState().value?.destination

    // UI logic
    fun navigate(destination: NiaNavigationDestination, route: String? = null) { /* ... */ }

     /* ... */
}

В предыдущем примере примечательны следующие детали, касающиеся NiaAppState :

  • Не сохраняется после воссоздания Activity : NiaAppState remembered в Composition путём его создания с помощью функции Composable rememberNiaAppState в соответствии с соглашениями об именовании Compose. После воссоздания Activity предыдущий экземпляр теряется и создаётся новый экземпляр со всеми переданными зависимостями, соответствующими новой конфигурации воссозданной Activity . Эти зависимости могут быть новыми или восстановленными из предыдущей конфигурации. Например, rememberNavController() используется в конструкторе NiaAppState и делегирует rememberSaveable сохранение состояния при воссоздании Activity .
  • Имеет ссылки на источники данных в области пользовательского интерфейса : ссылки на navigationController , Resources и другие подобные типы данных в области жизненного цикла могут безопасно храниться в NiaAppState , поскольку они имеют одну и ту же область жизненного цикла.

Выберите между ViewModel и обычным классом для держателя состояния

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

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

Данные передаются из слоя создания данных в слой пользовательского интерфейса
Рисунок 6. Держатели состояний в конвейере производства состояний пользовательского интерфейса. Стрелки обозначают поток данных.

В конечном счёте, состояние пользовательского интерфейса следует создавать, используя держатели состояний, наиболее близкие к месту его использования . Менее формально, состояние следует хранить как можно ниже, сохраняя при этом надлежащее владение. Если вам нужен доступ к бизнес-логике и нужно, чтобы состояние пользовательского интерфейса сохранялось до тех пор, пока осуществляется переход к экрану, даже при воссоздании Activity , ViewModel — отличный выбор для реализации держателя состояний бизнес-логики. Для краткосрочных состояний и логики пользовательского интерфейса достаточно простого класса, жизненный цикл которого зависит исключительно от пользовательского интерфейса.

Держатели штатов имеют право на компаундирование

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

  • Держатель логического состояния пользовательского интерфейса может зависеть от другого держателя логического состояния пользовательского интерфейса.
  • держатель состояния на уровне экрана может зависеть от держателя состояния логики пользовательского интерфейса.

В следующем фрагменте кода показано, как DrawerState Compose зависит от другого внутреннего держателя состояния, SwipeableState , и как держатель состояния логики пользовательского интерфейса приложения может зависеть от DrawerState :

@Stable
class DrawerState(/* ... */) {
  internal val swipeableState = SwipeableState(/* ... */)
  // ...
}

@Stable
class MyAppState(
  private val drawerState: DrawerState,
  private val navController: NavHostController
) { /* ... */ }

@Composable
fun rememberMyAppState(
  drawerState: DrawerState = rememberDrawerState(DrawerValue.Closed),
  navController: NavHostController = rememberNavController()
): MyAppState = remember(drawerState, navController) {
  MyAppState(drawerState, navController)
}

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

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

class MyScreenViewModel(/* ... */) {
  val uiState: StateFlow<MyScreenUiState> = /* ... */
  fun doSomething() { /* ... */ }
  fun doAnotherThing() { /* ... */ }
  // ...
}

@Stable
class MyScreenState(
  // DO NOT pass a ViewModel instance to a plain state holder class
  // private val viewModel: MyScreenViewModel,

  // Instead, pass only what it needs as a dependency
  private val someState: StateFlow<SomeState>,
  private val doSomething: () -> Unit,

  // Other UI-scoped types
  private val scaffoldState: ScaffoldState
) {
  /* ... */
}

@Composable
fun rememberMyScreenState(
  someState: StateFlow<SomeState>,
  doSomething: () -> Unit,
  scaffoldState: ScaffoldState = rememberScaffoldState()
): MyScreenState = remember(someState, doSomething, scaffoldState) {
  MyScreenState(someState, doSomething, scaffoldState)
}

@Composable
fun MyScreen(
  modifier: Modifier = Modifier,
  viewModel: MyScreenViewModel = viewModel(),
  state: MyScreenState = rememberMyScreenState(
    someState = viewModel.uiState.map { it.toSomeState() },
    doSomething = viewModel::doSomething
  ),
  // ...
) {
  /* ... */
}

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

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

Образцы

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

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