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

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

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

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

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

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

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

Если событие возникло в 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")
        }
    }
}

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

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

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

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

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

Образцы

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

{% дословно %} {% дословно %} {% дословно %} {% дословно %}