界面状态生成

现在,静态界面已十分罕见。当用户与界面互动或应用需要显示新数据时,界面状态会发生变化。

本文档将介绍界面状态的生成和管理指南。阅读完本文档后,您应能够:

  • 了解应使用哪些 API 来生成界面状态。这取决于状态容器 (state holder) 中可用的状态变化来源的性质,并遵循单向数据流原则。
  • 了解您应该如何限定界面状态生成的作用域,以便密切注意系统资源。
  • 了解应该如何公开界面状态以供界面使用。

从根本上说,状态生成是将这些变化逐步应用于界面状态的过程。状态始终存在,并且会随着事件而发生变化。下表总结了事件和状态之间的差异:

事件 状态
暂时性、不可预测,且存在时间有限。 始终存在。
状态生成的输入。 状态生成的输出。
界面或其他来源的生成对象。 供界面使用。

以下总结可以很好地帮助您记住二者的区别:状态始终存在,而事件则不时发生。下图以时间轴的形式直观展示了在事件发生时的状态变化。每个事件都由适当的状态容器进行处理,这会导致状态发生变化:

事件与状态
图 1:事件导致状态发生变化

事件可能来自以下来源:

  • 用户:当用户与应用界面进行互动时。
  • 状态变化的其他来源:呈现来自界面层、网域层或数据层的应用数据(示例分别为信息条超时事件、用例或仓库)的 API。

界面状态生成流水线

Android 应用中的状态生成可以理解为一种处理流水线,其中包含:

  • 输入:状态变化的来源。这些来源可能包括:
    • 界面层本地:可能是用户事件(例如用户在任务管理应用中输入“待办事项”的标题),也可能是提供对界面逻辑的访问权限的 API,这些事件/API 会导致界面状态发生变化。例如,在 Jetpack Compose 中对 DrawerState 调用 open 方法。
    • 界面层外部:这些来源来自网域层或数据层,并会导致界面状态发生变化。例如,从 NewsRepository 或其他事件完成加载的新闻。
    • 以上各项兼而有之。
  • 状态容器:用于将业务逻辑和/或界面逻辑应用于状态变化来源并处理用户事件以生成界面状态的类型。
  • 输出:应用可以呈现以向用户提供所需信息的界面状态。
状态生成流水线
图 2:状态生成流水线

状态生成 API

状态生成过程主要用到两个 API,具体取决于您处于流水线的哪个阶段:

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

在这两者中,与对用于输出的可观测 API 的选择相比,对用于输入的异步 API 的选择对于状态生成流水线的性质具有更大的影响。这是因为输入决定了可能应用于流水线的处理的类型

状态生成流水线组建

后续部分将介绍各种输入最适合采用的状态生成技术,以及匹配的输出 API。每个状态生成流水线都是输入和输出的组合,并应满足以下条件:

  • 可感知生命周期:如果界面不可见或未处于活动状态,除非明确要求,否则状态生成流水线不应消耗任何资源。
  • 易于使用:界面应能够轻松呈现生成的界面状态。状态生成流水线输出的相关注意事项因不同的 View API(例如 View 系统或 Jetpack Compose)而异。

状态生成流水线中的输入

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

  • 可能是同步或异步的一次性操作,例如对 suspend 函数的调用。
  • 流 API,例如 Flows
  • 以上二者兼用。

以下各部分介绍了如何为上述每项输入组建状态生成流水线。

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

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

以简单的掷骰子应用中的状态更新为例。用户每次掷骰子都会调用同步 Random.nextInt() 方法,并且系统会将结果写入界面状态。

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

通过异步调用更改界面状态

对于需要异步结果的状态更改,请在相应的 CoroutineScope 中启动协程。这样,在 CoroutineScope 取消后,应用便会舍弃相应工作。然后,状态容器会将挂起方法调用的结果写入用于公开界面状态的可观测 API。

架构示例中的 AddEditTaskViewModel 为例。当挂起的 saveTask() 方法异步保存任务时,MutableStateFlow 上的 update 方法会将状态变化传播到界面状态。

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

通过后台线程更改界面状态

最好在主调度程序上启动协程以生成界面状态。也就是说,在以下代码段中的 withContext 代码块之外。不过,如果您需要在其他后台上下文中更新界面状态,可以通过使用以下 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 函数来实现此目的。您可在 InterestsViewModel 中的“Now in Android”示例中查看示例:

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,可使界面能够更精细地控制状态生成流水线的活动,因为它可能只需在界面可见时才处于活动状态。

  • 如果仅当界面可见,同时正在以生命周期感知方式收集数据流时,流水线才应处于活动状态,则使用 SharingStarted.WhileSubscribed()
  • 如果只要用户可能返回界面(即界面位于后台堆栈上或屏幕外的其他标签页中),流水线就应该处于活动状态,则使用 SharingStarted.Lazily

如果聚合基于数据流的状态来源不适用,Kotlin Flow 等流 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
    }
}

状态生成流水线中的输出类型

界面状态的输出 API 的选择及其呈现方式的性质在很大程度上取决于应用用于呈现界面的 API。在 Android 应用中,您可以选择使用 View 或 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 通过 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 示例演示了界面层中的状态生成过程。请查看这些示例,了解如何实际运用本指南: