UI 事件是应在界面层处理的操作,可由界面或 ViewModel 处理。最常见的事件类型是用户事件。用户通过与应用交互来生成用户事件,例如轻触屏幕或生成手势。然后,界面会使用 onClick()
监听器等回调来使用这些事件。
ViewModel 通常负责处理特定用户事件的业务逻辑,例如用户点击按钮以刷新某些数据。通常,ViewModel 会通过公开可供界面调用的函数来处理此问题。用户事件还可能具有界面可直接处理的界面行为逻辑,例如导航到其他屏幕或显示 Snackbar
。
虽然同一个应用在不同移动平台或外形规格上的业务逻辑保持不变,但界面行为逻辑是一个实现细节,在这些情况下可能会有所不同。界面层页面对这些逻辑类型的定义如下:
- 业务逻辑是指如何处理状态变化,例如进行支付或存储用户偏好设置。领域层和数据层通常处理此逻辑。在本指南中,Architecture Components ViewModel 类被用作处理业务逻辑的类的规定性解决方案。
- 界面行为逻辑或界面逻辑是指如何显示状态变化,例如导航逻辑或如何向用户显示消息。界面处理此逻辑。
UI 事件决策树
下图显示了一个决策树,可帮助您找到处理特定事件用例的最佳方法。本指南的其余部分将详细解释这些方法。

处理用户事件
如果用户事件与修改 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 事件。您可以探索这些示例,了解本指南的实际应用。
为您推荐
- 注意:当 JavaScript 关闭时,会显示链接文本
- UI 层
- 状态持有器和 UI 状态 {:#mad-arch}
- 应用架构指南