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 逻辑驱动 UI 状态的变化。例如,在 Jetpack Compose 中调用 DrawerState 上的 open 方法。
    • UI 层外部:这些是来自领域或数据层导致 UI 状态变化的来源。例如,从 NewsRepository 加载完成的新闻或其他事件。
    • 以上所有情况的混合。
  • 状态容器:将业务逻辑和/或UI 逻辑应用于状态变化来源并处理用户事件以生成 UI 状态的类型。
  • 输出:应用可以渲染以向用户提供所需信息的 UI 状态。
The state production pipeline
图 2:状态生成流水线

状态生成 API

状态生成中主要使用两种 API,具体取决于您所处的流水线阶段:

流水线阶段 API
输入 您应该使用异步 API 在 UI 线程之外执行工作,以避免 UI 卡顿。例如,Kotlin 中的协程或 Flow,以及 Java 编程语言中的 RxJava 或回调。
输出 您应该使用可观察数据容器 API 在状态变化时使 UI 失效并重新渲染。例如,StateFlow、Compose State 或 LiveData。可观察数据容器保证 UI 始终有 UI 状态可在屏幕上显示。

两者之中,输入异步 API 的选择对状态生成流水线的性质影响更大,而非输出可观察 API 的选择。这是因为输入决定了可以应用于流水线的处理类型

状态生成流水线组装

接下来的章节将介绍最适合各种输入的状态生成技术,以及匹配的输出 API。每个状态生成流水线都是输入和输出的组合,应具备以下特点:

  • 感知生命周期:在 UI 不可见或不活跃的情况下,除非明确要求,否则状态生成流水线不应消耗任何资源。
  • 易于使用:UI 应该能够轻松渲染生成的 UI 状态。状态生成流水线的输出考虑因素会因不同的 View API(如 View 系统或 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 取消时丢弃工作。然后,状态容器会将 suspend 方法调用的结果写入用于公开 UI 状态的可观察 API 中。

例如,考虑架构示例中的 AddEditTaskViewModel。当 suspend saveTask() 方法异步保存任务时,MutableStateFlow 上的 update 方法会将状态变化传播到 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() 是一个计算密集型 suspend 函数,需要从 CPU 绑定的协程调用。

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 Flow 时,您可以使用 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 Flow 等流式 API 提供了一组丰富的转换功能,例如合并展平等,以帮助将流处理为 UI 状态。

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

如果状态生成流水线同时依赖于一次性调用和流作为状态变化的来源,那么流是决定性约束。因此,将一次性调用转换为流式 API,或将其输出导入流中,然后按照上述流部分中的描述恢复处理。

对于 Flow,这通常意味着创建一个或多个私有支持的 MutableStateFlow 实例来传播状态变化。您还可以从 Compose 状态创建 snapshot flow

请考虑以下来自 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 应用中,您可以选择使用 View 或 Jetpack Compose。这里的考虑因素包括:

下表总结了针对任何给定输入和消费者,您的状态生成流水线应使用的 API:

输入 消费者 输出
一次性 API View StateFlowLiveData
一次性 API Compose StateFlow 或 Compose State
流式 API View StateFlowLiveData
流式 API Compose StateFlow
一次性 API 和流式 API View StateFlowLiveData
一次性 API 和流式 API Compose StateFlow

状态生成流水线初始化

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

您应该尽可能延迟初始化状态生成流水线,以节省系统资源。实际上,这通常意味着等待直到有输出的消费者。 Flow API 允许通过 stateIn 方法中的 started 参数来实现这一点。在不适用此方法的情况下,定义一个幂等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 层中状态的生成。您可以探索它们,以了解这些指导原则的实际应用: