События пользовательского интерфейса

События пользовательского интерфейса — это действия, которые должны обрабатываться на уровне пользовательского интерфейса, либо самим интерфейсом, либо ViewModel. Наиболее распространенный тип событий — это пользовательские события . Пользователь генерирует пользовательские события, взаимодействуя с приложением, например, касаясь экрана или выполняя жесты. Затем пользовательский интерфейс обрабатывает эти события с помощью коллбэков, таких как обработчики событий onClick() .

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

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

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

Дерево принятия решений по событиям пользовательского интерфейса

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

Если событие возникло в ViewModel, обновите состояние пользовательского интерфейса. Если событие возникло в пользовательском интерфейсе и требует бизнес-логики, делегируйте бизнес-логику ViewModel. Если событие возникло в пользовательском интерфейсе и требует логики поведения пользовательского интерфейса, измените состояние элемента пользовательского интерфейса непосредственно в пользовательском интерфейсе.
Рисунок 1. Дерево решений для обработки событий.

Обработка событий пользователя

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

В следующем примере показано, как различные кнопки используются для развертывания элемента пользовательского интерфейса (логика пользовательского интерфейса) и для обновления данных на экране (бизнес-логика):

Мнения

class LatestNewsActivity : AppCompatActivity() {

    private lateinit var binding: ActivityLatestNewsBinding
    private val viewModel: LatestNewsViewModel by viewModels()

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

        // The expand details 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 LatestNewsScreen(viewModel: LatestNewsViewModel = viewModel()) {

    // State of whether more details should be shown
    var expanded by remember { mutableStateOf(false) }

    Column {
        Text("Some text")
        if (expanded) {
            Text("More details")
        }

        Button(
          // The expand details event is processed by the UI that
          // modifies this composable's internal state.
          onClick = { expanded = !expanded }
        ) {
          val expandText = if (expanded) "Collapse" else "Expand"
          Text("$expandText details")
        }

        // 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")
        }
    }
}

Пользовательские события в RecyclerView

Если действие выполняется ниже по дереву пользовательского интерфейса, например, в элементе 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, вы разделяете обязанности. Это гарантирует, что объекты, специфичные для пользовательского интерфейса, такие как представления или адаптеры RecyclerView , не будут напрямую взаимодействовать с ViewModel.

Соглашения об именовании функций пользовательских событий

В этом руководстве функции ViewModel, обрабатывающие пользовательские события, именуются глаголом в зависимости от выполняемого действия — например: addBookmark(id) или logIn(username, password) .

Обработка событий ViewModel

Действия пользовательского интерфейса, инициированные из ViewModel (события ViewModel), всегда должны приводить к обновлению состояния пользовательского интерфейса . Это соответствует принципам однонаправленного потока данных . Это обеспечивает воспроизводимость событий после изменения конфигурации и гарантирует, что действия пользовательского интерфейса не будут потеряны. При желании вы также можете обеспечить воспроизводимость событий после завершения процесса, используя модуль сохранения состояния .

Сопоставление действий пользовательского интерфейса с состоянием интерфейса — не всегда простой процесс, но он приводит к более простой логике. Ваш мыслительный процесс не должен ограничиваться, например, определением того, как перевести интерфейс на определенный экран. Вам нужно думать дальше и учитывать, как представить этот пользовательский поток в состоянии интерфейса. Другими словами: не думайте о том, какие действия должен совершать интерфейс; думайте о том, как эти действия влияют на состояние интерфейса.

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

data class LoginUiState(
    val isLoading: Boolean = false,
    val errorMessage: String? = null,
    val isUserLoggedIn: Boolean = false
)

Этот пользовательский интерфейс реагирует на изменения состояния 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 в пользовательском интерфейсе может привести к другим обновлениям состояния интерфейса. Например, при отображении временных сообщений на экране, чтобы сообщить пользователю о произошедшем событии, пользовательский интерфейс должен уведомить ViewModel о необходимости запуска обновления состояния после отображения сообщения на экране. Событие, происходящее после того, как пользователь обработал сообщение (закрыв его или по истечении тайм-аута), можно рассматривать как «пользовательский ввод», и, следовательно, ViewModel должен это учитывать. В этой ситуации состояние пользовательского интерфейса можно смоделировать следующим образом:

// Models the UI state for the Latest news screen.
data class LatestNewsUiState(
    val news: List<News> = emptyList(),
    val isLoading: Boolean = false,
    val userMessage: String? = null
)

В случае, когда бизнес-логика требует отображения пользователю нового временного сообщения, ViewModel будет обновлять состояние пользовательского интерфейса следующим образом:

Мнения

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 ->
                    currentUiState.copy(userMessage = "No Internet connection")
                }
                return@launch
            }

            // Do something else.
        }
    }

    fun userMessageShown() {
        _uiState.update { currentUiState ->
            currentUiState.copy(userMessage = null)
        }
    }
}

Сочинить

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()) {
                uiState = uiState.copy(userMessage = "No Internet connection")
                return@launch
            }

            // Do something else.
        }
    }

    fun userMessageShown() {
        uiState = uiState.copy(userMessage = null)
    }
}

ViewModel не нужно знать, как пользовательский интерфейс отображает сообщение на экране; он просто знает, что есть сообщение от пользователя, которое необходимо показать. После того, как временное сообщение будет показано, пользовательский интерфейс должен уведомить об этом ViewModel, что вызовет еще одно обновление состояния пользовательского интерфейса для очистки свойства userMessage :

Мнения

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.userMessage?.let {
                        // TODO: Show Snackbar with userMessage.

                        // Once the message is displayed and
                        // dismissed, notify the ViewModel.
                        viewModel.userMessageShown()
                    }
                    ...
                }
            }
        }
    }
}

Сочинить

@Composable
fun LatestNewsScreen(
    snackbarHostState: SnackbarHostState,
    viewModel: LatestNewsViewModel = viewModel(),
) {
    // Rest of the UI content.

    // If there are user messages to show on the screen,
    // show it and notify the ViewModel.
    viewModel.uiState.userMessage?.let { userMessage ->
        LaunchedEffect(userMessage) {
            snackbarHostState.showSnackbar(userMessage)
            // Once the message is displayed and dismissed, notify the ViewModel.
            viewModel.userMessageShown()
        }
    }
}

Несмотря на то, что сообщение носит временный характер, состояние пользовательского интерфейса точно отражает то, что отображается на экране в каждый конкретный момент времени. Либо сообщение пользователя отображается, либо нет.

В разделе « Использование событий может запускать обновления состояния» подробно описано, как использовать состояние пользовательского интерфейса для отображения сообщений пользователя на экране. События навигации также являются распространенным типом событий в приложениях Android.

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

Мнения

class LoginActivity : AppCompatActivity() {

    private lateinit var binding: ActivityLoginBinding
    private val viewModel: LoginViewModel by viewModels()

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

        binding.helpButton.setOnClickListener {
            navController.navigate(...) // Open help screen
        }
    }
}

Сочинить

@Composable
fun LoginScreen(
    onHelp: () -> Unit, // Caller navigates to the right screen
    viewModel: LoginViewModel = viewModel()
) {
    // Rest of the UI

    Button(onClick = onHelp) {
        Text("Get help")
    }
}

Если ввод данных требует проверки с помощью бизнес-логики перед переходом по страницам, ViewModel должен предоставить это состояние пользовательскому интерфейсу. Пользовательский интерфейс будет реагировать на изменение этого состояния и выполнять соответствующую навигацию. Этот вариант использования описан в разделе «Обработка событий ViewModel» . Вот похожий код:

Мнения

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

Сочинить

@Composable
fun LoginScreen(
    onUserLogIn: () -> Unit, // Caller navigates to the right screen
    viewModel: LoginViewModel = viewModel()
) {
    Button(
        onClick = {
            // ViewModel validation is triggered
            viewModel.login()
        }
    ) {
        Text("Log in")
    }
    // Rest of the UI

    val lifecycle = LocalLifecycleOwner.current.lifecycle
    val currentOnUserLogIn by rememberUpdatedState(onUserLogIn)
    LaunchedEffect(viewModel, lifecycle)  {
        // Whenever the uiState changes, check if the user is logged in and
        // call the `onUserLogin` event when `lifecycle` is at least STARTED
        snapshotFlow { viewModel.uiState }
            .filter { it.isUserLoggedIn }
            .flowWithLifecycle(lifecycle)
            .collect {
                currentOnUserLogIn()
            }
    }
}

В приведенном выше примере приложение работает как ожидалось, поскольку текущая точка входа (Login) не сохраняется в стеке возврата. Пользователи не могут вернуться к ней, нажав кнопку «Назад». Однако в случаях, когда это может произойти, решение потребует дополнительной логики.

Когда ViewModel устанавливает некоторое состояние, которое генерирует событие навигации с экрана A на экран B, и экран A остается в стеке возврата навигации, может потребоваться дополнительная логика, чтобы предотвратить автоматический переход к экрану B. Для реализации этого необходимо иметь дополнительное состояние, указывающее, следует ли пользовательскому интерфейсу рассматривать возможность перехода на другой экран. Обычно это состояние хранится в пользовательском интерфейсе, поскольку логика навигации относится к пользовательскому интерфейсу, а не к ViewModel. Для иллюстрации рассмотрим следующий пример использования.

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

