Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
В конце декабря 2021-го Android обновил рекомендации по архитектуре мобильных приложений. Публикуем перевод гайда в пяти частях:
Обзор архитектуры
Слой UI
Cобытия UI (вы находитесь здесь)
Доменный слой
Слой данных
События пользовательского интерфейса (события UI) — это действия, которые должны обрабатываться на слое UI посредством самого UI или ViewModel. Наиболее распространённый вид событий – пользовательские события. Пользователи производят их в процессе взаимодействия с приложением: тапают по экрану или делают навигацию жестами. Затем UI принимает эти события с помощью таких функций callback, как onClick()
listeners.
Основные термины:
UI – код на основе view или Compose, который обрабатывает пользовательский интерфейс.
События UI – действия, которые должны обрабатываться на слое UI.
Пользовательские события – события, которые пользователь производит при взаимодействии с приложением.
Как правило, за обработку бизнес-логики пользовательского события отвечает ViewModel. Например, если пользователь кликает по кнопке, чтобы обновить данные. Обычно ViewModel обрабатывает такое событие, открывая доступ к функциям, которые может вызвать UI. Также у пользовательских событий может быть логика поведения UI, которую UI способен обрабатывать напрямую: к примеру, перенаправить пользователя на другой экран или показать Snackbar.
В отличие от бизнес-логики, которая остаётся неизменной для одного и того же приложения на разных мобильных платформах или в разных форм факторах, логика поведения UI – это деталь реализации, которая может отличаться в каждом из случаев. В гайде «Слой UI» даны определения этих видов логики:
Бизнес-логика – то, как меняется состояние. Пример: оплата заказа или сохранение пользовательских настроек. Эту логику обычно обрабатывают доменный слой и слой данных. В этом гайде в качестве единственно правильного решения для классов, обрабатывающих бизнес-логику, используется класс Architecture Components ViewModel.
Логика поведения UI или логика UI – это то, как мы отображаем изменения состояния. Пример: навигационная логика или способ отображения сообщений пользователю. Эту логику обрабатывает UI.
Дерево принятия решений для событий UI
На схеме ниже изображено дерево принятия решений для поиска наилучшего подхода к обработке UseCase определённого события.
Обработка пользовательских событий
UI способен обрабатывать пользовательские события самостоятельно, если они связаны с изменением состояния элемента UI: например, с состоянием раскрываемого элемента. Если событие требует выполнения бизнес-логики — скажем, обновить данные на экране — его должна обработать ViewModel.
На примере ниже посмотрим, как используются различные кнопки, чтобы раскрыть элемент UI (логика UI) и обновить данные на экране (бизнес-логика):
Views
class LatestNewsActivity : AppCompatActivity() {
private lateinit var binding: ActivityLatestNewsBinding
private val viewModel: LatestNewsViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
/* ... */
// The expand section event is processed by the UI that
// modifies a View's internal state.
binding.expandButton.setOnClickListener {
binding.expandedSection.visibility = View.VISIBLE
}
// The refresh event is processed by the ViewModel that is in charge
// of the business logic.
binding.refreshButton.setOnClickListener {
viewModel.refreshNews()
}
}
}
Compose
@Composable
fun NewsApp() {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = "latestNews") {
composable("latestNews") {
MyScreen(
// The navigation event is processed by calling the NavController
// navigate function that mutates its internal state.
onProfileClick = { navController.navigate("profile") }
)
}
/* ... */
}
}
@Composable
fun LatestNewsScreen(
viewModel: LatestNewsViewModel = viewModel(),
onProfileClick: () -> Unit
) {
Column {
// The refresh event is processed by the ViewModel that is in charge
// of the UI's business logic.
Button(onClick = { viewModel.refreshNews() }) {
Text("Refresh data")
}
Button(onClick = onProfileClick) {
Text("Profile")
}
}
}
Пользовательские события в RecyclerView
Если действие производится ниже по дереву UI, например в элементе RecyclerView
или кастомном View
, обработкой пользовательских событий всё ещё должна заниматься ViewModel
.
Представим, что у всех элементов новостных статей из NewsActivity
есть кнопка «добавить в закладки». ViewModel
нужно знать ID добавленного в закладки элемента. Когда пользователь добавляет статью в закладки, адаптер RecyclerView
не вызывает функцию addBookmark(newsId)
, к которой открыт доступ из ViewModel
, так как для этого бы потребовалась зависимость от ViewModel
. Вместо этого ViewModel
открывает доступ к объекту состояния под названием NewsItemUiState
, в котором содержится реализация для обработки события:
data class NewsItemUiState(
val title: String,
val body: String,
val bookmarked: Boolean = false,
val publicationDate: String,
val onBookmark: () -> Unit
)
class LatestNewsViewModel(
private val formatDateUseCase: FormatDateUseCase,
private val repository: NewsRepository
)
val newsListUiItems = repository.latestNews.map { news ->
NewsItemUiState(
title = news.title,
body = news.body,
bookmarked = news.bookmarked,
publicationDate = formatDateUseCase(news.publicationDate),
// Business logic is passed as a lambda function that the
// UI calls on click events.
onBookmark = {
repository.addBookmark(news.id)
}
)
}
}
Таким образом, адаптер RecyclerView
работает только с теми данными, которые ему нужны, — со списком объектов NewsItemUiState
. У адаптера нет доступа ко всей ViewModel: это сильно сокращает вероятность неправильного использования функциональности, к которой открывает доступ ViewModel.
Предоставляя разрешение на работу с ViewModel только классу Activity, вы разделяете ответственности. Таким образом, вы гарантируете, что UI-специфичные объекты вроде view или адаптеров RecyclerView
не взаимодействуют с ViewModel напрямую.
Важно! Передавать ViewModel адаптеру RecyclerView — плохая практика: адаптер и класс ViewModel становятся сильно связаны.
Важно: также разработчики часто создают адаптеру RecyclerView-интерфейс Callback для пользовательских действий. В таком случае Activity или Fragment выполняют связывание и вызывают функции ViewModel напрямую из интерфейса функции Callback.
Правила именования функций пользовательских событий
В этом гайде функции ViewModel, обрабатывающие пользовательские события, называются словосочетанием с глаголом — исходя из действия, которое они обрабатывают. Например, addBookmark(id)
или logIn(username, password)
.
Обработка событий ViewModel
Действия UI, которые отправлены из ViewModel — события ViewModel — всегда должны вести к обновлению UI-состояния. Это правило согласуется с принципами Unilateral Data Flow (UDF). Благодаря ему события можно переиспользовать, если изменилась конфигурация, а действия UI гарантированно не потеряются. При желании события можно сделать переиспользуемыми и после смерти процесса с помощью модуля Saved State.
Преобразовывать действия UI в UI-состояние не всегда просто, но логика от этого действительно упрощается. К примеру, не думайте, как сделать так, чтобы UI отправлял пользователя на определённый экран. Думать нужно шире и решать, как представить нужный user flow в UI-состоянии своего приложения. Другими словами, не думайте о том, какие действия должен выполнить UI, – думайте, как эти действия повлияют на UI-состояние.
Ключевой момент: события ViewModel всегда должны вести к обновлению UI-состояния.
К примеру, возьмём сценарий, в котором нужно перейти на главную страницу с экрана регистрации, когда пользователь зарегистрировался в приложении. В UI-состоянии это можно смоделировать следующим образом:
data class LoginUiState(
val isLoading: Boolean = false,
val errorMessage: String? = null,
val isUserLoggedIn: Boolean = false
)
В данном случае UI отреагирует на изменения состояния isUserLoggedIn
и переключится на нужный экран:
Views
class LoginViewModel : ViewModel() {
private val _uiState = MutableStateFlow(LoginUiState())
val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()
/* ... */
}
class LoginActivity : AppCompatActivity() {
private val viewModel: LoginViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
/* ... */
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { uiState ->
if (uiState.isUserLoggedIn) {
// Navigate to the Home screen.
}
...
}
}
}
}
}
Compose
class LoginViewModel : ViewModel() {
var uiState by mutableStateOf(LoginUiState())
private set
/* ... */
}
@Composable
fun LoginScreen(
viewModel: LoginViewModel = viewModel(),
onUserLogIn: () -> Unit
) {
val currentOnUserLogIn by rememberUpdatedState(onUserLogIn)
// Whenever the uiState changes, check if the user is logged in.
LaunchedEffect(viewModel.uiState) {
if (viewModel.uiState.isUserLoggedIn) {
currentOnUserLogIn()
}
}
// Rest of the UI for the login screen.
}
Важно: для понимания примеров кода в этом разделе требуется знание корутин и правил их применения с компонентами, зависящими от изменений жизненного цикла других компонентов.
Обработка событий может запустить обновление состояния
Обработка некоторых событий ViewModel в UI может привести к обновлению других UI-состояний.
Пример: на экране отобразилось временное сообщение, что что-то произошло. Когда сообщение уже отобразилось на экране, интерфейсу необходимо оповестить ViewModel, что нужно инициировать обновление состояния снова. Такое UI-состояние можно смоделировать следующим образом:
// Models the message to show on the screen.
data class UserMessage(val id: Long, val message: String)
// Models the UI state for the Latest news screen.
data class LatestNewsUiState(
val news: List<News> = emptyList(),
val isLoading: Boolean = false,
val userMessages: List<UserMessage> = emptyList()
)
ViewModel обновит UI-состояние следующим образом, если бизнес-логика потребует показать пользователю новое исчезающее сообщение:
Views
class LatestNewsViewModel(/* ... */) : ViewModel() {
private val _uiState = MutableStateFlow(LatestNewsUiState(isLoading = true))
val uiState: StateFlow<LatestNewsUiState> = _uiState
fun refreshNews() {
viewModelScope.launch {
// If there isn't internet connection, show a new message on the screen.
if (!internetConnection()) {
_uiState.update { currentUiState ->
val messages = currentUiState.userMessages + UserMessage(
id = UUID.randomUUID().mostSignificantBits,
message = "No Internet connection"
)
currentUiState.copy(userMessages = messages)
}
return@launch
}
// Do something else.
}
}
fun userMessageShown(messageId: Long) {
_uiState.update { currentUiState ->
val messages = currentUiState.userMessages.filterNot { it.id == messageId }
currentUiState.copy(userMessages = messages)
}
}
}
Compose
class LatestNewsViewModel(/* ... */) : ViewModel() {
var uiState by mutableStateOf(LatestNewsUiState())
private set
fun refreshNews() {
viewModelScope.launch {
// If there isn't internet connection, show a new message on the screen.
if (!internetConnection()) {
val messages = uiState.userMessages + UserMessage(
id = UUID.randomUUID().mostSignificantBits,
message = "No Internet connection"
)
uiState = uiState.copy(userMessages = messages)
return@launch
}
// Do something else.
}
}
fun userMessageShown(messageId: Long) {
val messages = uiState.userMessages.filterNot { it.id == messageId }
uiState = uiState.copy(userMessages = messages)
}
}
ViewModel необязательно знать, как UI отображает сообщение на экране. Она просто знает, что для пользователя есть сообщение и нужно его показать. Как только исчезающее сообщение отобразилось, UI должен сообщить об этом ViewModel, в результате чего UI-состояние снова обновится:
Views
class LatestNewsActivity : AppCompatActivity() {
private val viewModel: LatestNewsViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
/* ... */
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { uiState ->
uiState.userMessages.firstOrNull()?.let { userMessage ->
// TODO: Show Snackbar with userMessage.
// Once the message is displayed and
// dismissed, notify the ViewModel.
viewModel.userMessageShown(userMessage.id)
}
...
}
}
}
}
}
Compose
@Composable
fun LatestNewsScreen(
snackbarHostState: SnackbarHostState,
viewModel: LatestNewsViewModel = viewModel(),
) {
// Rest of the UI content.
// If there are user messages to show on the screen,
// show the first one and notify the ViewModel.
viewModel.uiState.userMessages.firstOrNull()?.let { userMessage ->
LaunchedEffect(userMessage) {
snackbarHostState.showSnackbar(userMessage.message)
// Once the message is displayed and dismissed, notify the ViewModel.
viewModel.userMessageShown(userMessage.id)
}
}
}
Другие сценарии
Если вам кажется, что ваш сценарий с UI-событием не решить при помощи обновления UI-состояния, можно переорганизовать потоки данных в приложении. Попробуйте применить следующие принципы:
Каждый класс должен делать только то, за что он отвечает, — и не более того. UI отвечает за логику поведения, связанную с экраном: например, за вызовы навигации, события нажатий на кнопки и получение запросов на разрешение. ViewModel содержит бизнес-логику и преобразует результаты с нижних слоев иерархии в UI-состояние.
Подумайте, где зарождается конкретное событие. Пройдите по дереву принятия решений (в начале гайда) и сделайте так, чтобы каждый класс обрабатывал только то, за что он отвечает. К примеру, если событие зарождается в UI и приводит к событию навигации, это событие нужно обрабатывать в UI. В некоторых случаях логику можно делегировать ViewModel, однако обработку события нельзя полностью делегировать ViewModel.
Если у вас несколько получателей события, и вы переживаете, что событие может быть получено несколько раз, возможно, вам стоит пересмотреть архитектуру приложения. Если у вас в один момент времени есть несколько получателей состояния, гарантировать контракт однократной доставки (delivered exactly once) становится крайне затруднительно, а сложность и хрупкость решения становится чрезмерной. Если вы столкнулись с такой проблемой, попробуйте сдвинуть получателей вверх по дереву UI. Возможно, вам понадобится другая сущность, расположенная выше в иерархии.
Подумайте, когда конкретное состояние нужно получать. В некоторых ситуациях нежелательно продолжать получать состояние, если приложение работает в фоне. Например, если речь идёт о
Toast
. В таких случаях рекомендуется получать состояние, когда UI виден пользователю.
Важно: Вы могли столкнуться с тем, что в некоторых приложениях события ViewModel представляются UI с помощью Kotlin Channels или других реактивных потоков. Таким решениям, как правило, требуются обходные пути (например, обёртки над событиями), чтобы гарантировать, что события не будут потеряны и будут использованы только один раз.
Если для решения задачи нужен обходной путь, это говорит о том, что с подходом не всё в порядке. Проблема с предоставлением доступа к событиям из ViewModel в том, что это противоречит принципу UDF (состояния идут вниз, а события – вверх).
Если вы оказались в такой ситуации, пересмотрите значение этого единичного события из ViewModel для вашего UI и преобразуйте его в UI-состояние. UI-состояние лучше представляет состояние UI в заданный момент времени, что даёт больше гарантии, что оно будет доставлено и обработано. Как правило, его проще тестировать, а ещё оно стабильно интегрируется с остальными элементами приложения.
Читайте далее
Обзор архитектуры
Слой UI
Доменный слой
Слой данных