UI 事件

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

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

虽然同一个应用在不同移动平台或外形规格上的业务逻辑保持不变,但界面行为逻辑是一个实现细节,在这些情况下可能会有所不同。界面层页面对这些逻辑类型的定义如下:

  • 业务逻辑是指如何处理状态变化,例如进行支付或存储用户偏好设置。领域层和数据层通常处理此逻辑。在本指南中,Architecture Components ViewModel 类被用作处理业务逻辑的类的规定性解决方案。
  • 界面行为逻辑界面逻辑是指如何显示状态变化,例如导航逻辑或如何向用户显示消息。界面处理此逻辑。

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 配合使用时,您就分离了职责。这可确保视图或 RecyclerView 适配器等 UI 特定对象不会直接与 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()
            }
    }
}

在上述示例中,应用按预期工作,因为当前目标(登录)不会保留在返回堆栈中。如果用户按下返回按钮,他们无法返回。但是,在可能发生这种情况的情况下,解决方案将需要额外的逻辑。

当 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 配置而变化。例如,如果您同时显示多个注册步骤,则可能不希望平板电脑自动前进到另一个屏幕。上面代码中的 validationInProgress 变量实现了此功能,并处理了当出生日期有效且用户想要继续到以下注册步骤时,UI 是否应自动导航。

其他用例

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

  • 每个类都应只负责其职责,不多不少。UI 负责屏幕特定的行为逻辑,例如导航调用、点击事件和获取权限请求。ViewModel 包含业务逻辑并将层次结构下层的结果转换为 UI 状态。
  • 考虑事件的来源。遵循本指南开头介绍的决策树,让每个类处理其职责。例如,如果事件源自 UI 并导致导航事件,则该事件必须在 UI 中处理。某些逻辑可能委托给 ViewModel,但事件处理不能完全委托给 ViewModel。
  • 如果您有多个消费者并且担心事件被多次消费,您可能需要重新考虑您的应用架构。拥有多个并发消费者会导致恰好一次交付的契约变得极其难以保证,因此复杂性和微妙行为的数量会猛增。如果您遇到此问题,请考虑将这些关注点推到 UI 树的更上方;您可能需要层次结构中范围更高的不同实体。
  • 考虑何时需要消费状态。在某些情况下,您可能不希望在应用处于后台时继续消费状态,例如显示 Toast。在这些情况下,请考虑在 UI 处于前台时消费状态。

示例

以下 Google 示例演示了 UI 层中的 UI 事件。您可以探索这些示例,了解本指南的实际应用。