前言

学技术要学本质。对 MVI 的有用了解,建立在 “呼应式编程” 的 “效果和缝隙” 等要害细节的发掘,

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

文章目录一览

  • 前言
  • 呼应式编程
    • 呼应式编程的优点
    • 呼应式编程的缝隙
    • 呼应式编程的困境
  • MVI 的存在含义
  • MVI 的完成
    • 函数式编程思维
    • MVI 怎样完成纯函数效果
    • 存在哪些副效果
    • 全体流程
  • 当下开发现状的反思
    • 从源头把问题消除
    • 什么是过度规划,如何防止
    • 平替计划的探索
  • 综上

呼应式编程

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

ReactiveX 官方对 Rx 结构描绘是:运用 “可调查流” 进行异步编程的 API,

翻译成人话即,呼应式编程暗示人们 应当总是向数据源恳求数据,然后在指定的调查者中呼应数据的改动

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

MVI 的存在意义

呼应式编程的优点

经过上述代码易得,在呼应式编程下,事务逻辑在 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
}

清楚明了,纯函数的优点是 “能够闭着眼运用”,有怎样的输入,必有怎样的输出,且进程中不会有意料外的影响产生。

MVI 的存在意义

这儿贴一张网上盛传的图来说明 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 是给 “呼应式编程” 填坑的存在,经过状况聚合来消除 “不符预期回推、调查者爆破” 等问题,

但是 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 架构实战痛点