В конце декабря 2021-го Android обновил рекомендации по архитектуре мобильных приложений. Публикуем перевод гайда в пяти частях:
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) и обновить данные на экране (бизнес-логика):
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() } } }
@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 и переключится на нужный экран:
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. } ... } } } } }
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-состояние следующим образом, если бизнес-логика потребует показать пользователю новое исчезающее сообщение:
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) } } }
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-состояние снова обновится:
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) } ... } } } } }
@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 в заданный момент времени, что даёт больше гарантии, что оно будет доставлено и обработано. Как правило, его проще тестировать, а ещё оно стабильно интегрируется с остальными элементами приложения.
Читайте далее
ссылка на оригинал статьи https://habr.com/ru/company/surfstudio/blog/653681/
Добавить комментарий