说在前头:
纪晓岚问和珅,为何他们往灾民粥里掺沙子,和珅道:“你是有所不知啊,如不掺沙子,灾民怕是一口粥也喝不上啊”。
同理,架构的存在是为 “在实际开发过程中消除不可预期问题”,而非为架构而架构。
为使架构组件真正能在团队中普及,乃至最终有效达成 “消除大部分不可预期问题” 目的,本文采取 “淡化理论概念 + 设计简明易懂” 方式,让团队新手老手都能因为 “这框架好懂、简便、用着舒服”,而自然而然效仿和使用。
本文假设您已具备 State、Event、响应式编程、BehaviorSubject、PublishSubject、函数式编程、纯函数、副作用、MVI、软件工程、设计模式原则、一致性问题、单一职责原则、过度设计 等前置知识,且在团队中推行 MVI 遭遇不利,想就近找到平替方案。
通过本文可快速了解:
1.为何使用 MVI,是否非用不可,
2.为何最终考虑 SharedFlow 实现,
3.repeatOnLifecycle + SharedFlow 实现思路
文章目录一览
- 前置知识
- 为何使用 MVI,是否非用不可
- MVI 经典模型
- 改善版本 1:添加防抖处理
- 使用 DataBinding ObservableField
- 使用 distinctUntilChanged
- 使用 RecyclerView DiffUtils
- 改善版本 2:简化 Action 和 Reduce
- 使用 Sealed Class 分流
- 改善版本 3:改用 PublishSubject 回推结果
- 使用 SharedFlow 回推结果
- 改善版本 4:通过计数防止重复回推
- 改善版本 5:封装和屏蔽样板代码
- 改善版本 6:State 和 Event 合与分
- State 和 Event 什么时候该分,什么时候该合
- 添加 version 防止订阅回推
- 三层架构 vs 二层架构
- 综上
前置知识
上一期《MVI 的存在意义》已铺垫信息如下:
1.响应式编程暗示人们 应当总是向数据源请求数据,并在指定观察者中响应数据的变化。
2.响应式编程的好处是 便于测试,有输入必有回响。
3.响应式编程 存在 “多个粘性观察者回推不符预期数据” 的漏洞。
4.MVI 即是 通过 “聚合页面状态” 消除该漏洞。
5.鉴于 “响应式编程” 便于测试,官方出于完备性考虑,也是以响应式编程作为架构示例。
6.由于 Kotlin 抹平语法复杂度,便于响应式编程,且 Kotlin 开发者更容易跟着官方文档走,接受这套开发模式,乃至 有机会踩坑,且有动力通过 MVI 改善。
7.Android 开发者 70% 仍是纯 Java,响应式编程在 Android Java 开发者中的推行不太理想。
为何使用 MVI,是否非用不可
所以至此,第一个问题的答案呼之欲出,
因为对一部分开发者来说,响应式编程很香,但又存在漏洞,即部分 BehaviorSubject 框架存在过度设计,导致存在 “多个粘性观察者不符预期回推” 的漏洞,所以需要 MVI 出马解决。
注:什么是过度设计,如何避免?具体见上期解析,本文不再累述。
那有人可能会问,既然部分 BehaviorSubject 框架过度设计,那替换成没有过度设计的 BehaviorSubject,比如 ObservableField 不就可以了,
可以是可以,不过也看情况,MVI 天然适合与 Jetpack Compose 搭配,
如果是使用 Jetpack Compose,就用不上 ObservableField,只能使用 LiveData/StateFlow 来回推 UiStates,也即只能通过 MVI 来消除漏洞,难有别的平替方案。
所以如果暂不使用 Jetpack Compose,根据上期的分析易知,只要消除过度设计,就能从源头上把问题解决,无所谓开发者用不用 MVI。
鉴于上期文末已分享 MVI 最小成本平替方案,本文直接从 “设计模式原则” 出发,探索一种更加普适的方案,相信阅读后你会耳目一新。
MVI 经典模型
1.创建一个 UiStates,反映当前页面的所有状态。
data class UiStates {
val weather : Weather,
val isLoading : Boolean,
val error : List<UiEvent>,
}
2.创建一个 Intent,用于发送请求时携带参数,和指明当前想执行的业务。
sealed class MainPageIntent {
data class GetWeather(val cityCode) : MainPageIntent()
}
3.创建一个 Actions,用于 reduce 当前业务的 partialChange 并生成新的 UiStates。
sealed class MainPageActions {
fun reduce(oldStates : UiStates) : UiStates {
return when(this){
Loading -> oldStates.copy(isLoading = true)
is Success -> oldStates.copy(isLoading = false, weather = this.weather)
is Error -> oldStates.copy(isLoading = false, error = listOf(UiEvent(msg)))
}
}
object Loading : MainPageActions()
data class Success(val weather : Weather) : MainPageActions()
data class Error(val msg : String) : MainPageActions()
}
4.创建当前页面使用的 MVI-Model。
class MainPageModel : MVI_Model<UiStates>() {
private val _stateFlow = MutableStateFlow(UiStates())
val stateFlow = _stateFlow.asStateFlow
private fun sendResult(uiStates: S) = _stateFlow.emit(uiStates)
fun input(intent: Intent) = viewModelScope.launch{ onHandle(intent) }
private suspend fun onHandle(intent: Intent) {
when(intent){
is GetWeather -> {
sendResult(MainPageActions.Loading.reduce(oldStates)
val response = api.post()
if(response.isSuccess) sendResult(
MainPageActions.Success(response.data).reduce(oldStates)
else sendResult(
MainPageActions.Error(response.message).reduce(oldStates)
}
}
}
}
5.创建 MVI-View,并在 stateFlow 中响应 MVI-Model 数据。
class MainPageActivity : Android_Activity(){
private val model : MainPageModel
fun onCreate(){
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
model.stateFlow.collect {uiStates ->
progressView.setProgress(uiStates.isLoading)
tvWeatherInfo.setText(uiStates.weather.info)
...
}
}
model.input(Intent.GetWeather(BEI_JING))
}
}
整个流程用一张图来表示即:
改善版本 1:添加防抖处理
使用 DataBinding ObservableField
考虑到 DataBinding ObservableField 存在防抖特性,故页面可通过 ObservableField 完成末端状态改变,尽可能消除 “控件刷新” 性能开销。
class MainPageActivity : Android_Activity(){
private val model : MainPageModel
private val views : MainPageViews
fun onCreate(){
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
model.stateFlow.collect {uiStates ->
views.progress.set(uiStates.isLoading)
views.weatherInfo.set(uiStates.weather.info)
...
}
}
model.input(Intent.GetWeather(BEI_JING))
}
class MainPageViews : Jetpack_ViewModel() {
val progress = ObservableBoolean(false)
val weatherInfo = ObservableField<String>("")
...
}
}
不过这要求开发者具备 DataBinding 使用经验、额外书写 DataBinding 样板代码和 XML 绑定。
使用 distinctUntilChanged
除了 DataBinding,网上还提到有 2 类方案:
一类是通过 distinctUntilChanged 来为 ViewStates 的属性提供防抖,
但如此后续便难屏蔽 diff,只能暴露给开发者手动 map distinct 分流,增加手写代码量和认知成本,
class View-Controller : Android-Activity() {
fun onCreate() {
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState
.map { it.isDownload }
.distinctUntilChanged()
.collect { progress = it }
viewModel.uiState
.map { it.Setting }
.distinctUntilChanged()
.collect { btnChecked = it }
...
}
}
}
}
使用 RecyclerView DiffUtils
另一类是通过 RecyclerView 编写页面。
如此便难支持复杂交互效果、容易引入其他不可预期问题,也难在多数开发者中普及开(有点为 MVI 而 MVI),且 DiffUtils 需手动配置,equals 列表密密麻麻易漏写或写错,
val diff = object : DiffUtil.ItemCallback<ViewStates>() {
override fun areItemsTheSame(oldItem: ViewStates, newItem: ViewStates): Boolean {
return oldItem.equals(newItem)
}
override fun areContentsTheSame(oldItem: ViewStates, newItem: ViewStates): Boolean {
return oldItem.progress().equals(newItem.progress())
&& ... equals ...
&& ... equals ...
&& ... equals ...
...
}
}
易得 diff 方式皆存在学习成本和使用成本,当同事写多了感觉厌烦,便自动回归原始,架构目的前功尽弃。
故我们只好另辟蹊径,探索少有人走的路,
改善版本 2:简化 Action 和 Reduce
如上文所述,响应式编程漏洞是由多观察者引起,只要使用单一观察者,便无该漏洞需修补。
不过单一观察者并不意味着只能通过 data class 聚合 UiStates,我们也可将其限定为,每次只从同一个出口回推当前业务的数据,如此便也无线程安全问题,乃至无需 Actions 和 reduce,
使用 Sealed Class 分流
为此每个页面可以简单通过 Intent 来包含入参和结果的传递,loading、error 等 Action 可以通过单独的 Intent 来反映,如此将 MVI 中最繁琐的 Action 设计拍平:
sealed class MainIntent {
data class Loading(var progress: Boolean) : MainIntent()
data class Info(var title: String) : MainIntent()
...
}
class Model : Jetpack-ViewModel() {
private val _states = MutableLiveData<MainIntent>()
val states = _states.asLiveData()
fun request(intent: Intent){
when(intent){
is Intent.XXX -> {
_states.setValue(MainIntent.Loading(true))
_states.setValue(MainIntent.Info(DataRepository.getInfo()))
_states.setValue(MainIntent.Loading(false))
}
}
}
}
class View-Controller : Android-Activity() {
private val model : Model
private val holder : StateHolder
fun onCreate(){
model.states.observe(this){
when(it){
is MainIntent.Loading -> holder.progress = it.progress
is MainIntent.Info -> holder.tvTitle = it.title
...
}
}
}
}
然而 BehaviorSubject 天然不适合连续发送消息的场景,
例如息屏(页面生命周期离开 STARTED)期间所获消息,BehaviorSubject 仅存留最后一个,那么分流设计下,亮屏后(页面生命周期重回 STARTED)多种类消息只会推送最后一个,其余皆丢失(比如 Loading、Success、Error 等数据,最终只响应 Error),
故改用 PublishSubject,比如 SharedFlow 来处理。
那么有人可能会问,改用 PublishSubject,那 State 如何保留和自动回推?
笔者认为会有这样的疑问,本身是由于搞混组件的职责所致。网上流行的写法,领域层和表现层混杂一处,BehaviorSubject 同时承担业务消息回推和 State 容器,这也是造成响应式编程漏洞的祸根。
根据单一职责原则,组件宜职责单一,各司其职。在领域层消息分发环节,可以使用 PublishSubject 专职业务消息的回推,在表现层渲染环节,可以使用 BehaviorSubject 通知控件渲染,并为控件兜着最后一次状态。
对此下文的 “改善版本 6” 一节,通过图文详细介绍 State 和 Event 合与分的时机和设计。
改善版本 3:改用 PublishSubject 回推结果
使用 SharedFlow 回推结果
SharedFlow 内有一队列,如欲亮屏后自动推送多种类消息,则可将 replay 次数设置为与队列长度一致,例如 10,
class Model : Jetpack-ViewModel() {
private val _sharedFlow: MutableSharedFlow<ViewStates>? by lazy {
MutableSharedFlow(
onBufferOverflow = BufferOverflow.DROP_OLDEST,
extraBufferCapacity = DEFAULT_QUEUE_LENGTH,
replay = DEFAULT_QUEUE_LENGTH
)
}
val sharedFlow = _sharedFlow.asSharedFlow()
companion object {
private const val DEFAULT_QUEUE_LENGTH = 10
}
}
由于 replay 会重走设定次数中队列的元素,故重走 STARTED 时会重走所有,包括已消费和未消费过,视觉上给人感觉即,控件上旧数据 “一闪而过”,
这体验并不好,
改善版本 4:通过计数防止重复回推
故此处可加个判断 —— 如已消费,则下次 replay 时不消费。
class Model : class Model : Jetpack-ViewModel() {
private var observerCount = 0
private val _sharedFlow: MutableSharedFlow<ViewStates>? by lazy {
MutableSharedFlow(
onBufferOverflow = BufferOverflow.DROP_OLDEST,
extraBufferCapacity = DEFAULT_QUEUE_LENGTH,
replay = DEFAULT_QUEUE_LENGTH
)
}
val sharedFlow = _sharedFlow.asSharedFlow()
companion object {
private const val DEFAULT_QUEUE_LENGTH = 10
}
}
data class ConsumeOnceValue<E>(
var consumeCount: Int = 0,
val value: E
)
class View-Controller : Android-Activity() {
private val model : Model
private val holder : StateHolder
fun onCreate(){
lifecycleScope?.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
model.states.collect {
if (version > currentVersion) {
if (model.consumeCount >= observerCount) return@collect
model.consumeCount++
when(it){
is MainIntent.Download -> holder.progress = it.progress
is MainIntent.Setting -> holder.btnChecked = it.btnChecked
is MainIntent.Info -> holder.tvTitle = it.title
is MainIntent.List -> holder.list = it.list
}
}
}
}
}
}
}
但每次创建一页面都需如此写一番,繁琐且易出错,
故可将其内聚,统一抽取至单独框架维护,
MVI-Dispatcher-KTX 应运而生,
改善版本 5:封装和屏蔽样板代码
如下,通过将 repeatOnLifecycle、计数比对、mutable/immutable 等样板逻辑内聚,
open class MviDispatcherKTX<E> : ViewModel(), DefaultLifecycleObserver {
private var observerCount = 0
private val _sharedFlow: MutableSharedFlow<ConsumeOnceValue<E>>? by lazy {
MutableSharedFlow(
onBufferOverflow = BufferOverflow.DROP_OLDEST,
extraBufferCapacity = initQueueMaxLength(),
replay = initQueueMaxLength()
)
}
protected open fun initQueueMaxLength(): Int {
return DEFAULT_QUEUE_LENGTH
}
fun output(activity: AppCompatActivity?, observer: (E) -> Unit) {
observerCount++
activity?.lifecycle?.addObserver(this)
activity?.lifecycleScope?.launch {
activity.repeatOnLifecycle(Lifecycle.State.STARTED) {
_sharedFlow?.collect {
if (it.consumeCount >= observerCount) return@collect
it.consumeCount++
observer.invoke(it.value)
}
}
}
}
override fun onDestroy(owner: LifecycleOwner) {
super.onDestroy(owner)
observerCount--
}
protected suspend fun sendResult(event: E) {
_sharedFlow?.emit(ConsumeOnceValue(value = event))
}
fun input(event: E) {
viewModelScope.launch { onHandle(event) }
}
protected open suspend fun onHandle(event: E) {}
data class ConsumeOnceValue<E>(
var consumeCount: Int = 0,
val value: E
)
companion object {
private const val DEFAULT_QUEUE_LENGTH = 10
}
}
如此开发者哪怕不熟 MVI、mutable,只需关注 “input-output” 两处即可自动完成 “单向数据流” 开发,
改善版本 6:State 和 Event 合与分
State 和 Event 什么时候该分,什么时候该合
为了改善 “副作用”(关于 “副作用” 见上期解析),通常是 传输过程中合并 UiStates 和 UiEvents,并在响应时分开处理,这也和 “响应式编程” 串流设计不谋而合。
对此官方做法是,将 UiEvents 整合到 UiStates,界面事件 | Android Developers
笔者认为,此做法相较于 UiStates 和 UiEvents 分开发送的优点在于,使 UiEvents 同处于 STATRED 环节响应,避免手写遗漏乃至引发 “弹窗无法获取 token” 等情况,
缺点是,需要手动 filterNot 屏蔽已消费事件,增加学习成本且埋下手写的一致性隐患。
故笔者采取的是另一种办法 —— 将 UiState 整合到 UiEvent,响应时再将 UiState 和 UiEvent 解离。也即我们可以采用 PublishSubject 来做观察者,并在观察者回调中,单独对 UiState 采取 BehaviorSubject(比如 ObservableField)的方式来通知控件响应和渲染。
添加 version 防止订阅回推
故此处可再加个 verison 比对,
open class MviDispatcherKTX<E> : ViewModel(), DefaultLifecycleObserver {
private var version = START_VERSION
private var currentVersion = START_VERSION
private var observerCount = 0
...
fun output(activity: AppCompatActivity?, observer: (E) -> Unit) {
currentVersion = version
observerCount++
activity?.lifecycle?.addObserver(this)
activity?.lifecycleScope?.launch {
activity.repeatOnLifecycle(Lifecycle.State.STARTED) {
_sharedFlow?.collect {
if (version > currentVersion) {
if (it.consumeCount >= observerCount) return@collect
it.consumeCount++
observer.invoke(it.value)
}
}
}
}
}
protected suspend fun sendResult(event: E) {
version++
_sharedFlow?.emit(ConsumeOnceValue(value = event))
}
companion object {
private const val DEFAULT_QUEUE_LENGTH = 10
private const val START_VERSION = -1
}
}
如此即可从根源上消除 “响应式编程” 的漏洞,且无论团队成员是否理解 “响应式编程”,都可快速稳定迭代,不滋生不可预期问题。
class MainPageActivity : Android_Activity(){
private val model : MainPageModel
private val views : MainPageViews
fun onOutput(){
model.output(this){ intent ->
when(intent){
MainIntent.Progress -> views.progress.set(intent.progress)
MainIntent.Weather -> views.weatherInfo.set(intent.weather)
MainIntent.Error -> showErrorDialog()
}
}
model.input(Intent.GetWeather(BEI_JING))
}
class MainPageViews : Jetpack_ViewModel() {
val progress = ObservableBoolean(false)
val weatherInfo = ObservableField<String>("")
...
}
}
三层架构 vs 二层架构
换言之,上述设计属于三层架构(表现层、领域层、数据层),也即表现层使用 Jetpack ViewModel 做 StateHolder,其中安排各式 ObservableField 作为 BehaviorSubject,用于为 State 兜着状态、自动通知控件渲染,以及旋屏重建时一对一自动回推。领域层使用 MVI-Dispatcher 做业务处理,其中安排一个 PublishSubject 用作唯一出口 output 消息回推。
三层架构由于各司其职,消息回推环节不使用 BehaviorSubject,从而从根源上消除 “响应式编程” 漏洞。且三层架构复用性更佳,同一业务的页面皆可共用同一套业务逻辑,避免业务逻辑的冗余,
反之,网上流行的二层架构(表现层,数据层。其中 ViewModel 属于表现层)意味着每个页面都要在配套的 ViewModel 书写业务逻辑,对于冗余的业务逻辑,后续容易发生修改其中一个页面的,忘记另一页面的,造成代码更新的不一致。
注:SharedFlow 仅限于 Kotlin 项目,如 Java 项目也想用,可参考 MVI-Dispatcher 设计,其内部维护一队列,通过基于 LiveData 改造的 Mutable-Result 亦圆满实现上述功能。
综上
理论模型皆旨在特定环境下解决特定问题,直用于生产环境或存在不可预期问题,故我们不断尝试、交流和更新。
感谢实事求是测试反馈交流的小伙伴,让 MVI-Dispatcher 系框架得以演化至今。