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

The role of the UI is to display the application data on the screen. The UI also serves as the primary point of user interaction. Whenever the data changes, either due to user interaction (like pressing a button) or external input (like a network response), the UI updates to reflect those changes. Effectively, the UI is a visual representation of the application state as retrieved from the data layer.

However, the application data you get from the data layer is usually in a different format than the information you need to display. For example, you might only need part of the data for the UI, or you might need to merge two different data sources to present information that is relevant to the user. Regardless of the logic you apply, you need to pass the UI all the information it needs to render fully. The UI layer is the pipeline that converts application data changes to a form that the UI can present, and then displays it.

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

Базовый пример из практики

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

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

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

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

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

  1. Обрабатывайте данные приложения и преобразуйте их в данные, которые пользовательский интерфейс сможет легко отобразить.
  2. Обрабатывать данные, доступные для отрисовки пользовательского интерфейса, и преобразовывать их в элементы пользовательского интерфейса для отображения пользователю.
  3. Обрабатывайте события пользовательского ввода от собранных элементов пользовательского интерфейса и отражайте их влияние в данных пользовательского интерфейса по мере необходимости.
  4. Повторяйте шаги с 1 по 3 столько раз, сколько необходимо.

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

  • Как определить состояние пользовательского интерфейса
  • Однонаправленный поток данных (UDF) как средство создания и управления состоянием пользовательского интерфейса.
  • Как предоставить доступ к состоянию пользовательского интерфейса с помощью наблюдаемых типов данных в соответствии с принципами пользовательских функций (UDF).
  • Как реализовать пользовательский интерфейс, который использует наблюдаемое состояние пользовательского интерфейса?

Наиболее фундаментальным из них является определение состояния пользовательского интерфейса.

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

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

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

Пользовательский интерфейс (UI) — это результат связывания элементов UI на экране с состоянием UI.
Рисунок 3. Пользовательский интерфейс является результатом связывания элементов пользовательского интерфейса на экране с состоянием пользовательского интерфейса.

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

data class NewsUiState(
    val isSignedIn: Boolean = false,
    val isPremium: Boolean = false,
    val newsItems: List<NewsItemUiState> = listOf(),
    val userMessages: List<Message> = listOf()
)

data class NewsItemUiState(
    val title: String,
    val body: String,
    val bookmarked: Boolean = false,
    // ...
)

Для получения дополнительной информации о состоянии пользовательского интерфейса см. раздел «Состояние и Jetpack Compose» .

Неизменность

The UI state definition in the previous example is immutable. The key benefit of this is that immutable objects provide guarantees regarding the state of the application at an instant in time. This frees up the UI to focus on its primary role: to read the state and update its UI elements accordingly. Never modify the UI state in the UI directly unless the UI itself is the sole source of its data. Violating this principle results in multiple sources of truth for the same piece of information, leading to data inconsistencies and subtle bugs.

Например, рассмотрим предыдущий пример. Если флаг bookmarked в объекте NewsItemUiState из состояния пользовательского интерфейса обновляется в классе Activity , этот флаг конкурирует со слоем данных как источник статуса "закладка" статьи. Неизменяемые классы данных очень полезны для предотвращения подобных несоответствий.

Правила именования в этом руководстве

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

функциональность + UiState .

Например, состояние экрана, отображающего новости, может называться NewsUiState , а состояние новостной статьи в списке новостей — NewsItemUiState .

Управление состоянием с помощью однонаправленного потока данных.

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

These interactions can benefit from a mediator to process them, defining the logic to be applied to each event and transforming the backing data sources to create UI state. Although these interactions and their logic can be housed in the UI itself, this can quickly get unwieldy as the UI takes on too much responsibility. Furthermore, this can affect testability because the resulting code is tightly coupled. Unless the UI state is very simple, make sure the UI's sole responsibility is to consume and display UI state.

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

Владельцы штатов

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

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

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

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

Схема, в которой состояние передается вниз, а события — вверх, называется однонаправленным потоком данных (UDF). Последствия этой схемы для архитектуры приложений следующие:

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

For navigation destinations or screens, the ViewModel works with repositories or use case classes to get data and transform it into the UI state while incorporating the effects of events that may cause mutations of the state. The case study mentioned earlier contains a list of articles, each having a title, description, source, author name, publication date, and whether it was bookmarked. The UI for each article item looks like this:

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

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

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

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

