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

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

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

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

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

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

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

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

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

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

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

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

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

Самое важное из них – определение состояния интерфейса.

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

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

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

Интерфейс – это результат связывания элементов интерфейса на экране с состоянием интерфейса.
Рисунок 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. Диаграмма, иллюстрирующая цикл событий и данных в пользовательской функции.

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

Типы логики

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

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

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

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

Зачем использовать пользовательские функции?

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

Другими словами, пользовательские функции позволяют:

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

Как предоставить доступ к состоянию интерфейса

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

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

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

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

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

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

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

Страницы

Библиотека разбивки на страницы используется в интерфейсе с типом 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")
        }
    }
}

Анимации

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

Подробнее о переходах между экранами можно узнать в статьях Навигация 3 и Переходы между общими элементами в Compose.

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

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

Образцы

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