MVI 的存在意义
2023-08-04 19:49:19

学技术要学本质。对 MVI 的有效理解,源于对 “响应式编程 作用和漏洞” 等关键细节的挖掘,

故这期专为 MVI 打磨一篇 “通俗易懂、看完便理解来龙去脉、并能活学活用”,相信阅读后你会耳目一新。

文章目录一览

  • 前言

  • 响应式编程

    • 响应式编程的好处

    • 响应式编程的漏洞

    • 响应式编程的困境

  • MVI 的存在意义

  • MVI 的实现

    • 函数式编程思想
    • MVI 怎样实现纯函数效果
    • 存在哪些副作用
    • 整体流程
  • 当下开发现状的反思

    • 从源头把问题消灭
    • 什么是过度设计,如何避免
    • 平替方案的探索
  • 综上

响应式编程

谈到 MVI,首先要提的是 “响应式编程”,响应式是 Reactive 翻译成中文叫法,对应 Java 语言实现是 RxJava,

ReactiveX 官方对 Rx 框架描述是:使用 “可观察流” 进行异步编程的 API,

翻译成人话即,响应式编程暗示人们 应当总是向数据源请求数据,然后在指定的观察者中响应数据的变化

常见的 “响应式编程” 流程用伪代码表示如下:

响应式编程的好处

通过上述代码易得,在响应式编程下,业务逻辑在 ViewModel / Presenter 处集中管理,过程中向 UI 回推状态,且 UI 控件在指定的 “粘性观察者” 中响应,该模式下很容易做单元测试,有输入必有回响

反之如像往常一样,将控件渲染代码分散在观察者以外的各个方法中,便很难做到这一点。

响应式编程的漏洞

随着业务发展,人们开始往 “粘性观察者” 回调中添加各种控件渲染,

如果同一控件实例(比如 textView)出现在不同粘性观察者回调中:

1
2
3
4
5
6
7
8
9
livedata_A.observe(this, dataA ->
textView.setText(dataA.b)
...
}

livedata_B.observe(this, dataB ->
textView.setText(dataB.b) ⚠️
...
}

假设用户操作使得 textView 先接收到 liveData_B 消息,再接收到 liveData_A 消息,

那么旋屏重建后,由于 liveData_B 的注册晚于 liveData_A,textView 被回推的最后一次数据反而是来自 liveData_B,

给用户的感觉是,旋屏后展示老数据,不符预期。

响应式编程的困境

由此可得,响应式编程存在 1 个不显眼的关键细节:

一个控件应当只在同一个观察者中响应,也即同一控件实例不该出现在多个观察者中。

但如果这么做,又会产生新的问题。由于页面控件往往多达十数个,如此观察者也需配上十数个。

是否存在某种方式,既能杜绝 “一个控件在多个观察者中响应”,又能消除与日俱增的观察者?答案是有 —— 即接下来我们介绍的 MVI。

MVI 的存在意义

MVI 是 在响应式编程的前提下,通过 “将页面状态聚合” 来统一消除上述 2 个问题,

也即原先分散在各个 LiveData 中的 String、Boolean 等状态,现全部聚合到一个 JavaBean / data class 中,由唯一的粘性观察者回推,所有控件都在该观察者中响应数据的变化。

具体该如何实现?业界有个简单粗暴的解法 —— 遵循 “函数式编程思想”。

MVI 的实现

函数式编程思想

函数式编程的核心主要是纯函数,这种函数只有 “参数列表” 这唯一入口来传入初值,只有 “返回值” 这唯一出口来返回结果,且 “运算过程中” 不调用和影响函数作用域外的变量(也即 “无副作用”),

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int a

public int calculate(int b){ //纯函数
return b + b
}

public int changeA(){ //非纯函数,因运算过程中调用和影响到外界变量 a
int c = a = calculate(b)
return c
}

public int changeB() { //纯函数
int b = calculate(2)
return b + 1
}

显而易见,纯函数的好处是 “可以闭着眼使用”,有怎样的输入,必有怎样的输出,且过程中不会有预料外的影响发生。

这里贴一张网上盛传的图来说明 Model、View、Intent 三者关系,

笔者认为,MVI 并非真的 “纯函数实现”,而只是 “纯函数思想” 的实现,

