最近在业务开发中,对“状态”和“事件”两个概念有些思考,在这里记录一下。

从LiveData的“问题”说起

在 androidx 的 LiveData 和 ViewModel 库刚推出的时候,我也在业务中接入使用,并有了一些思考,可参见之前的文章

这时,我们在业务中注意到一个“问题”,在网上、甚至在面试中也多有讨论:LiveData的通知是“黏性”的,即一个新的订阅者出现(或者不活跃的订阅者重新活跃)的时候,如果LiveData中已经持有着一个数据的话,会立即将这个数据回调给这个订阅者。

而在我们的实际业务场景中却可能这么使用:我们在LiveData中存放一个“需要显示的toast”,如果View观察到LiveData中的toast发生变动,就将其显示出来。
这个做法的问题很快就被发现了,在显示了toast之后,如果View层发生了重建、或者退入后台之类的操作,在恢复显示之后就会重复显示上一个toast的内容。

这个问题在网上的讨论中,已经被给出来许多种解法,有想办法获取版本号进行比较的、有hook监听自行分发的、甚至有根据调用栈中的信息进行判断的。当时我也根据自己的理解,在业务中给出了一种解法。

但这篇文章并不打算对这个问题进行展开,而是希望站在今天一个新的高度上,从一个新的角度再去理解它。

数据类的演变

在接触使用了 MVVM 模式之后,我们的开发思路开始向 数据驱动 的方向转变,开始寻求将页面上尽可能多的信息封装成数据类进行描述,最后得到的数据类,我们称之为页面的“状态”。
在这个状态数据中,描述了这个页面的“静态信息”,例如标题显示什么文字、需要显示什么图片、有多少业务卡片等等,这样的封装允许我们在一个独立的数据对象中完整还原用户可见的页面信息,方便调试和测试,减少UI逻辑特殊判断带来的出错可能性,而且还更好地面向 Compose 的编写习惯。

在这样的设计中,我们同样也碰到了跟前面 LiveData 类似的问题:单纯的静态状态难以描述 toast 显示、URL 跳转等“动态”的内容,对于现有的系统api来说,我们仅能调用 toast 的显示,而不能控制它的关闭、或者在消失时获取到回调通知。如果直接用一个普通字段承载需要显示的 toast 内容,会出现一个已经被响应过的toast仍留在状态中这种不可接受的情况。

针对类似的问题,需要再对业务进行另一个抽象:这些“动态”操作内容单独封装为一个独立的数据类,称为“事件”,通过单独的管道通知给 view 层;view 层消仅能通过管道通知获取最新事件的出现,消费后即舍弃。

状态与数据

经过一段时间的使用和思考之后,我在业务开发的层面,对“状态”和“事件”给了以下定义:

状态:一个可以在任意时间点描述业务当前的瞬时数据的实体。

首先,状态描述的应当是在一个时间节点上业务数据,不需要包含“之前曾经”是什么状态、也不用包含“以后将会变成什么状态”的信息。因此状态包含的信息应当是静态、稳定的,view 层级订阅、响应最新状态以更新展现,在需要修改时(如响应用户操作)时通知给 VM 层,然后等待新的状态到来。

使用kotlin中的 data class,并将必要的字段标识为 val 不可变将与此很匹配。
当然,如果需要记录操作返回栈的的业务,则应当作为一个业务字段去记录。

在这套定义中,状态将是描述业务信息的“一等公民”,业务所需的信息应当优先考虑声明在对应的状态类中;同时,在业务的生命周期内的任意时间节点,应当都能获取到当前时间的状态,用于自己的业务逻辑中。

前面所说的问题里,业务无法直接控制的 toast 信息可以被理解成,在业务状态之上,存在一个抽象的“系统状态”概念,“是否显示着 toast”的信息就保存在系统状态中;
同理,也可以理解为有一个抽象的路由状态,记录着当前所处在的路由节点,跳转 URL 就是向这个抽象的路由状态压入一个新的 URL 栈帧。

但是这样的抽象解决不了的问题是,我们不能实际地去感知抽象的系统状态的变化,并不能知道一个 toast 具体是何时显示、何时消失的。
为了解决实际使用的问题,我再引入“事件”的概念,作为状态变更的补充。

事件:用于描述状态即将或者已经发生的变化的数据实体。

此时的事件是依赖于状态存在的,是因为无法访问某个实体的状态、或者为了方便实现业务而引入的概念;是专门用来描述状态的变更的,只有能应用在状态的事件才有意义。

在这个情况下,“用户点击了按钮”就可以是一个事件,去驱动业务状态发生变化;状态变化之后,也可以产生一个事件通知其他业务;或者状态变化本身也是一个事件,业务可以通过前后两个状态的对比来推断出这一次发生的事件。

“小明有3个苹果”这句话描述了一个时间节点上的一个状态,小明在任何时候都会有“有x个苹果”的状态;
小明的日记本作为一个 VM 实现,需要订阅状态来实现自己的“记录每天苹果数量”的业务;