Мнения

// Key that identifies the `validationInProgress` state in the Bundle
private const val DOB_VALIDATION_KEY = "dobValidationKey"

class DobValidationFragment : Fragment() {

    private var validationInProgress: Boolean = false
    private val viewModel: DobValidationViewModel by viewModels()

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        val binding = // ...
        validationInProgress = savedInstanceState?.getBoolean(DOB_VALIDATION_KEY) ?: false

        binding.continueButton.setOnClickListener {
            viewModel.validateDob()
            validationInProgress = true
        }

        viewLifecycleOwner.lifecycleScope.launch {
            viewModel.uiState
                .flowWithLifecycle(viewLifecycleOwner.lifecycle)
                .collect { uiState ->
                    // Update other parts of the UI ...

                    // If the input is valid and the user wants
                    // to navigate, navigate to the next screen
                    // and reset `validationInProgress` flag
                    if (uiState.isDobValid && validationInProgress) {
                        validationInProgress = false
                        navController.navigate(...) // Navigate to next screen
                    }
                }
        }

        return binding
    }

    override fun onSaveInstanceState(outState: Bundle) {
        super.onSaveInstanceState(outState)
        outState.putBoolean(DOB_VALIDATION_KEY, validationInProgress)
    }
}

Сочинить

class DobValidationViewModel(/* ... */) : ViewModel() {
    var uiState by mutableStateOf(DobValidationUiState())
        private set
}

@Composable
fun DobValidationScreen(
    onNavigateToNextScreen: () -> Unit, // Caller navigates to the right screen
    viewModel: DobValidationViewModel = viewModel()
) {
    // TextField that updates the ViewModel when a date of birth is selected

    var validationInProgress by rememberSaveable { mutableStateOf(false) }

    Button(
        onClick = {
            viewModel.validateInput()
            validationInProgress = true
        }
    ) {
        Text("Continue")
    }
    // Rest of the UI

    /*
     * The following code implements the requirement of advancing automatically
     * to the next screen when a valid date of birth has been introduced
     * and the user wanted to continue with the registration process.
     */

    if (validationInProgress) {
        val lifecycle = LocalLifecycleOwner.current.lifecycle
        val currentNavigateToNextScreen by rememberUpdatedState(onNavigateToNextScreen)
        LaunchedEffect(viewModel, lifecycle) {
            // If the date of birth is valid and the validation is in progress,
            // navigate to the next screen when `lifecycle` is at least STARTED,
            // which is the default Lifecycle.State for the `flowWithLifecycle` operator.
            snapshotFlow { viewModel.uiState }
                .filter { it.isDobValid }
                .flowWithLifecycle(lifecycle)
                .collect {
                    validationInProgress = false
                    currentNavigateToNextScreen()
                }
        }
    }
}

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

Другие варианты использования

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

  • Каждый класс должен выполнять свои обязанности, не более того. Пользовательский интерфейс отвечает за логику поведения, специфичную для каждого экрана, такую ​​как вызовы навигации, события кликов и получение запросов на разрешение. Модель представления содержит бизнес-логику и преобразует результаты из нижних уровней иерархии в состояние пользовательского интерфейса.
  • Подумайте, откуда исходит событие. Следуйте дереву решений , представленному в начале этого руководства, и пусть каждый класс обрабатывает то, за что он отвечает. Например, если событие исходит из пользовательского интерфейса и приводит к событию навигации, то это событие должно быть обработано в пользовательском интерфейсе. Часть логики может быть делегирована ViewModel, но обработка события не может быть полностью делегирована ViewModel.
  • Если у вас несколько потребителей и вы беспокоитесь о том, что событие будет обработано несколько раз, вам, возможно, следует пересмотреть архитектуру вашего приложения. Наличие нескольких одновременно работающих потребителей приводит к тому, что гарантировать доставку события ровно один раз становится крайне сложно, поэтому уровень сложности и тонкостей поведения резко возрастает. Если у вас возникла эта проблема, подумайте о том, чтобы перенести эти проблемы на более высокий уровень в дереве пользовательского интерфейса; вам может потребоваться другая сущность, расположенная выше в иерархии.
  • Подумайте о том, когда необходимо использовать состояние приложения. В некоторых ситуациях вам может не понадобиться постоянно использовать состояние, когда приложение находится в фоновом режиме — например, при отображении Toast . В таких случаях лучше использовать состояние, когда пользовательский интерфейс находится на переднем плане.

Образцы

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

{% verbatim %} {% endverbatim %} {% verbatim %} {% endverbatim %}