Виды логики

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

  • Бизнес-логика — это реализация требований к продукту для данных приложения. Как уже упоминалось, одним из примеров является добавление статьи в закладки в приложении для анализа кейса. Бизнес-логика обычно размещается на уровне предметной области или данных, но никогда не на уровне пользовательского интерфейса.
  • Логика поведения пользовательского интерфейса ( UI logic) — это способ отображения изменений состояния на экране. Примеры включают получение нужного текста для отображения на экране с помощью Resources Android, переход на определенный экран при нажатии пользователем кнопки или отображение сообщения пользователя на экране с помощью всплывающего уведомления (toast) или всплывающей панели (snackbar) .

Keep UI logic in the UI, not in the ViewModel, particularly when it involves UI types like Context . If the UI grows in complexity and you want to delegate the UI logic to another class to favor testability and separation of concerns, you can create a simple class as a state holder . Simple classes created in the UI can take Android SDK dependencies because they follow the lifecycle of the UI; ViewModel objects have a longer lifespan.

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

Почему следует использовать пользовательские функции (UDF)?

UDF моделирует цикл создания состояния, как показано на рисунке 4. Он также разделяет место, где возникают изменения состояния, место, где они преобразуются, и место, где они, наконец, используются. Это разделение позволяет пользовательскому интерфейсу делать именно то, что подразумевает его название: отображать информацию, наблюдая за изменениями состояния, и передавать намерения пользователя, передавая эти изменения в ViewModel.

Другими словами, UDF позволяет следующее:

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

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

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

When using UDF to manage the production of state, you can consider the produced state to be a stream—in other words, multiple versions of the state are produced over time. Expose the UI state in an observable data holder like StateFlow . This lets the UI react to any changes made in the state without having to manually pull data directly from the ViewModel. This also has the benefit of always having the latest version of the UI state cached, which is useful for quick state restoration after configuration changes.

class NewsViewModel(
    // ...
) : ViewModel() {

    val uiState: NewsUiState = /* ... */
}

Введение в потоки Kotlin см. в статье «Потоки Kotlin на Android» . Чтобы узнать, как использовать StateFlow в качестве хранилища наблюдаемых данных, см. практическое занятие «Расширенные возможности состояния и побочные эффекты в Jetpack Compose» .

In cases where the data exposed to the UI is relatively simple, it's often worth wrapping the data in a UI state type because it conveys the relationship between the emission of the state holder and its associated screen or UI element. As the UI element grows more complex, it's straightforward to add to the definition of the UI state, so you can accommodate the extra information needed to render the UI element.

Распространенный способ создания потока UiState — это предоставление свойства mutableStateOf с private set , при этом состояние остается изменяемым внутри ViewModel, но доступным только для чтения в пользовательском интерфейсе.

class NewsViewModel(
    // ...
) : ViewModel() {

    var uiState by mutableStateOf(NewsUiState())
        private set

    // ...
}

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

class NewsViewModel(
    private val repository: NewsRepository,
    // ...
) : ViewModel() {

    var uiState by mutableStateOf(NewsUiState())
        private set

    private var fetchJob: Job? = null

    fun fetchArticles(category: String) {
        fetchJob?.cancel()
        fetchJob = viewModelScope.launch {
            try {
                val newsItems = repository.newsItemsForCategory(category)
                uiState = uiState.copy(newsItems = newsItems)
            } catch (ioe: IOException) {
                // Handle the error and notify the UI when appropriate.
                val messages = getMessagesFromThrowable(ioe)
                uiState = uiState.copy(userMessages = messages)
            }
        }
    }
}

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

Дополнительные соображения

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

  • Use a single UI state object to handle states that are related to each other. This leads to fewer inconsistencies and it makes the code easier to understand. If you expose the list of news items and the number of bookmarks in two different streams, you might end up in a situation where one was updated and the other was not. When you use a single stream, both elements are kept up to date. Furthermore, some business logic may require a combination of sources. For example, you might need to show a bookmark button only if the user is signed in and that user is a subscriber to a premium news service. You can define a UI state class as follows:

    data class NewsUiState(
        val isSignedIn: Boolean = false,
        val isPremium: Boolean = false,
        val newsItems: List<NewsItemUiState> = listOf()
    )
    
    val NewsUiState.canBookmarkNews: Boolean get() = isSignedIn && isPremium

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

  • UI states: single stream or multiple streams? The key guiding principle for choosing between exposing UI state in a single stream or in multiple streams is the relationship between the items emitted. The biggest advantages of a single-stream exposure are convenience and data consistency: consumers of state always have the latest information available at any given time. However, there are instances where separate streams of state from the ViewModel might be appropriate:

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

    • UiState diffing: The more fields there are in a UiState object, the more likely it is that the stream emits as a result of one of its fields being updated. Because UI elements don't have a diffing mechanism to understand whether consecutive emissions are different or the same, every emission causes an update to the UI element. This means that mitigation using Flow API methods like distinctUntilChanged() might be necessary.

