注册
MVI

关于 MVI,我想聊的更明白些

前言


谈到 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 搭配实现 “现代化的开发模式”,


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


作者:KunMinX
链接:https://juejin.cn/post/7145317979708735496
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

0 个评论

要回复文章请先登录注册