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

Концепции и реализация Jetpack Compose

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

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

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

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

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

Пользовательский интерфейс может обрабатывать события напрямую, если эти события связаны с изменением состояния элемента интерфейса — например, состояния раскрывающегося элемента. Если событие требует выполнения бизнес-логики, такой как обновление данных на экране, оно должно обрабатываться 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()
        }
    }
}

Пользовательские события в 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.
                    }
                    ...
                }
            }
        }
    }
}

Потребление событий может инициировать обновление состояния.

Обработка определенных событий 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)
        }
    }
}

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

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

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

Если ввод данных требует проверки с помощью бизнес-логики перед переходом по странице, 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.
                    }
                    ...
                }
            }
        }
    }
}

В приведенном выше примере приложение работает как ожидалось, поскольку текущая точка входа (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)
    }
}

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

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