Для получения дополнительной информации о рендеринге и состоянии пользовательского интерфейса см. раздел «Жизненный цикл компонуемых объектов» .

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

Для обработки потока объектов UiState в пользовательском интерфейсе используйте терминальный оператор для используемого вами типа наблюдаемых данных. Например, для потоков Kotlin используйте метод collect() или его варианты.

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

@Composable
private fun ConversationScreen(
    conversationViewModel: ConversationViewModel = viewModel()
) {

    val messages by conversationViewModel.messages.collectAsStateWithLifecycle()

    ConversationScreen(
        messages = messages,
        onSendMessage = { message: Message -> conversationViewModel.sendMessage(message) }
    )
}

@Composable
private fun ConversationScreen(
    messages: List<Message>,
    onSendMessage: (Message) -> Unit
) {

    MessagesList(messages, onSendMessage)
    /* ... */
}

Показать текущие операции

Простой способ представления состояний загрузки в классе UiState — использование логического поля:

data class NewsUiState(
    val isFetchingArticles: Boolean = false,
    // ...
)

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

@Composable
fun LatestNewsScreen(
    modifier: Modifier = Modifier,
    viewModel: NewsViewModel = viewModel()
) {
    Box(modifier.fillMaxSize()) {

        if (viewModel.uiState.isFetchingArticles) {
            CircularProgressIndicator(Modifier.align(Alignment.Center))
        }

        // Add other UI elements. For example, the list.
    }
}

Отобразить ошибки на экране

Showing errors in the UI is similar to showing in-progress operations because they are both easily represented by boolean values that denote their presence or absence. However, errors might also include an associated message to relay back to the user, or an action associated with them that retries the failed operation. Therefore, while an in-progress operation is either loading or not loading, error states might need to be modeled with data classes that host the metadata appropriate for the context of the error.

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

data class Message(val id: Long, val message: String)

data class NewsUiState(
    val userMessages: List<Message> = listOf(),
    // ...
)

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

Многопоточность и параллельное программирование

Убедитесь, что вся работа, выполняемая в ViewModel, является безопасной для основного потока — то есть, её можно безопасно вызывать из основного потока. За перенос работы в другой поток отвечают слои данных и предметной области.

Если ViewModel выполняет длительные операции, то он также отвечает за перенос этой логики в фоновый поток. Корутины Kotlin — отличный способ управления параллельными операциями, и компоненты архитектуры Jetpack обеспечивают их встроенную поддержку. Чтобы узнать больше об использовании корутин в приложениях Android, см. статью «Корутины Kotlin на Android» .

Изменения в навигации приложения часто запускаются с помощью событий. Например, после того, как класс SignInViewModel выполнит вход в систему, UiState может быть установлено поле isSignedIn в true . Используйте подобные триггеры так же, как и те, что были рассмотрены в предыдущем разделе «Использование состояния пользовательского интерфейса» , но отложите реализацию использования до компонента Navigation .

Для получения дополнительной информации о навигации по пользовательскому интерфейсу см. раздел «Навигация 3» .

Пейджинг

Библиотека Paging используется в пользовательском интерфейсе с помощью типа PagingData . Поскольку PagingData представляет и содержит элементы, которые могут изменяться со временем — другими словами, это не неизменяемый тип — не следует представлять его в неизменяемом состоянии пользовательского интерфейса. Вместо этого, следует предоставлять к нему доступ из ViewModel независимо в отдельном потоке.

В следующем примере показан интерфейс Compose библиотеки Paging:

@Composable
fun MyScreen(flow: Flow<PagingData<String>>) {
    val lazyPagingItems = flow.collectAsLazyPagingItems()
    LazyColumn {
        items(
            lazyPagingItems.itemCount,
            key = lazyPagingItems.itemKey { it }
        ) { index ->
            val item = lazyPagingItems[index]
            Text("Item is $item")
        }
    }
}

Анимации

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

Для получения дополнительной информации о переходах навигации см. разделы «Навигация 3» и «Переходы общих элементов в Compose» .

Дополнительные ресурсы

Просмотры контента

Образцы

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

{% verbatim %} {% endverbatim %} {% verbatim %} {% endverbatim %}