“妈妈喊你吃一个苹果”是一个输入事件,驱动“苹果数量”状态发生-1的变化;
“吃完了一个苹果”是一个代表状态变化的事件;

上述两种事件对日记本本身没有意义,只有事件发生在某一天的具体状态上,以此得到事件发生后的状态才有实际意义。

在这种设计下,显示 toast 可以是业务状态中带着需要显示的内容、和触发时间点的状态,在需要显示的时候赋上新值。
在业务状态的这个字段发生变化的时候,派生出一个“驱动系统抽象状态显示toast”的事件,然后依靠这个事件的订阅消息调用toast的显示。

从 LiveData 到 flow

使用这一套定义去理解业务之后,最开始提到的 LivaData 的“黏性订阅”问题也就有了另一个答案:

LiveData 是安卓特地为“状态”提供的容器,因为业务在任意时刻都会有状态信息,并且订阅者随时都需要知道最新的状态的信息,因此使用黏性通知是合适的。
而在上面提到的使用场景里,其实是希望使用状态容器,去承载事件分发的逻辑,当然会出现奇奇怪怪的问题了。

安卓在 Java 实现里只提供了 LivaData 这个状态容器,没有提供相应的事件容器,但 Kotlin 在协程中却给我们提供了两个好用的封装:

StateFlow 在名字里就包含了状态,天然适合作为状态的容器提供订阅。
如果深入去看它的实现的话,会发现它是一个自带1缓存、1重放、可随时获取最新数据、更新内容不阻塞、永不结束的热流,正好能适应前面对于状态订阅的几个特点;

SharedFlow 则适合作为事件容器提供订阅,它提供开放的api能让我们控制缓存、重放和更新策略,方便我们根据自己的业务特点自行配置。

设计一个状态/事件通知中枢

基于以上的这些认知,我们可以尝试设计一个通知中枢,方便不同业务模块之间进行信息同步。

在这个体系中,状态永远是最重要的一部分,而且加上状态的变化本身可以成为一个事件,那么首先为状态订阅设计api:

1
2
3
4
5
interface IState
interface INotifyCenter {
fun updateState(newState: IState)
fun <S: IState> ofState(stateClass: Class<out S>): StateFlow<S>
}

要求所有订阅者直接通过状态进行业务编写不太方便,还是需要直接给出事件的订阅:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
interface IState

interface IEvent

interface IStateDerivedEvent<S: IState> {
val currentState: S // 事件发生之后最新的状态
}

interface INotifyCenter {
fun updateState(newState: IState)
fun <S: IState> ofState(stateClass: Class<out S>): StateFlow<S>

fun sendEvent(event: IEvent)
fun <E: IEvent> ofEvent(eventClass: Class<out E>): Flow<E>
}

由状态生成事件

对于状态的维护者而言,每次在更新状态之后手动同步发送事件通知很不方便,因此需要提供一个办法,让更新者方便地注入事件的转发:

1
2
3
4
5
6
7
8
9
10
// 通过判断新旧两个状态的数据,生成一系列事件
fun <S: IState, E: IStateDerivedEvent<S>> StateFlow<S>.deriveEvent(eventMapper: suspend FlowCollector<E>.(oldState: S, newState:S) -> Unit): Flow<E>

// 状态维护者在合适的时机注入转发
INotifyCenter.ofState(State::class).deriveEvent { oldState, newState ->
// 比较数据,生成事件
emit(Event())
}

// 订阅者按需订阅状态或者事件即可。

事件的订阅策略

在大多数情况下,订阅者会只关心事件的产生;
而订阅者并不一定在任何时候都存活着,在某些情况下,订阅者首先注册了事件的订阅,但随后进入了不活跃的状态;在这个订阅者恢复活跃的时候,它可能需要接收到不活跃过程中发生的所有事件。

对于这样的订阅者,首选的应当是订阅状态;或者考虑使用缓存机制。

在这种场景中,通知中枢是不能做这样的缓存的:

  • 中枢不感知事件的定义,不知道应该选择何种缓存策略;
  • 中枢不知道订阅者需要的缓存范围,无法在消费完缓存后清理,有巨大的内存压力;

所以,最合适的方式是订阅者根据自己的需要,选择一个更大的 scope 做事件缓存:

1
2
3
4
5
6
7
8
9
10
11
12
13

class VM: ViewModel() {
val myEvents = NotifyCenter.ofEvent(MyEvent::class).sharedIn(this.viewModelScope)
}
class SomeFragment: Fragment() {
override fun onvViewCreated() {
viewLifecycleScope.launch {
vm.myEvents.collect {
// biz
}
}
}
}

像是“在自己业务生命周期之前发生的事件”,则属于没有存在意义的“过时事件”,应当通过获取最新状态自行判断来做逻辑,不在事件策略范围内讨论了。

总结

这一套状态与事件的定义是我在业务需求中不断总结更新出来的,目前在自己的业务场景中有着不错的应用场景。
虽然会有代码增多的情况出现,但我认为更方便开发理解业务、方便调试,值得一用。
也希望在后续的思考里不断更新,扩展应用范围。