采用这种“要做什么”风格的编程通常被称为声明式编程。你制定规则,给出了希望实现的目标,让系统来决定如何实现这个目标。 它带来的好处非常明显,用这种方式编写的代码更加接近问题陈述了。——《Java8实战》
前文介绍了 UI State、State Holder 的概念,接下来在本文中,将学习 UI State 的生成机制,主要包含 UI State 的输入及输出流水线,使用哪几个 API 函数生成 UI State,如何控制 UI State 的 Scope,以及 UI State 的消费方式。
State 与 Event 的区别
从概念上,State 表示一种 状态,是持续性的,而 Event 的含义是 事件,具备瞬时性。我们可以说当前 APP 处于哪种状态,发生了什么事件,而不能倒过来说。
两者的区别概括如下表:
Events | State |
---|---|
短暂发生 | 持续存在 |
作为 UI State 的触发者 | 作为 UI State 的产生物 |
UI 或其它来源的产物 | 被 UI 消费 |
处于某种状态,发生某个事件,下表展示了两者在时间轴上的直观形式,Event 被 State Holder 处理后,引发应用 State 的变更。
Events 的来源有两种:
- 用户操作: 当用户操作 UI 进行交互时,会产生事件。
- 状态变化: Events 引发 State 变更,而 State 变更同样会产生 Events。例如 Snackbar 超时事件、网络接口返回。所有非用户交互产生的 Events 都可以归入此类。
UI State 生成流水线
如上图所示,UI State 的生成,按照逻辑顺序,依次是 输入原始数据 ===> 处理器(State Holder)处理 ===> 产生 UI State。
- 输入: 用户输入、非用户输入。
- State Holder: 处理业务逻辑、UI 逻辑。
- 输出: 能够被 UI 直接消费的 State 对象。
由于前一篇文章已经讲解了 State Holder, 本文接下来重点剖析 输入、输出 两部分。
API 的选择
流水线阶段 | API 选择 |
---|---|
输入 | 使用 异步 API 在 UI 线程以外执行耗时操作,Coroutine 、Flow 都是很好的选择,或者是更传统 Java Callbacks |
输出 | 使用可观测的对象,对 Kotlin 而言是 StateFlow 或者 Compose State (使用 Compose 时),对 Java 而言可以用 LiveData ,可观测的机制保证 UI 能够自动获取 State 用于显示 |
组装流水线时的注意事项
- 感知生命周期: 当页面不可见时,应当暂停生成 UI State,当页面销毁时,需要终止 State 生成。
- 兼容不同的 UI 体系: 不论是 View 还是 Compose,都要支持。
State 流水线的输入
输入部分是 State 的来源,有3种类型:
- 一次性函数 API: 即
one-shot operation
,可以是同步/异步的,例如suspend
函数。 - 流式 API: 例如
Flows
。 - 两者混合。
下文对此逐一介绍。
一次性函数 API
常用的可观测对象是 MutableStateFlow,在使用 Compose 的 APP 中,则用 mutableStateOf。它是 线程安全 的,可以在 UI 线程对其进行监听。
举一个 扔骰子 的例子,通过 Random.nextInt()
获取新的点数,并将其显示在界面上。
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() // ===> 暴露给 UI,不可变
// Called from the UI
fun rollDice() {
_uiState.update { currentState -> // ===> 使用 MutableStateFlow.update() 函数更新
currentState.copy( // ===> 使用 copy 函数赋值
firstDieValue = Random.nextInt(from = 1, until = 7),
secondDieValue = Random.nextInt(from = 1, until = 7),
numberOfRolls = currentState.numberOfRolls + 1,
)
}
}
}
在发射新值时,
update()
与=
与emit()
之间的联系与区别: 它们都是用于发射新的状态值的函数,update()
能读到前一个值,并在其基础上进行更新;=
和emit()
相同,它们无法获取上一个值,只能直接发射新的值。因此,在更新状态值时如果需要依赖上一个值进行计算,应当选用
update()
,否则使用=
和emit()
。
通过异步调用生成 UI State
对于耗时操作,可以使用 Coroutine
的 async()
启动,接口在工作线程运行,直至返回。
举一个提交任务的例子, saveTask()
是异步运行的耗时操作,操作有结果后,仍然通过 update
通知 UI。
data class AddEditTaskUiState( // ===> UI 监听此 State
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 { // ===> 一段时间后返回,update 基于当前值进行更新
it.copy(isTaskSaved = true)
}
}
catch(cancellationException: CancellationException) {
throw cancellationException
}
catch(exception: Exception) {
_uiState.update { // ===> 异常处理
it.copy(userMessage = getErrorMessage(exception))
}
}
}
}
}
在后台线程完成后更新 UI
接下来提供一种协程的建议使用方法,它基于如下的协程基础知识:
- withContext 不启动新协程,只是切换作用域。
- launch 会启动新协程,并返回
Job
对象。
基于如上的认知,总结出一个通用的实现模式:
- 在
ViewModel
中通过Dispatchers.Main
作用域启动(launch
)协程,在任务内部通过witchContext
切换作用域,执行后台任务。 - 由于是在
ViewModel
中启动,因此可以感知到应用生命周期,当页面销毁时,取消掉最外层的Job
,由于协程作用域的级联关系,其内部的协程任务自然取消。
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() { // ===> 在 ViewModel 中启动协程,不阻塞 UI 线程
// Other Coroutines that may be called from the current context
…
withContext(defaultDispatcher) { // ===> 切换分发器为 Default
_uiState.update { currentState ->
currentState.copy(
firstDieValue = SlowRandom.nextInt(from = 1, until = 7),
secondDieValue = SlowRandom.nextInt(from = 1, until = 7),
numberOfRolls = currentState.numberOfRolls + 1,
)
}
}
}
}
}
使用流式 API 生成 State
对于存在多个数据源的场景,使用 combine
操作符能够很方便地汇总结果。回忆没有 Flow
技术的时候,当年还是用 CountDownLatch
手动实现的结果同步,极其麻烦。
class InterestsViewModel(
authorsRepository: AuthorsRepository,
topicsRepository: TopicsRepository
) : ViewModel() {
val uiState = combine( // ===> 汇总两个 Stream 的返回值
authorsRepository.getAuthorsStream(),
topicsRepository.getTopicsStream(),
) { availableAuthors, availableTopics ->
InterestsUiState.Interests(
authors = availableAuthors,
topics = availableTopics
)
}
.stateIn( // ===> 通过 stateIn 转换为可感知 UI 生命周期的 StateFlow,转换时建议增加以下3个参数
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000), // UI 可见时才启动流
initialValue = InterestsUiState.Loading
)
}
除了使用最广泛的 combine 外,还可以根据实际需求选择使用 merging 和 flattening 操作符。
组装一次性调用&流式 API,生成 UI State
仍然使用 MutableStateFlow
作为输出,使用 combile
组装 MutableStateFlow
和普通 Flow
,得到普通 Flow
,再通过 stateIn
转换为可观测的 StateFlow
,发射给 UI。
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( // ===> combile 组装 Flow
_isTaskDeleted,
_task
) { isTaskDeleted, task ->
TaskDetailUiState(
task = taskAsync.data,
isTaskDeleted = isTaskDeleted
)
}
// ===> Flow -> 可观测的 StateFlow
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = TaskDetailUiState()
)
fun deleteTask() = viewModelScope.launch {
tasksRepository.deleteTask(taskId)
_isTaskDeleted.update { true }
}
}
UI State 流水线的输出
在考虑流水线的输出时,需要根据 UI 的实现方式——View 还是 Jetpack Compose——来进行选择。考虑以下因素:
- 接收 State 的对象应当具备 生命周期感知 功能。
- State Holder 生成的 State 是 一个还是多个 。
下表提供了不同 Input 和 Consumer 场景下,对于 Output 的选择:
Input | Consumer | Output |
---|---|---|
One-shot APIs | Views | StateFlow or LiveData |
One-shot APIs | Compose | StateFlow or Compose State |
Stream APIs | Views | StateFlow or LiveData |
Stream APIs | Compose | StateFlow |
One-shot and stream APIs | Views | StateFlow or LiveData |
One-shot and stream APIs | Compose | StateFlow |
简单地说,使用 StateFlow
是万金油,哪里都能用。
UI State 流水线的初始化
在初始化流水线时,应当设定所发射元素的初始值。
为了节省系统资源,使用 延迟初始化 技术,在使用 Flow
框架时,仅仅当有消费者接入时,才开始计算和发射元素。Flow
API 在 stateIn
函数里 提供了 started
参数。在未使用 Flow
的其它场景里,应当自定义幂等函数 initialize()
来提供等价功能,如下例所示:
幂等性 是指无论调用一个函数多少次,得到的结果都是相同的。
class MyViewModel : ViewModel() {
private var initializeCalled = false
// This function is idempotent provided it is only called from the UI thread.
@MainThread // ===> 限制 UI 线程调用
fun initialize() {
if(initializeCalled) return // ===> 状态变量标识初始化流程
initializeCalled = true
viewModelScope.launch {
// 启动生产状态流水线,由于 initialized 限制,该流水线最多只启动一次,且是懒加载
}
}
}
关于上述代码,要注意 避免在初始化代码块里进行异步操作 ,因为异步操作的时序性是没有保证的,如果对象没有初始化完成,会造成难以预测的后果。因此出于一致性的考虑,初始化代码块里只允许进行同步操作。