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

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

Слой данных

Комментарии (0)