Pull to refresh
68.08
Surf
Создаём веб- и мобильные приложения

Гайд по архитектуре приложений для Android. Часть 2: слой UI

Reading time 15 min
Views 18K
Original author: Android

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

Обзор архитектуры

Слой UI (вы находитесь здесь)

События UI

Доменный слой

Слой данных

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

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

Роль слоя UI в архитектуре приложения
Роль слоя UI в архитектуре приложения

Разберём простой пример

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

Таким образом, приложение позволяет пользователям:

  • Просматривать доступные для прочтения статьи.

  • Фильтровать их по категориям.

  • Войти в систему и добавить интересные статьи в закладки.

  • Пользоваться премиальными фичами, если к ним есть доступ.

Пример новостного приложения для изучения UI
Пример новостного приложения для изучения UI

В следующих разделах будем использовать это приложение как пример. Вы познакомитесь с принципами Unidirectional Data Flow (UDF) и узнаете, с какими проблемами в контексте архитектуры приложения в слое UI они помогают разобраться.

Архитектура слоя UI

Под термином UI подразумеваются UI-элементы (например, Activity и Fragment), которые отображают данные: вне зависимости от того, какими API они пользуются для этой цели — View или Jetpack Compose. Цель слоя данных — хранить данные приложения, управлять ими и открывать к ним доступ. Поэтому слой UI должен:

  1. Принимать данные приложения и преобразовывать их в данные, которые UI легко сможет отрисовать.

  2. Принимать данные, которые может отрисовать UI, и преобразовывать их в элементы UI, которые будут показаны пользователю.

  3. Принимать события о вводе данных пользователем от элементов UI из пункта 2 и отражать в данных UI изменения, которые они вносят.

  4. Повторять шаги 1–3 необходимое количество раз.

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

  • Как описать UI-состояние.

  • UDF: как с его помощью создать UI-состояние и управлять им.

  • Как открывать доступ к UI-состоянию с помощью данных типа observable в соответствии с принципами UDF.

  • Как реализовать UI, который принимает UI-состояние типа observable.

Самая базовая из этих задач — описание UI-состояния.

Как описывать UI-состояния

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

UI – то, что пользователь видит на экране. С помощью UI state приложение управляет тем, что пользователь увидит на экране.

Два этих понятия — как две стороны одной монеты: UI — это визуальное представление UI-состояния. Любые изменения UI-состояния немедленно отражаются в UI.

UI – результат привязки UI элементов на экране к UI-состоянию
UI – результат привязки UI элементов на экране к UI-состоянию

Чтобы выполнить требования новостного приложения, информацию, необходимую для полной отрисовки UI, можно инкапсулировать в класс данных 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,

    ...

)

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

В предыдущем примере UI-состояние описано как неизменяемое. Основное преимущество такого подхода — неизменяемые объекты гарантируют целостность состояния приложения в любой момент времени. 

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

Если нарушить этот принцип, у одной и той же информации появится несколько source of truth (источников истины), что приведёт к противоречивости данных и багам, которые трудно обнаружить.

Скажем, если бы в флаг bookmarked в объекте NewsItemUiState из UI-состояния обновлялся в классе Activity, за статус статьи «сохранена» отвечали бы параллельно и этот флаг, и слой данных. Неизменяемые классы помогают успешно предотвратить возникновение таких антипаттернов.

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

Правила именования в этом гайде

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

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

К примеру, состояние, при котором на экране отображены новости, будет называться NewsUiState. Состояние, при котором статья отображается в списке статей, — NewsItemUiState.

Как управлять состоянием с помощью UDF

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

Лучше обрабатывать эти взаимодействия с помощью дополнительной сущности:

  • она будет описывать логику, применяемую к каждому событию,

  • и трансформировать данные, получаемые из источников, чтобы создать UI-состояние. 

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

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

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

State holder

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

В последнем случае его реализация – это, как правило, экземпляр ViewModel. Однако, в зависимости от требований приложения, иногда можно обойтись обычным классом. Так, новостное приложение из примера использует в качестве state holder класс NewsViewModel: он производит UI-состояние для экрана, изображённого в начале статьи.

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

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

Схематичное изображение работы UDF в рамках архитектуры приложения
Схематичное изображение работы UDF в рамках архитектуры приложения

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

  • ViewModel хранит состояние и открывает к нему доступ для UI. UI-состояние – это данные приложения, преобразованные ViewModel.

  • UI уведомляет ViewModel о пользовательских событиях.

  • ViewModel обрабатывает действия пользователя и обновляет состояние.

  • Обновлённое состояние возвращается обратно в UI, а тот его отрисовывает.

  • Все предыдущие пункты повторяются с каждым событием, меняющим состояние.

