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 公开功能的可能性。当您只允许 Activity 类与 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 事件。请浏览它们以实际了解此指南。