关于 MVI,深入了解一下

Posted 初一十五啊

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了关于 MVI,深入了解一下相关的知识,希望对你有一定的参考价值。

前言

谈到 MVI,相信大家略有耳闻,由于该架构有一定门槛,导致开发者要么完全理解,要么完全不理解。

且由于存在门槛,理解的开发者往往受 “知识的诅咒”,很难体会不理解的人困惑之所在,也即容易在分享时遗漏关键点,这也使得该技术点的普及和传播更加困难。

故这期专为 MVI 打磨一篇 “通俗易懂、看完便理解来龙去脉、并能自行判断什么时候适用、是否非用不可”,相信阅读后你会耳目一新。

文章目录一览

  • 前言

  • 响应式编程

    • 响应式编程的好处
    • 响应式编程的漏洞
    • 响应式编程的困境
  • MVI 的存在意义

  • MVI 的实现

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

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

一丶响应式编程

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

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

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

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

1.1.响应式编程的好处

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

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

1.2.响应式编程的漏洞

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

如果同一控件实例(比如 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_AtextView 被回推的最后一次数据反而是来自 liveData_B

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

1.3.响应式编程的困境

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

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

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

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

二丶MVI 的存在意义

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

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

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

三丶MVI 的实现

3.1.函数式编程思想

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

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

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

这里贴一张网上盛传的图来说明 ModelViewIntent 三者关系,

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

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

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

3.2.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 的回调中渲染,即可达成 “纯”。

3.3.存在哪些副作用

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

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

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

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

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

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

不过这是符合预期的副作用,对此官方 Guide 也有介绍 “将 UiEvents 整合到 UiStates” 的方式来改善该副作用

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

3.4.整体流程

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

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

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

4.1.从源头把问题消灭

现实情况往往复杂。

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

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

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

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

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

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

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

常见 BehaviorSubject 实现有 ObservableFieldLiveDataStateFlow 等。

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

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

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

4.3.平替方案的探索

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

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

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

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

五丶综上

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

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

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

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

作者:KunMinX
链接:https://juejin.cn/post/7145317979708735496
来源:稀土掘金

其他资料

1.腾讯Android开发笔记+2022Android十一位大厂面试真题+音视频60道面试题+Jetpack+Matrix+JVM
2.腾讯Android开发笔记大全
3.2022Android十一位大厂面试真题-附答案
4.60道经典音视频面试

以上是关于关于 MVI,深入了解一下的主要内容,如果未能解决你的问题,请参考以下文章

对应用架构的理解与 MVI 介绍

关于Android架构:MVI + LiveData + ViewModel | ProAndroidDev

“深入浅出”来解读Docker网络核心原理

深入理解javascript原型和闭包(13)-作用域和上下文环境

Rust网络编程框架-深入理解Tokio中的管道

Rust网络编程框架-深入理解Tokio中的管道