UI 事件

UI 事件是应该在 UI 层处理的操作,可以由 UI 或 ViewModel 处理。最常见的事件类型是用户事件。用户通过与应用交互来生成用户事件,例如,通过点击屏幕或生成手势。然后,UI 使用回调(例如 onClick() 监听器)来使用这些事件。

ViewModel 通常负责处理特定用户事件的业务逻辑,例如,用户点击按钮以刷新一些数据。通常,ViewModel 通过公开 UI 可以调用的函数来处理此问题。用户事件也可能具有 UI 行为逻辑,UI 可以直接处理该逻辑,例如,导航到不同的屏幕或显示 Snackbar

虽然在不同的移动平台或外形尺寸上,相同应用的业务逻辑保持一致,但UI 行为逻辑是一个可能在这些情况下不同的实现细节。 UI 层页面 将这些类型的逻辑定义如下

  • 业务逻辑是指对状态更改的操作,例如,进行付款或存储用户偏好。域层和数据层通常处理此逻辑。在本指南中, 架构组件 ViewModel 类用作处理业务逻辑的类的意见性解决方案。
  • UI 行为逻辑UI 逻辑是指如何显示状态更改,例如,导航逻辑或如何向用户显示消息。UI 处理此逻辑。

UI 事件决策树

下图显示了一棵决策树,用于找到处理特定事件用例的最佳方法。本指南的其余部分将详细解释这些方法。

If the event originated in the ViewModel, then update the UI state. If
    the event originated in the UI and requires business logic, then delegate
    the business logic to the ViewModel. If the event originated in the UI and
    requires UI behavior logic, then modify the UI element state directly in the
    UI.
图 1. 处理事件的决策树。

处理用户事件

如果这些事件与修改 UI 元素的状态相关,例如,可扩展项的状态,则 UI 可以直接处理用户事件。如果事件需要执行业务逻辑,例如,刷新屏幕上的数据,则应由 ViewModel 处理。

以下示例显示了如何使用不同的按钮来展开 UI 元素(UI 逻辑)和刷新屏幕上的数据(业务逻辑)

视图

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

Compose

@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 中的用户事件

如果操作是在 UI 树的更下方产生的,例如,在 RecyclerView 项或自定义 View 中,则 ViewModel 仍应是处理用户事件的组件。

例如,假设所有来自 NewsActivity 的新闻项目都包含一个书签按钮。 ViewModel 需要知道已加书签的新闻项目的 ID。当用户为新闻项目添加书签时,RecyclerView 适配器不会调用 ViewModel 中公开的 addBookmark(newsId) 函数,这需要依赖 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 协作时,您就将职责分开了。这确保了 UI 特定的对象(如视图或 RecyclerView 适配器)不会直接与 ViewModel 交互。

用户事件函数的命名约定

在本指南中,处理用户事件的 ViewModel 函数的名称以动词开头,该动词基于它们处理的操作,例如: addBookmark(id)logIn(username, password)

处理 ViewModel 事件

源于 ViewModel 的 UI 操作(ViewModel 事件)应始终导致 UI 状态 更新。 这符合 单向数据流 的原则。它使事件在配置更改后可重现,并保证 UI 操作不会丢失。您还可以选择使用 保存状态模块 使事件在进程死亡后可重现。

将 UI 操作映射到 UI 状态并不总是一个简单的过程,但这确实会导致更简单的逻辑。例如,您的思维过程不应以确定如何使 UI 导航到特定屏幕而结束。您需要进一步思考并考虑如何在 UI 状态中表示该用户流程。换句话说:不要考虑 UI 需要执行哪些操作;而要考虑这些操作如何影响 UI 状态。

例如,考虑用户在登录屏幕上登录后导航到主屏幕的情况。您可以在 UI 状态中对其进行如下建模

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

此 UI 对 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.
                    }
                    ...
                }
            }
        }
    }
}

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

使用事件可以触发状态更新

在 UI 中使用某些 ViewModel 事件可能会导致其他 UI 状态更新。例如,当在屏幕上显示瞬态消息以告知用户发生了某些事情时,UI 需要通知 ViewModel 触发另一个状态更新,以便在屏幕上显示消息后。用户使用该消息(通过将其关闭或在超时后)发生的事件可以被视为“用户输入”,因此 ViewModel 应该意识到这一点。在这种情况下,可以将 UI 状态建模如下

// 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 将更新 UI 状态如下

视图

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

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

            // Do something else.
        }
    }

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

ViewModel 不需要知道 UI 如何在屏幕上显示消息;它只知道需要显示用户消息。一旦显示了瞬态消息,UI 需要通知 ViewModel,从而导致另一个 UI 状态更新以清除 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()
                    }
                    ...
                }
            }
        }
    }
}

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

即使消息是瞬态的,UI 状态也是对每个时间点屏幕上显示内容的忠实表示。用户消息要么显示,要么不显示。

使用事件可以触发状态更新 部分中详细介绍了如何使用 UI 状态在屏幕上显示用户消息。导航事件也是 Android 应用程序中常见的事件类型。

如果事件是在 UI 中触发的,因为用户点击了一个按钮,则 UI 会通过调用导航控制器或将事件公开给调用者可组合项来处理该事件。

视图

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

Compose

@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 需要将该状态公开给 UI。UI 会对该状态更改做出反应,并相应地导航。 处理 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.
                    }
                    ...
                }
            }
        }
    }
}

Compose

@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。要实现这一点,需要有额外的状态来指示 UI 是否应该考虑导航到另一个屏幕。通常,该状态保留在 UI 中,因为导航逻辑是 UI 的关注点,而不是 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)
    }
}

Compose

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 会将该逻辑委托给数据层。将用户导航到下一个屏幕的逻辑是UI 逻辑,因为这些要求可能会根据 UI 配置而改变。例如,如果您同时显示多个注册步骤,那么您可能不希望在平板电脑上自动前进到另一个屏幕。上面的代码中的 validationInProgress 变量实现了此功能,并处理了当出生日期有效并且用户想要继续到下一个注册步骤时,UI 是否应该自动导航。

其他用例

如果您认为您的 UI 事件用例无法通过 UI 状态更新来解决,那么您可能需要重新考虑应用程序中数据的流动方式。请考虑以下原则

  • 每个类都应该做它们负责的事情,不要做更多的事情。 UI 负责屏幕特定行为逻辑,例如导航调用、点击事件和获取权限请求。ViewModel 包含业务逻辑,并将来自层次结构较低层的結果转换为 UI 状态。
  • 考虑事件的来源。 遵循本指南开头介绍的 决策树,并使每个类都处理它们负责的事情。例如,如果事件源于 UI,并且它导致导航事件,那么该事件必须在 UI 中处理。某些逻辑可能会委托给 ViewModel,但处理事件不能完全委托给 ViewModel。
  • 如果您有多个使用者,并且您担心事件会被多次使用,那么您可能需要重新考虑应用程序的体系结构。 拥有多个并发使用者会导致完全一次合约变得极其难以保证,因此复杂性和细微行为会激增。如果您遇到此问题,请考虑在 UI 树中向上推送这些问题;您可能需要在层次结构中更高一级的位置使用不同的实体。
  • 考虑何时需要使用状态。 在某些情况下,您可能不希望在应用程序处于后台时继续使用状态,例如,显示 Toast。在这些情况下,请考虑在 UI 处于前台时使用状态。

示例

以下 Google 示例演示了 UI 层中的 UI 事件。请探索它们以实际查看本指南。