界面事件 (Views)

概念和 Jetpack Compose 实现

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

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

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

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

处理用户事件

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

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

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

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 状态的更改做出响应,并根据需要导航到正确的目的地:

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.
                    }
                    ...
                }
            }
        }
    }
}

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

使用界面中的某些 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() {

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

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

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

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

如果因用户点按某个按钮而在界面中触发了该事件,界面便会通过调用导航控制器来处理该事件。

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
        }
    }
}

如果数据输入需要先进行一些业务逻辑验证,然后才能进行导航,ViewModel 需要将该状态公开给界面。界面会响应该状态变化,并相应地进行导航。“处理 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.
                    }
                    ...
                }
            }
        }
    }
}

在上面的示例中,应用会按预期运行,因为当前的目的地(即“登录”)不会保留在返回堆栈中。用户按“返回”按钮并不能返回。不过,如果可能发生这种情况,解决方案将需要额外的逻辑。

如果 ViewModel 设置了某种状态,使其生成从屏幕 A 到屏幕 B 的导航事件,并且屏幕 A 保留在导航返回堆栈中,您可能需要其他逻辑,以免继续自动进入屏幕 B。为实现这一点,您必须设置其他状态,以指示界面是否应该考虑前往其他屏幕。通常,该状态会保留在界面中,因为导航逻辑与界面有关,而与 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)
    }
}

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