В случае с конечными точками навигации или экранами ViewModel получает данные из репозиториев или классов UseCase, а затем преобразует их в UI-состояния, применяя к ним эффекты событий, вызвавших изменение состояния. В упомянутом ранее примере есть список статей: у каждой указаны заголовок, описание, источник, имя автора, дата публикации и возможна пометка «добавлено в закладки». UI каждой статьи выглядит так:

UI статьи в приложении из примера
UI статьи в приложении из примера

Когда пользователь отправляет запрос, чтобы добавить статью в закладки, он создаёт событие, которое способно изменить состояние. Так как ViewModel производит состояния, его ответственность – описывать логику, необходимую для заполнения всех полей в UI-состоянии, и обрабатывать события, без которых UI не сможет отрисоваться полностью.

Цикл событий и данных в UDF
Цикл событий и данных в UDF

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

Виды логики

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

  • Логика поведения UI или логика UI — то, как мы отображаем изменения состояния на экране. Например, с помощью Android Resources получаем текст, который нужно отобразить на экране; переходим на нужный экран, когда пользователь нажимает на кнопку; или показываем пользователю сообщение на экране с помощью Toast или Snackbar.

Логика UI, в особенности когда она затрагивает такие типы UI, как Context, должна находиться в UI, а не во ViewModel. Если UI приложения усложняется, и вам хочется делегировать логику UI другому классу, чтобы разделить ответственности и легче тестировать приложение, можно создать простой класс, который будет выполнять роль экземпляра state holder. Простые классы, созданные в UI, могут получать зависимости от Android SDK: их жизненный цикл ограничен жизненным циклом UI, в то время как объекты ViewModel живут дольше.

Подробнее о state holder и том, как он помогает построить UI, можно почитать в гайде «Jetpack Compose State».

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

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

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

  • Согласованности данных. У UI есть только один источник правды.

  • Удобства тестирования. Источник состояния изолирован, а значит, его можно тестировать отдельно от UI.

  • Надёжности. Изменение состояния следует чётко заданному паттерну: изменения вызываются пользовательскими событиями и источниками данных, которые они запрашивают.

Как предоставлять доступ к UI-состоянию

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

Так как вы управляете производством состояния с помощью UDF, рекомендуется сделать производимое состояние потоком — другими словами, в процессе работы программы будет произведено несколько версий этого состояния. В таком случае доступ к UI-состоянию следует предоставить с помощью observable-обёртки над данными, к примеру LiveData или StateFlow. Это нужно сделать, чтобы UI мог реагировать на все изменения, внесённые в состояние, и вам не приходилось вручную получать данные прямо из ViewModel. 

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

Views

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

val uiState: StateFlow = …

}

Compose

class NewsViewModel(...) : ViewModel() {
  
    val uiState: NewsUiState = …
  
}

Вводную информацию об использовании LiveData в качестве observable-обёртки над данными можно прочитать в этом кодлабе. Вводную информацию о flow в Kotlin — в статье «Flow в Kotlin на Android».

Важно: чтобы предоставить доступ к UI-состоянию в приложениях с Jetpack Compose, можно использовать его родные observable State API, например mutableStateOf или snapshotFlow. Все указанные в этом гайде типы observable-обёрток над данными, такие как StateFlow или LiveData, легко можно использовать в Compose с помощью соответствующих extension функций.

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

Поток данных UiState зачастую создают, предоставляя из ViewModel доступ к приватному изменяемому потоку как к неизменяемому: например, доступ к MutableStateFlow<UiState> как к StateFlow<UiState>.

Views

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

    private val _uiState = MutableStateFlow(NewsUiState())

    val uiState: StateFlow<NewsUiState> = _uiState.asStateFlow()

    ...

}

Compose

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

    var uiState by mutableStateOf(NewsUiState())
        private set

    ...
}

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

Views

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

    private val _uiState = MutableStateFlow(NewsUiState())
    val uiState: StateFlow<NewsUiState> = _uiState.asStateFlow()

    private var fetchJob: Job? = null

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

Compose

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 notify the UI when appropriate.
                val messages = getMessagesFromThrowable(ioe)
                uiState = uiState.copy(userMessages = messages)
            }
        }
    }
}

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

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

Дополнительные рекомендации

