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 中的协程或 Flow,以及 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 作为状态变化的来源

对于随时间推移在流中产生多个值的state变化来源,将所有来源的输出聚合到一个连贯的整体中是状态生成的一种简单方法。

使用 Kotlin Flows 时,您可以使用combine函数实现这一点。“现在在 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 state 创建快照流

考虑以下来自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 应用中,您可以选择使用 Views 或 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层生成状态的过程。请浏览这些示例以了解此指南在实践中的应用。