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

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

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

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

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

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

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

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

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

функциональность + 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).

Виды логики

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

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

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

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

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

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

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

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

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

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

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

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

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