也即我们实际上都是以 “面向对象” 方式在编程,从效果上达到 “纯函数” 即可,

反之如钻牛角尖,看什么都 “有副作用、不纯”,则易陷入悲观,忽视本可改善的环节,有点得不偿失。

MVI 怎样实现纯函数效果

Model 通常是继承 Jetpack ViewModel 来实现,负责处理业务逻辑;

Intent 是指发起本次请求的意图,告诉 Model 本次执行哪个业务。它可以携带或不带参数;

View 通常对应 Activity/Fragment,根据 Model 返回的 UiStates 进行渲染。

也即我们让 Model 只暴露一个入口,用于输入 intent;只暴露一个出口,用于回调 UiStates;业务执行过程中不影响 UiStates 以外的结果;且 UiStates 的字段都设置为不可变(final / val)确保线程安全,即可达成 Model 的 “纯”,

Intent 达成 “纯” 比较简单,由于它只是个入参,字段都设置为不可变即可。

View 同样不难,只要确保 View 的入口就是 Model 的出口,也即 View 的控件都集中放置在 Model 的回调中渲染,即可达成 “纯”。

存在哪些副作用

存在争议的副作用

那有人可能会说,“不对啊,View 在入口中调用了控件实例,也即函数作用域外的成员变量,是副作用呀” …… 笔者认为这是误解,

因为 MVI 的 View 事实上就不是一个函数,而是一个类。如上文所述,MVI 实际上是 通过面向对象编程的方式实现 “纯函数” 效果,而非真的纯函数,

故我们可以站在类的角度重新审视 —— 控件是类成员,对应的是纯函数的自动变量,

换言之,控件渲染并没有调用和影响到 View 作用域外的元素,故不算副作用。

公认的副作用

与此同时,UiEvents 属于副作用,也即那些弹窗、页面跳转等 “一次性消费” 的情况,

为什么?笔者认为 “弹窗、页面跳转” 时,在当前 MVI-View 页面之外创建了新的 Window、或是在返回栈添加了新的页面,如此等于调用和影响了外界环境,所以这必是副作用,

不过这是符合预期的副作用,对此官方 Guide 也有介绍 “将 UiEvents 整合到 UiStates” 的方式来改善该副作用:界面事件 | Android 开发者 | Android Developers

与之相对的即 “不符预期的副作用” —— 例如控件实例被分散在观察者外的各个方法中,并在某个方法中被篡改和置空,其他方法并不知情,调用该实例即发生 NullPointException。

整体流程

至此 MVI 的代码实现已呼之欲出:

1.创建一个 UiStates,反映当前页面的所有状态。

1
2
3
4
5
data class UiStates {
val weather : Weather,
val isLoading : Boolean,
val error : List<UiEvent>,
}

2.创建一个 Intent,用于发送请求时携带参数,和指明当前想执行的业务。

1
2
3
sealed class MainPageIntent {
data class GetWeather(val cityCode) : MainPageIntent()
}

3.执行业务的过程,总是先从数据层获取数据,然后根据情况分流和回推结果,例如请求成功,便执行 Success 来回推结果,请求失败,则 Error,对此业内普遍的做法是,增设一个 Actions,

并且由于 UiStates 的字段不可变,且控件集中响应 UiStates,也即务必确保 UiStates 的延续,由此每个业务带来局部改变时(partialChange),需通过 copy 等方式,将上一次的 UiStates 拷贝一份,并为对应字段注入 partialChange。这个过程业内称为 reduce。

1
2
3
4
5
6
7
8
9
10
11
12
13
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。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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() }

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 数据。

控件集中响应,带来不必要的性能开销,需要做个 diff,只响应发生变化的字段。

笔者通常是通过 DataBinding ObservableField 做防抖。后续如 Jetpack Compose 普及,建议是使用 Jetpack Compose,无需开发者手动 diff,其内部类似前端 DOM ,根据本次注入的声明树自行在内部差分合并渲染新内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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>("")
...
}
}

整个流程用一张图表示即:

当下开发现状的反思

上文我们追溯了 MVI 来龙去脉,不难发现,MVI 是给 “响应式编程” 填坑的存在,通过状态聚合来消除 “不符预期回推、观察者爆炸” 等问题,