Предоставляя доступ к UI-состоянию, следует учитывать:

  • Объект UI-состояния должен обрабатывать состояния, которые связаны друг с другом. В таком случае неконсистентность будет возникать реже, а код будет проще понять. Если открыть доступ к списку статей и количеству сохранённых статей в двух разных потоках данных, можно оказаться в ситуации, где одни данные обновились, а другие – нет. Если использовать один поток, оба элемента будут содержать актуальные данные.

    Более того, в некоторых случаях бизнес логика может потребовать слияния источников. К примеру, вам может потребоваться показывать кнопку «добавить в закладки» только если пользователь зарегистрировался и подписан на премиум услугу. В таком случае класс UI-состояния можно описать следующим образом:

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-состояния: один поток или несколько? Если вы выбираете, открыть доступ к UI-состоянию в одном потоке или в нескольких, основным ориентиром должен стать предыдущий пункт: связь между отправляемыми элементами.

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

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

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

Как принимать UI-состояния

Чтобы UI начал получать UiState из потока, используется терминальный оператор, соответствующий конкретному observable-типу данных в коде. Например, в случае с LiveData используется метод observe(), а в случае с flow в Kotlin – метод collect() или его вариации.

Когда запускаете получение observable-обёрток над данными в UI, убедитесь, что учли жизненный цикл UI. Это важно: UI не должен отслеживать UI-состояние, когда view пользователю не показывают. 

Подробнее тему можно изучить в посте «A safer way to collect flows from Android UIs». Когда используется LiveData, LifecycleOwner сам решает вопрос жизненных циклов. В случае с flow лучше использовать соответствующий coroutine scope и repeatOnLifecycle API:

Views

class NewsActivity : AppCompatActivity() {

    private val viewModel: NewsViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        ...

        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.uiState.collect {
                    // Update UI elements
                }
            }
        }
    }
}

Compose

@Composable
fun LatestNewsScreen(
    viewModel: NewsViewModel = viewModel()
) {
    // Show UI elements based on the viewModel.uiState
}

Важно: объекты StateFlow, использованные в этом примере, не прекращают работу, когда у них нет активных получателей данных. Но, работая с flow, вы можете и не знать, как они реализованы. Получение данных flow с учётом жизненного цикла позволяет вносить подобные изменения в потоки ViewModel позднее — без необходимости дорабатывать код получателя данных.

Как отображать прогресс выполнения операций

Проще всего отобразить состояние загрузки в классе UiState с помощью поля boolean:

data class NewsUiState(

    val isFetchingArticles: Boolean = false,

    ...

)

Значение этого флага указывает на наличие или отсутствие прогресс-бара в UI.

Views

class NewsActivity : AppCompatActivity() {

    private val viewModel: NewsViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        ...

        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                // Bind the visibility of the progressBar to the state
                // of isFetchingArticles.
                viewModel.uiState
                    .map { it.isFetchingArticles }
                    .distinctUntilChanged()
                    .collect { progressBar.isVisible = it }
            }
        }
    }
}

Compose

@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.
    }
}

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

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

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

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

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

data class NewsUiState(

    val userMessages: List<Message> = listOf(),

    ...

)

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

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

Любая операция, которую мы выполняем в ViewModel, должна быть main-safe: вызывать её из главного потока должно быть безопасно. За переключение работы в другой поток отвечают слои данных и предметной области.

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

Навигация

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

Пагинация

Paging library передает на UI-класс PagingData. Так как PagingData представляет и содержит элементы, которые могут меняться с течением времени (то есть не относятся к неизменяемому типу), его не следует представлять в неизменяемом UI-состоянии. Логичнее будет предоставить к нему доступ из ViewModel в отдельном потоке. Конкретные примеры можно посмотреть в кодлабе Android Paging.

Анимации

Чтобы анимации переходов были качественными и плавными, иногда приходится подождать, пока загрузятся данные со следующего экрана, прежде чем стартовать анимацию. В Android view фреймворке есть методы, с помощью которых можно отсрочить переход между фрагментами с postponeEnterTransition() API и startPostponedEnterTransition() API. Эти API помогают убедиться, что элементы UI на следующем экране (как правило, изображения, которые загружаются из сети) готовы к отрисовке, прежде чем UI анимирует переход на этот экран. Подробную информацию и способы применения вы найдёте в Android Motion sample.

Читайте далее

Обзор архитектуры

События UI

Доменный слой

Слой данных


Больше полезного про Android — в нашем телеграм-канале Surf Android Team. Здесь мы публикуем кейсы, лучшие практики, новости и вакансии Surf, а также проводим прямые эфиры. Присоединяйтесь!

Tags:
Hubs:
+4
Comments 1
Comments Comments 1

Articles

Information

Website
surf.ru
Registered
Founded
Employees
201–500 employees
Location
Россия