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

от автора

В конце декабря 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

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

Слой данных


ссылка на оригинал статьи https://habr.com/ru/company/surfstudio/blog/653681/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *