UI 状态生成

现代 UI 很少是静态的。当用户与 UI 交互或当应用需要显示新数据时,UI 状态会发生变化。

本文档规定了 UI 状态生成和管理的指南。在阅读完本文档后,您应该

  • 了解应该使用哪些 API 来生成 UI 状态。这取决于状态持有者中可用的状态更改来源的性质,遵循 单向数据流 原则。
  • 了解如何对 UI 状态生成进行范围限定,以便意识到系统资源。
  • 了解如何公开 UI 状态,以便 UI 可以使用它。

从根本上讲,状态生成是对 UI 状态的这些更改的增量应用。状态始终存在,并且由于事件而发生变化。事件和状态之间的差异总结在下表中

事件 状态
瞬态、不可预测,并且存在有限的时间段。 始终存在。
状态生成的输入。 状态生成的输出。
UI 或其他来源的产物。 被 UI 使用。

一个很好的记忆方法是 状态是;事件发生。下图有助于在时间线上可视化状态的更改,因为事件发生。每个事件都由相应的 状态持有者 处理,并导致状态发生变化

Events vs. state
图 1:事件导致状态发生变化

事件可以来自

  • 用户:当他们与应用的 UI 交互时。
  • 其他状态更改来源:从 UI、域或数据层(如 Snackbar 超时事件、用例或存储库)呈现应用数据的 API。

UI 状态生成管道

Android 应用中的状态生成可以被认为是一个处理管道,包括

  • 输入:状态更改的来源。它们可能是
    • UI 层局部:这些可能是用户事件,例如用户在任务管理应用程序中输入“待办事项”的标题,或者提供对UI 逻辑的访问权限的 API,这些逻辑会驱动 UI 状态的变化。例如,在 Jetpack Compose 中调用open 方法DrawerState
    • UI 层外部:这些是来自域层或数据层的来源,导致 UI 状态发生变化。例如,从NewsRepository 或其他事件中加载完毕的新闻。
    • 以上所有内容的混合。
  • 状态持有者:应用业务逻辑和/或UI 逻辑于状态变化来源并处理用户事件以生成 UI 状态的类型。
  • 输出:应用程序可以渲染以向用户提供所需信息的 UI 状态。
The state production pipeline
图 2:状态生成管道

状态生成 API

根据您所在的管道阶段,状态生成中有两种主要的 API。

管道阶段 API
输入 您应该使用异步 API 在 UI 线程之外执行工作,以保持 UI 的流畅性。例如,Kotlin 中的协程或流,以及 Java 编程语言中的 RxJava 或回调。
输出 您应该使用可观察的数据持有者 API 来使 UI 失效并在状态发生变化时重新渲染。例如,StateFlow、Compose State 或 LiveData。可观察的数据持有者保证 UI 始终具有要显示在屏幕上的 UI 状态。

在这两者中,用于输入的异步 API 的选择对状态生成管道的性质的影响大于用于输出的可观察 API 的选择。这是因为输入决定了可能应用于管道的处理类型

状态生成管道组装

接下来的部分介绍了最适合各种输入的状态生成技术,以及匹配的输出 API。每个状态生成管道都是输入和输出的组合,应

  • 生命周期感知:在 UI 不可见或不活动的情况下,状态生成管道不应消耗任何资源,除非明确要求。
  • 易于使用:UI 应该能够轻松地渲染生成的 UI 状态。对状态生成管道输出的考虑因素将因不同的视图 API 而异,例如视图系统或 Jetpack Compose。

状态生成管道中的输入

状态生成管道中的输入可以通过以下方式提供其状态变化来源:

  • 可能同步或异步的一次性操作,例如对suspend 函数的调用。
  • 流 API,例如Flows
  • 以上所有内容。

以下部分介绍如何为上述每个输入组装状态生成管道。

一次性 API 作为状态变化的来源

使用MutableStateFlow API 作为可观察的、可变的状态容器。在 Jetpack Compose 应用程序中,您还可以考虑mutableStateOf,尤其是在使用Compose 文本 API 时。这两种 API 都提供了方法,允许对它们托管的值进行安全的原子更新,无论更新是同步的还是异步的。

例如,考虑一个简单的掷骰子应用程序中的状态更新。用户每次掷骰都会调用同步的Random.nextInt() 方法,并将结果写入 UI 状态。

StateFlow

data class DiceUiState(
    val firstDieValue: Int? = null,
    val secondDieValue: Int? = null,
    val numberOfRolls: Int = 0,
)

class DiceRollViewModel : ViewModel() {

    private val _uiState = MutableStateFlow(DiceUiState())
    val uiState: StateFlow<DiceUiState> = _uiState.asStateFlow()

    // Called from the UI
    fun rollDice() {
        _uiState.update { currentState ->
            currentState.copy(
            firstDieValue = Random.nextInt(from = 1, until = 7),
            secondDieValue = Random.nextInt(from = 1, until = 7),
            numberOfRolls = currentState.numberOfRolls + 1,
            )
        }
    }
}

Compose State

@Stable
interface DiceUiState {
    val firstDieValue: Int?
    val secondDieValue: Int?
    val numberOfRolls: Int?
}

private class MutableDiceUiState: DiceUiState {
    override var firstDieValue: Int? by mutableStateOf(null)
    override var secondDieValue: Int? by mutableStateOf(null)
    override var numberOfRolls: Int by mutableStateOf(0)
}

class DiceRollViewModel : ViewModel() {

    private val _uiState = MutableDiceUiState()
    val uiState: DiceUiState = _uiState

    // Called from the UI
    fun rollDice() {
        _uiState.firstDieValue = Random.nextInt(from = 1, until = 7)
        _uiState.secondDieValue = Random.nextInt(from = 1, until = 7)
        _uiState.numberOfRolls = _uiState.numberOfRolls + 1
    }
}

从异步调用中更改 UI 状态

对于需要异步结果的状态更改,请在适当的CoroutineScope 中启动协程。这允许应用程序在CoroutineScope 被取消时丢弃工作。然后,状态持有者将挂起方法调用的结果写入用于公开 UI 状态的可观察 API。

例如,考虑架构示例 中的AddEditTaskViewModel。当挂起的saveTask() 方法异步保存任务时,update 方法在 MutableStateFlow 上将状态更改传播到 UI 状态。

StateFlow

data class AddEditTaskUiState(
    val title: String = "",
    val description: String = "",
    val isTaskCompleted: Boolean = false,
    val isLoading: Boolean = false,
    val userMessage: String? = null,
    val isTaskSaved: Boolean = false
)

class AddEditTaskViewModel(...) : ViewModel() {

   private val _uiState = MutableStateFlow(AddEditTaskUiState())
   val uiState: StateFlow<AddEditTaskUiState> = _uiState.asStateFlow()

   private fun createNewTask() {
        viewModelScope.launch {
            val newTask = Task(uiState.value.title, uiState.value.description)
            try {
                tasksRepository.saveTask(newTask)
                // Write data into the UI state.
                _uiState.update {
                    it.copy(isTaskSaved = true)
                }
            }
            catch(cancellationException: CancellationException) {
                throw cancellationException
            }
            catch(exception: Exception) {
                _uiState.update {
                    it.copy(userMessage = getErrorMessage(exception))
                }
            }
        }
    }
}

Compose State

@Stable
interface AddEditTaskUiState {
    val title: String
    val description: String
    val isTaskCompleted: Boolean
    val isLoading: Boolean
    val userMessage: String?
    val isTaskSaved: Boolean
}

private class MutableAddEditTaskUiState : AddEditTaskUiState() {
    override var title: String by mutableStateOf("")
    override var description: String by mutableStateOf("")
    override var isTaskCompleted: Boolean by mutableStateOf(false)
    override var isLoading: Boolean by mutableStateOf(false)
    override var userMessage: String? by mutableStateOf<String?>(null)
    override var isTaskSaved: Boolean by mutableStateOf(false)
}

class AddEditTaskViewModel(...) : ViewModel() {

