【现代 Android APP 架构】08. UI State 生成机制

483 阅读7分钟

采用这种“要做什么”风格的编程通常被称为声明式编程。你制定规则,给出了希望实现的目标,让系统来决定如何实现这个目标。 它带来的好处非常明显,用这种方式编写的代码更加接近问题陈述了。——《Java8实战》

前文介绍了 UI State、State Holder 的概念,接下来在本文中,将学习 UI State 的生成机制,主要包含 UI State 的输入及输出流水线使用哪几个 API 函数生成 UI State如何控制 UI State 的 Scope,以及 UI State 的消费方式

State 与 Event 的区别

从概念上,State 表示一种 状态,是持续性的,而 Event 的含义是 事件,具备瞬时性。我们可以说当前 APP 处于哪种状态,发生了什么事件,而不能倒过来说。

两者的区别概括如下表:

EventsState
短暂发生持续存在
作为 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 线程以外执行耗时操作,CoroutineFlow 都是很好的选择,或者是更传统 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

对于耗时操作,可以使用 Coroutineasync() 启动,接口在工作线程运行,直至返回。

举一个提交任务的例子, 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 外,还可以根据实际需求选择使用 mergingflattening 操作符。

组装一次性调用&流式 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 的选择:

InputConsumerOutput
One-shot APIsViewsStateFlow or LiveData
One-shot APIsComposeStateFlow or Compose State
Stream APIsViewsStateFlow or LiveData
Stream APIsComposeStateFlow
One-shot and stream APIsViewsStateFlow or LiveData
One-shot and stream APIsComposeStateFlow

简单地说,使用 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 限制,该流水线最多只启动一次,且是懒加载
        }
    }
}

关于上述代码,要注意 避免在初始化代码块里进行异步操作 ,因为异步操作的时序性是没有保证的,如果对象没有初始化完成,会造成难以预测的后果。因此出于一致性的考虑,初始化代码块里只允许进行同步操作。

参考资料