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

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

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

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

Простой пример использования

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

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

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

Архитектура уровня интерфейса

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

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

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

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

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

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

В примере, описанном ранее, в пользовательском интерфейсе показан список статей с метаданными для каждой из них. Эта информация, которую приложение представляет пользователю, является состоянием 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…

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

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

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

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

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

functionality + UiState.

Например, состояние экрана с новостями может называться NewsUiState, а состояние новости в списке – NewsItemUiState.

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

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

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

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

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

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

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

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

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

Шаблон, в котором состояние передаётся вниз, а события – вверх, называется однонаправленным потоком данных (UDF). Последствия этого шаблона для архитектуры приложения следующие:

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

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

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

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

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

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

Типы логики

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

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

Логика пользовательского интерфейса должна находиться в нем самом, а не в ViewModel, особенно если она включает типы пользовательского интерфейса, такие как Context. Если интерфейс усложняется и вы хотите делегировать его логику другому классу, чтобы упростить тестирование и разделение ответственности, создайте простой класс в качестве хранилища состояния. Простые классы, созданные в интерфейсе, могут принимать зависимости Android SDK, поскольку они следуют жизненному циклу интерфейса. Объекты ViewModel имеют более длительный срок службы.

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

Зачем использовать UDF?

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

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

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

Предоставление состояния интерфейса

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

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

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

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

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

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

Распространенный способ создания потока 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 пытается получить статьи для определенной категории, а затем отражает результат попытки (успех или неудачу) в состоянии пользовательского интерфейса, где пользовательский интерфейс может соответствующим образом на него отреагировать. Подробнее об обработке ошибок рассказывается в разделе Показ ошибок на экране.

Дополнительные сведения

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

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

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

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

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

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

    • Сравнение UiState. Чем больше полей в объекте UiState, тем выше вероятность того, что поток будет запущен в результате обновления одного из полей. Поскольку у элементов пользовательского интерфейса нет механизма сравнения, позволяющего понять, различаются ли последовательные выбросы или нет, каждый выброс вызывает обновление элемента пользовательского интерфейса. Это означает, что может потребоваться устранение уязвимости с помощью методов API Flow, таких как distinctUntilChanged().

Подробнее о рендеринге и состоянии интерфейса…

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

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

При использовании наблюдаемых держателей данных в пользовательском интерфейсе обязательно учитывайте жизненный цикл пользовательского интерфейса. Не заставляйте пользовательский интерфейс отслеживать состояние пользовательского интерфейса, когда composable-функция не отображается пользователю. Подробную информацию вы найдете в этой записи блога. При использовании потоков лучше всего обрабатывать проблемы жизненного цикла с помощью соответствующей области сопрограммы и 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.
    }
}

Показывать ошибки на экране

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

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

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

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

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

Потоки и параллелизм

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

Если ViewModel выполняет продолжительные операции, то он также отвечает за перенос этой логики в фоновый поток. Сопрограммы Kotlin – отличный способ управления параллельными операциями, а компоненты архитектуры Jetpack обеспечивают их встроенную поддержку. Подробнее о сопрограммах Kotlin в приложениях для Android…

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

Подробнее о навигации по интерфейсу…

Страницы

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

В следующем примере показан Compose API библиотеки 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")
        }
    }
}

Анимации

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

Дополнительную информацию о переходах между экранами можно найти в статьях Navigation 3 и Shared element transitions in Compose.

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

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

Образцы

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