   private val _uiState = MutableAddEditTaskUiState()
   val uiState: AddEditTaskUiState = _uiState

   private fun createNewTask() {
        viewModelScope.launch {
            val newTask = Task(uiState.value.title, uiState.value.description)
            try {
                tasksRepository.saveTask(newTask)
                // Write data into the UI state.
                _uiState.isTaskSaved = true
            }
            catch(cancellationException: CancellationException) {
                throw cancellationException
            }
            catch(exception: Exception) {
                _uiState.userMessage = getErrorMessage(exception))
            }
        }
    }
}

从后台线程更改 UI 状态

最好在主调度程序上启动协程以生成 UI 状态。也就是说,在下面代码段中的withContext 块之外。但是,如果您需要在不同的后台上下文中更新 UI 状态,您可以使用以下 API

  • 使用withContext 方法在不同的并发上下文中运行协程。
  • 使用MutableStateFlow 时,照常使用update 方法。
  • 使用 Compose State 时,使用Snapshot.withMutableSnapshot 来保证在并发上下文中的 State 的原子更新。

例如,假设在下面的DiceRollViewModel 代码段中,SlowRandom.nextInt() 是一个需要从 CPU 绑定的协程调用的计算密集型suspend 函数。

StateFlow

class DiceRollViewModel(
    private val defaultDispatcher: CoroutineScope = Dispatchers.Default
) : ViewModel() {

    private val _uiState = MutableStateFlow(DiceUiState())
    val uiState: StateFlow<DiceUiState> = _uiState.asStateFlow()

  // Called from the UI
  fun rollDice() {
        viewModelScope.launch() {
            // Other Coroutines that may be called from the current context
            …
            withContext(defaultDispatcher) {
                _uiState.update { currentState ->
                    currentState.copy(
                        firstDieValue = SlowRandom.nextInt(from = 1, until = 7),
                        secondDieValue = SlowRandom.nextInt(from = 1, until = 7),
                        numberOfRolls = currentState.numberOfRolls + 1,
                    )
                }
            }
        }
    }
}

Compose State

class DiceRollViewModel(
    private val defaultDispatcher: CoroutineScope = Dispatchers.Default
) : ViewModel() {

    private val _uiState = MutableDiceUiState()
    val uiState: DiceUiState = _uiState

  // Called from the UI
  fun rollDice() {
        viewModelScope.launch() {
            // Other Coroutines that may be called from the current context
            …
            withContext(defaultDispatcher) {
                Snapshot.withMutableSnapshot {
                    _uiState.firstDieValue = SlowRandom.nextInt(from = 1, until = 7)
                    _uiState.secondDieValue = SlowRandom.nextInt(from = 1, until = 7)
                    _uiState.numberOfRolls = _uiState.numberOfRolls + 1
                }
            }
        }
    }
}

流 API 作为状态变化的来源

对于随着时间推移以流的形式生成多个值的狀態變化的來源,將所有來源的輸出彙總成一個整體是狀態生成的一種直接方法。

使用 Kotlin Flows 时,您可以使用combine 函数实现这一点。在 “Now in Android” 示例 中的 InterestsViewModel 中可以看到此示例。

class InterestsViewModel(
    authorsRepository: AuthorsRepository,
    topicsRepository: TopicsRepository
) : ViewModel() {

    val uiState = combine(
        authorsRepository.getAuthorsStream(),
        topicsRepository.getTopicsStream(),
    ) { availableAuthors, availableTopics ->
        InterestsUiState.Interests(
            authors = availableAuthors,
            topics = availableTopics
        )
    }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = InterestsUiState.Loading
    )
}

使用stateIn 运算符创建StateFlows 使 UI 对状态生成管道的活动有更细粒度的控制,因为它可能只需要在 UI 可见时才处于活动状态。

  • 如果管道应该仅在 UI 可见时处于活动状态,同时以生命周期感知的方式收集流,请使用SharingStarted.WhileSubscribed()
  • 如果管道应该在用户可能返回到 UI 时处于活动状态,即 UI 位于回退堆栈中或在屏幕外的另一个选项卡中,请使用SharingStarted.Lazily

在汇总基于流的狀態變化的來源不适用的情况下,Kotlin Flows 等流 API 提供了一套丰富的转换,例如合并扁平化等等,以帮助将流处理成 UI 状态。

一次性 API 和流 API 作为状态变化的来源

在状态生成管道依赖于一次性调用和流作为状态变化来源的情况下,流是决定性的约束。因此,将一次性调用转换为流 API,或将其输出管道到流中,并如上文流部分所述继续处理。

使用流时,这通常意味着创建一到多个私有的MutableStateFlow 实例以传播状态更改。您还可以从 Compose 状态创建快照流

考虑以下来自architecture-samples 存储库的TaskDetailViewModel

StateFlow

class TaskDetailViewModel @Inject constructor(
    private val tasksRepository: TasksRepository,
    savedStateHandle: SavedStateHandle
) : ViewModel() {

    private val _isTaskDeleted = MutableStateFlow(false)
    private val _task = tasksRepository.getTaskStream(taskId)

    val uiState: StateFlow<TaskDetailUiState> = combine(
        _isTaskDeleted,
        _task
    ) { isTaskDeleted, task ->
        TaskDetailUiState(
            task = taskAsync.data,
            isTaskDeleted = isTaskDeleted
        )
    }
        // Convert the result to the appropriate observable API for the UI
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = TaskDetailUiState()
        )

    fun deleteTask() = viewModelScope.launch {
        tasksRepository.deleteTask(taskId)
        _isTaskDeleted.update { true }
    }
}

Compose State

class TaskDetailViewModel @Inject constructor(
    private val tasksRepository: TasksRepository,
    savedStateHandle: SavedStateHandle
) : ViewModel() {

    private var _isTaskDeleted by mutableStateOf(false)
    private val _task = tasksRepository.getTaskStream(taskId)

    val uiState: StateFlow<TaskDetailUiState> = combine(
        snapshotFlow { _isTaskDeleted },
        _task
    ) { isTaskDeleted, task ->
        TaskDetailUiState(
            task = taskAsync.data,
            isTaskDeleted = isTaskDeleted
        )
    }
        // Convert the result to the appropriate observable API for the UI
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = TaskDetailUiState()
        )

    fun deleteTask() = viewModelScope.launch {
        tasksRepository.deleteTask(taskId)
        _isTaskDeleted = true
    }
}

状态生成管道中的输出类型

用于 UI 状态的输出 API 的选择及其呈现方式很大程度上取决于应用程序用于渲染 UI 的 API。在 Android 应用程序中,您可以选择使用视图或 Jetpack Compose。这里需要注意的是:

下表总结了对于任何给定的输入和使用者,您的状态生成管道应使用哪些 API。

输入 使用者 输出
一次性 API 视图 StateFlowLiveData
一次性 API Compose StateFlow 或 Compose State
流 API 视图 StateFlowLiveData
流 API Compose StateFlow
一次性 API 和流 API 视图 StateFlowLiveData
一次性 API 和流 API Compose StateFlow

状态生成管道初始化

初始化状态生产管道涉及设置管道运行的初始条件。这可能包括提供对管道启动至关重要的初始输入值,例如新闻文章详细信息视图的id,或启动异步加载。

如果可能,应延迟初始化状态生产管道以节省系统资源。实际上,这通常意味着等待有输出的使用者。Flow API 允许通过 started 参数在 stateIn 方法中实现这一点。在不适用此方法的情况下,定义一个 幂等 initialize() 函数以显式启动状态生产管道,如下面的代码片段所示

class MyViewModel : ViewModel() {

    private var initializeCalled = false

    // This function is idempotent provided it is only called from the UI thread.
    @MainThread
    fun initialize() {
        if(initializeCalled) return
        initializeCalled = true

        viewModelScope.launch {
            // seed the state production pipeline
        }
    }
}

示例

以下 Google 示例演示了在 UI 层中生成状态。探索它们以实际了解这些指导