然而 MVI 也有其不便之处,由于它本就是要通过聚合 UiStates 来规避上述问题,故 UiStates 很容易爆炸,特别是字段极多情况下,每次回推都要做数十个 diff ,在高实时场景下,难免有性能影响,

MVI 许多页面和业务都需手写定制,难通过自动生成代码等方式半自动开发,故我们我们不如退一步,反思下为什么要用响应式编程?是否非用不可?

穷举所有可能,笔者觉得最合理的解释是,响应式编程十分便于单元测试 —— 由于控件只在观察者中响应,有输入必有回响,

也是因为这原因,官方出于完备性考虑,以响应式编程作为架构示例。

从源头把问题消灭

现实情况往往复杂。

Android 最初为了站稳脚跟,选择复用已有的 Java 生态和开发者,乃至使用 Java 作为官方语言,后来 Java 越来越难支持现代化移动开发,故而转向 Kotlin,

Kotlin 开发者更容易跟着官方文档走,一开始就是接受 Flow 那一套,且 Kotlin 抹平了语法复杂度,天然适合 “响应式编程” 开发,如此便有机会踩坑,乃至有动力通过 MVI 来改善。

然而 10 个 Android 7 个纯 Java ,其中 6 个从不用 RxJava ,剩下一个还是偶尔用用 RxJava 的线程调度切换,所以响应式编程在 Android Java 开发者中的推行不太理想,领导甚至可能为了照顾多数同事,而要求撤回响应式代码,如此便很难有机会踩坑,更谈不上使用 MVI,

也因此,实际开发中更多考虑的是,如何从根源上避免各种不可预期问题。

对此从软件工程角度出发,笔者在设计模式原则中找到答案 —— 任何框架,只要遵循单一职责原则,便能有效避免各种不可预期问题,反之过度设计则易引发不可预期问题。

什么是过度设计,如何避免

上文提到的 “粘性观察者”,对应的是 BehaviorSubject 实现,强调 “总是有一个状态”,比如门要么是开着,要么是关着,门在订阅 BehaviorSubject 时,会被自动回推最后一次 State 来反映状态。

常见 BehaviorSubject 实现有 ObservableField、LiveData、StateFlow 等。

反之是 PublishSubject 实现,对应的是一次性事件,常见 PublishSubject 实现有 SharedFlow 等。

笔者认为,LiveData/StateFlow 存在过度设计,因为它的观察者是开放式,一旦开了这口子,后续便不可控,一个良好的设计是,不暴露不该暴露的口子,不给用户犯错的机会

一个正面的案例是 DataBinding observableField,不向开发者暴露观察者,且一个控件只能在 xml 中绑定一个,从根源上杜绝该问题。

平替方案的探索

至此平替方案便也呼之欲出 —— 使用 ObservableField 来承担 BehaviorSubject,

也即直接在 ViewModel 中调用 ObservableField 通知所绑定的控件响应,且每个 ObservableField 都携带原子数据类型(例如 String、Boolean 等类型),

如此便无需声明 UiStates 数据类。由于无 UiStates、无聚合、也无线程安全问题,也就无需再 reduce 和 diff,简单做个 Actions 为结果分流即可。

此时仍属响应式编程,相较经典 MVI,繁琐度大幅缩减,性能有所提升。

综上

响应式编程便于单元测试,但其自身存在漏洞,MVI 即是来消除漏洞,

MVI 有一定门槛,实现较繁琐,且存在性能等问题,难免同事撂挑子不干,一夜回到解放前,

综合来说,MVI 适合与 Jetpack Compose 搭配实现 “现代化的开发模式”,

反之如追求 “低成本、复用、稳定”,可通过遵循 “单一职责原则” 从源头把问题消除。

相关资料

响应式编程:ReactiveX

函数式编程:函数式编程 - 百科

MVI 纯函数图例:Reactive Apps with Model-View-Intent - Part 2: View and Intent

通过 UiStates 管理 UiEvent:界面事件 | Android Developers

平替方案探索:解决 MVI 架构实战痛点

版权声明

Copyright © 2019-present KunMinX 原创版权所有。

如需 转载本文,或引用、借鉴 本文 “引言、思路、结论、配图” 进行二次创作发行,须注明链接出处,否则我们保留追责权利。