“界面事件”是指应由界面或 ViewModel 在界面层处理的操作。最常见的事件类型是“用户事件”。 用户通过与应用互动(例如,点按屏幕或生成手势)来生成用户事件。随后,界面会使用 onClick()
监听器等回调来使用这些事件。
ViewModel 通常负责处理特定用户事件的业务逻辑。例如,用户点击某个按钮以刷新部分数据。ViewModel 通常通过公开界面可以调用的函数来处理这种情况。用户事件可能还有界面可以直接处理的界面行为逻辑。例如转到其他屏幕或显示 Snackbar
。
虽然同一应用的业务逻辑在不同移动平台或设备类型上保持不变,但界面行为逻辑在实现方面可能有所不同。界面层页定义了这些类型的逻辑,如下所示:
- 业务逻辑是指如何处理状态更改,例如付款或存储用户偏好设置。网域和数据层通常负责处理此逻辑。在本指南中,架构组件 ViewModel 类用作处理业务逻辑的类的特色解决方案。
- 界面行为逻辑(即界面逻辑)是指如何显示状态更改,例如导航逻辑或如何向用户显示消息。界面会处理此逻辑。
界面事件决策树
下图这个决策树展示了如何寻找处理特定事件使用场景的最佳实践。本指南的其余部分将详细介绍这些方法。

处理用户事件
如果用户事件与修改界面元素的状态(如可展开项的状态)相关,界面便可以直接处理这些事件。如果事件需要执行业务逻辑(如刷新屏幕上的数据),则应用由 ViewModel 处理此事件。
以下示例展示了如何使用不同的按钮来展开界面元素(界面逻辑)和刷新屏幕上的数据(业务逻辑):
View
class LatestNewsActivity : AppCompatActivity() {
private lateinit var binding: ActivityLatestNewsBinding
private val viewModel: LatestNewsViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
/* ... */
// The expand section 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 NewsApp() {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = "latestNews") {
composable("latestNews") {
LatestNewsScreen(
// The navigation event is processed by calling the NavController
// navigate function that mutates its internal state.
onProfileClick = { navController.navigate("profile") }
)
}
/* ... */
}
}
@Composable
fun LatestNewsScreen(
viewModel: LatestNewsViewModel = viewModel(),
onProfileClick: () -> Unit
) {
Column {
// 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")
}
Button(onClick = onProfileClick) {
Text("Profile")
}
}
}
RecyclerView 中的用户事件
如果操作是在界面树中比较靠下一层生成的,例如在 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
适配器)不会直接与 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
状态的更改做出响应,并根据需要导航到正确的目的地:
View
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.
}
使用事件可能会触发状态更新
使用界面中的某些 ViewModel 事件可能会引发其他界面状态更新。例如,当屏幕上显示瞬时消息以告知用户发生的情况时,界面需要通知 ViewModel 以在消息显示在屏幕上时触发另一状态更新。该界面状态可按以下方式建模:
// Models the message to show on the screen.
data class UserMessage(val id: Long, val message: String)
// Models the UI state for the Latest news screen.
data class LatestNewsUiState(
val news: List<News> = emptyList(),
val isLoading: Boolean = false,
val userMessages: List<UserMessage> = emptyList()
)
当业务逻辑需要向用户显示新的瞬时消息时,ViewModel 会更新界面状态,如下所示:
View
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 ->
val messages = currentUiState.userMessages + UserMessage(
id = UUID.randomUUID().mostSignificantBits,
message = "No Internet connection"
)
currentUiState.copy(userMessages = messages)
}
return@launch
}
// Do something else.
}
}
fun userMessageShown(messageId: Long) {
_uiState.update { currentUiState ->
val messages = currentUiState.userMessages.filterNot { it.id == messageId }
currentUiState.copy(userMessages = messages)
}
}
}
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()) {
val messages = uiState.userMessages + UserMessage(
id = UUID.randomUUID().mostSignificantBits,
message = "No Internet connection"
)
uiState = uiState.copy(userMessages = messages)
return@launch
}
// Do something else.
}
}
fun userMessageShown(messageId: Long) {
val messages = uiState.userMessages.filterNot { it.id == messageId }
uiState = uiState.copy(userMessages = messages)
}
}
ViewModel 不需要知道界面如何在屏幕上显示消息;只需要知道有一条用户消息需要显示。显示瞬时消息后,界面需要通知 ViewModel,这会引发另一个界面状态更新:
View
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.userMessages.firstOrNull()?.let { userMessage ->
// TODO: Show Snackbar with userMessage.
// Once the message is displayed and
// dismissed, notify the ViewModel.
viewModel.userMessageShown(userMessage.id)
}
...
}
}
}
}
}
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 the first one and notify the ViewModel.
viewModel.uiState.userMessages.firstOrNull()?.let { userMessage ->
LaunchedEffect(userMessage) {
snackbarHostState.showSnackbar(userMessage.message)
// Once the message is displayed and dismissed, notify the ViewModel.
viewModel.userMessageShown(userMessage.id)
}
}
}
其他用例
如果您认为界面事件用例无法通过界面状态更新得以解决,可能需要重新考虑数据在应用中的流动方式。请考虑以下原则:
- 每个类都应各司其职,不能越界。界面负责屏幕专属行为逻辑,例如导航调用、点击事件以及获取权限请求。ViewModel 包含业务逻辑,并将结果从层次结构的较低层转换为界面状态。
- 考虑事件的发起点。请遵循本指南开头介绍的决策树,并让每个类各司其职。例如,如果事件源自界面并导致出现导航事件,则必须在界面中处理该事件。某些逻辑可能会委托给 ViewModel,但事件的处理无法完全委托给 ViewModel。
- 如果事件有多个使用方,则当您对某个事件会被使用多次而感到担忧时,可能需要重新考虑您的应用架构。 同时有多个使用方会导致“恰好交付一次”协定变得非常难以保证,因此复杂性和细微行为的数量也会急剧增加。如果您遇到此问题,不妨考虑在界面树的上层这些问题;您可能需要在层次结构中较高层级设定其他实体。
- 考虑何时需要使用状态。在某些情况下,您可能不想在应用处于后台时保留使用状态(例如显示
Toast
)。在这些情况下,请考虑在界面位于前台时使用状态。