前言
学技术要学本质。对 MVI 的有用了解,建立在 “呼应式编程” 的 “效果和缝隙” 等要害细节的发掘,
故这期专为 MVI 打磨一篇 “通俗易懂、看完便了解来龙去脉、并能活学活用”,信任阅读后你会耳目一新。
文章目录一览
- 前言
- 呼应式编程
- 呼应式编程的优点
- 呼应式编程的缝隙
- 呼应式编程的困境
- MVI 的存在含义
- MVI 的完成
- 函数式编程思维
- MVI 怎样完成纯函数效果
- 存在哪些副效果
- 全体流程
- 当下开发现状的反思
- 从源头把问题消除
- 什么是过度规划,如何防止
- 平替计划的探索
- 综上
呼应式编程
谈到 MVI,首先要提的是 “呼应式编程”,呼应式是 Reactive 翻译成中文叫法,对应 Java 言语完成是 RxJava,
ReactiveX 官方对 Rx 结构描绘是:运用 “可调查流” 进行异步编程的 API,
翻译成人话即,呼应式编程暗示人们 应当总是向数据源恳求数据,然后在指定的调查者中呼应数据的改动,
常见的 “呼应式编程” 流程用伪代码表示如下:
呼应式编程的优点
经过上述代码易得,在呼应式编程下,事务逻辑在 ViewModel / Presenter 处会集办理,进程中向 UI 回推状况,且 UI 控件在指定的 “粘性调查者” 中呼应,该形式下很容易做单元测试,有输入必有回响。
反之如像往常一样,将控件烘托代码涣散在调查者以外的各个办法中,便很难做到这一点。
呼应式编程的缝隙
跟着事务开展,人们开始往 “粘性调查者” 回调中增加各种控件烘托,
假如同一控件实例(比如 textView)出现在不同粘性调查者回调中:
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 的完成
函数式编程思维
函数式编程的中心主要是纯函数,这种函数只需 “参数列表” 这仅有进口来传入初值,只需 “回来值” 这仅有出口来回来成果,且 “运算进程中” 不调用和影响函数效果域外的变量(也即 “无副效果”),
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,反映当时页面的一切状况。
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.履行事务的进程,总是先从数据层获取数据,然后依据状况分流和回推成果,例如恳求成功,便履行 Success 来回推成果,恳求失利,则 Error,对此业界普遍的做法是,增设一个 Actions,
并且由于 UiStates 的字段不行变,且控件会集呼应 UiStates,也即必须保证 UiStates 的延续,由此每个事务带来局部改动时(partialChange),需经过 copy 等办法,将上一次的 UiStates 拷贝一份,并为对应字段注入 partialChange。这个进程业界称为 reduce。
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() }
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 ,依据本次注入的声明树自行在内部差分兼并烘托新内容。
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 合适与 Jetpack Compose 调配完成 “现代化的开发形式”,
反之如寻求 “低成本、复用、安稳”,可经过遵从 “单一责任准则” 从源头把问题消除。
相关资料
呼应式编程:ReactiveX
函数式编程:函数式编程 – 百科
MVI 纯函数图例:Reactive Apps with Model-View-Intent – Part 2: View and Intent
经过 UiStates 办理 UiEvent:界面事情 | Android Developers
平替计划探索:处理 MVI 架构实战痛点