界面事件

“界面事件”是指应由界面或 ViewModel 在界面层处理的操作。最常见的事件类型是“用户事件”。用户通过与应用互动(例如,点按屏幕或生成手势)来生成用户事件。随后,界面会使用在不同可组合项上定义的 lambda 等回调来使用这些事件。

ViewModel 通常负责处理特定用户事件的业务逻辑。例如,用户点击某个按钮以刷新部分数据。ViewModel 通常通过公开界面可以调用的函数来处理这种情况。用户事件可能还有界面可以直接处理的界面行为逻辑。例如转到其他屏幕或显示 Snackbar

虽然同一应用的业务逻辑在不同移动平台或设备类型上保持不变,但界面行为逻辑在实现方面可能有所不同。界面层页定义了这些类型的逻辑,如下所示:

  • 业务逻辑是指如何处理状态更改,例如付款或存储用户偏好设置。网域和数据层通常负责处理此逻辑。在本指南中,架构组件 ViewModel 类用作处理业务逻辑的类的特色解决方案。
  • 界面行为逻辑(即界面逻辑)是指如何显示状态更改,例如导航逻辑或如何向用户显示消息。界面会处理此逻辑。

界面事件决策树

下图这个决策树展示了如何寻找处理特定事件使用场景的最佳实践。本指南的其余部分将详细介绍这些方法。

如果事件源自 ViewModel,则更新界面状态。如果事件源自界面并需要业务逻辑,则将业务逻辑委托给 ViewModel。如果事件源自界面并需要界面行为逻辑,则直接在界面中修改界面元素状态。
图 1. 事件处理决策树。

处理用户事件

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

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

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

延迟列表中的用户事件

如果操作是在界面树中比较靠下一层生成的,例如在 LazyColumn 项中,ViewModel 应仍是处理用户事件的操作。

例如,假设有一个可点击项的列表。请勿将 ViewModel 实例传递到列表可组合项 (MyList) 中,因为这会将界面组件与实现细节紧密耦合。

而是将该事件作为可组合项中的 lambda 函数形参公开。这样,列表就可以触发事件,而无需知道谁或如何处理该事件。

data class MyItem(val id: Int)

@Composable
fun MyList(
    items: List<String>,
    onItemClick: (MyItem) -> Unit
) {
    Card {
        LazyColumn {
            itemsIndexed(items) { index, string ->
                ListItem(
                    modifier = Modifier.clickable {
                        onItemClick(MyItem(index))
                    },
                    headlineContent = {
                        Text(text = string)
                    }
                )
            }
        }
    }
}

在此方法中,MyList 可组合项仅使用其显示的数据和公开的事件。它无法访问 ViewModel。该事件会被提升并传递给之前可组合项中的 ViewModel。

如需详细了解事件处理,请参阅 Compose 中的事件

用户事件函数和事件处理脚本的命名惯例

在本指南中,用于处理用户事件的 ViewModel 函数根据其处理的操作(例如,validateInput()login())以动词命名。

Compose 中的事件处理函数遵循标准命名惯例,以使数据流清晰明了:

  • 形参名称on + Verb + Target(例如 onExpandClickedonValueChange)。
  • Lambda 表达式:在调用可组合项时,lambda 通常只是相应事件的实现。

处理 ViewModel 事件

源自 ViewModel 的界面操作(ViewModel 事件)应始终引发界面状态更新。这符合单向数据流的原则。让事件在配置更改后可重现,并保证界面操作不会丢失。如果您使用已保存的状态模块,则还可以让事件在进程终止后可重现(可选操作)。

将界面操作映射到界面状态并不总是一个简单的过程,但确实可以简化逻辑。例如,您不单单要想办法确定如何将界面导航到特定屏幕,还需要进一步思考如何在界面状态中表示该用户流。换句话说:不需要考虑界面需要执行哪些操作,而是要思考这些操作会对界面状态造成什么影响。

例如,考虑登录屏幕的情况。您可以按如下方式为此界面的界面状态建模:

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

登录界面会响应界面状态的变化。

class LoginViewModel : ViewModel() {

    var uiState by mutableStateOf(LoginUiState())

    fun tryLogin(username: String, password: String) {
        viewModelScope.launch {
            // Emit a new state indicating that login is in progress
            uiState = uiState.copy(isLoginInProgress = true)

            uiState = if (login(username, password)) {
                // Emit a new state indicating that login was successful
                uiState.copy(isLoginInProgress = false, isUserLoggedIn = true)
            } else {
                // Emit a new state with the error message
                LoginUiState(isLoginInProgress = false, errorMessage = "Login failed")
            }
        }
    }

    private suspend fun login(username: String, password: String): Boolean {
        delay(1000)
        return (username == "Hello" && password == "World!")
    }
}

@Composable
fun LoginScreen(viewModel: LoginViewModel, onSuccessfulLogin: () -> Unit) {

    val uiState = viewModel.uiState

    LaunchedEffect(uiState) {
        if (uiState.isUserLoggedIn) {
            onSuccessfulLogin()
        }
    }

    if (uiState.isLoginInProgress) {
        CircularProgressIndicator()
    } else {
        LoginForm(
            onLoginAttempt = { username, password ->
                viewModel.tryLogin(username, password)
            },
            errorMessage = uiState.errorMessage
        )
    }
}

使用事件可能会触发状态更新

使用界面中的某些 ViewModel 事件可能会引发其他界面状态更新。例如,当屏幕上显示瞬时消息以告知用户发生的情况时,界面需要通知 ViewModel 以在消息显示于屏幕上时触发另一状态更新。用户处理消息(通过关闭消息或超时)后发生的事件可被视为“用户输入”,因此 ViewModel 应该知道这一点。在这种情况下,界面状态可按以下方式建模:

// 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 会更新界面状态,如下所示:

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 不需要知道界面如何在屏幕上显示消息;只需要知道有一条用户消息需要显示。显示瞬时消息后,界面需要通知 ViewModel,这会引发另一个界面状态更新并清除 userMessage 属性:

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

尽管消息是瞬态的,但界面状态能够忠实反映每个时间点屏幕上显示的内容。用户消息要么显示,要么不显示。

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

如果因用户点按某个按钮而在界面中触发了该事件,界面便会通过将该事件公开给调用方可组合项来处理该事件。

@Composable
fun LoginScreen(
    onHelp: () -> Unit, // Caller navigates to the help screen
    viewModel: LoginViewModel = viewModel()
) {
    // Rest of the UI
    Button(
        onClick = dropUnlessResumed { onHelp() }
    ) {
        Text("Get help")
    }
}

dropUnlessResumed 是生命周期库的一部分,可让您仅在生命周期至少处于 RESUMED 状态时运行 onHelp 函数。

如果数据输入需要先进行一些业务逻辑验证,然后才能进行导航,ViewModel 需要将该状态公开给界面。界面会响应该状态变化,并相应地进行导航。“处理 ViewModel 事件”部分介绍了这种用例。类似代码如下:

@Composable
fun LoginScreen(
    onUserLogIn: () -> Unit, // Caller navigates to the right screen
    viewModel: LoginViewModel = viewModel()
) {
    Button(
        onClick = {
            // ViewModel validation is triggered
            viewModel.tryLogin()
        }
    ) {
        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。为实现这一点,您需要设置其他状态,以指示界面是否应该前往其他屏幕。通常,该状态会保留在界面中,因为导航逻辑与界面有关,而与 ViewModel 无关。为了说明这一点,我们来看以下用例。

假设您已进入应用的注册流程。在“出生日期”验证屏幕中,如果用户输入某个日期,当用户点按“继续”按钮时,ViewModel 会验证该日期。ViewModel 会将相应验证逻辑委托给数据层。如果日期有效,用户会进入下一个屏幕。作为一项额外功能,用户可以在不同的注册屏幕之间来回切换,以便在想要更改某些数据时能够进行所需的操作。因此,注册流程中的所有目的地都保留在同一个返回堆栈中。根据这些要求,您可以按如下方式实现此屏幕:

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

其他用例

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

  • 每个类都应各司其职,不能越界。界面负责屏幕专属行为逻辑,例如导航调用、点击事件以及获取权限请求。ViewModel 包含业务逻辑,并将结果从层次结构的较低层转换为界面状态。
  • 考虑事件的发起点。请遵循本指南开头介绍的决策树,并让每个类各司其职。例如,如果事件源自界面并导致出现导航事件,则必须在界面中处理该事件。某些逻辑可能会委托给 ViewModel,但事件的处理无法完全委托给 ViewModel。
  • 如果事件有多个使用方,则当您对某个事件会被使用多次而感到担忧时,可能需要重新考虑您的应用架构。 同时有多个使用方会导致“恰好交付一次”协定变得非常难以保证,因此复杂性和细微行为的数量也会急剧增加。如果您遇到此问题,不妨考虑提升这些问题在界面树上的层级;您可能需要在层次结构中较高层级设定其他实体。
  • 考虑何时需要使用状态。在某些情况下,您可能不想在应用处于后台时保留使用状态(例如显示 Toast)。在这些情况下,请考虑在界面位于前台时使用状态。

示例

以下 Google 示例演示了界面层中的界面事件。请查看这些示例,了解如何实际运用本指南:

其他资源

如需详细了解界面事件,请参阅以下其他资源:

Codelab

文档

查看内容