往期回顾,专栏目录,更新动态,优惠政策,版权须知
温馨提示:如这是第一次接触《重学安卓》,可通过上述链接快速了解《重学安卓》专栏,获取 专栏目录、试读内容、更新动态 和 发展状况。
截至目前,专栏已对 体系化文章 实施 3310 余次修订,数十位群友告诉我,受专栏启发 亦开启了写作之路。群里不定期会有小伙伴讨论适配问题、分享原创开源库 和 提供内推机会,订阅后可随时进群交流。
前言
谈到 MVI,相信大家略有耳闻,由于该架构有一定门槛,导致开发者要么完全理解,要么完全不理解。
并且由于存在门槛,理解的开发者往往受 “知识的诅咒”,很难体会不理解的人困惑之所在,也即容易在分享时遗漏关键点,这也使得该技术点的普及和传播更加困难。
为此这期专为 MVI 打磨一篇 “通俗易懂、看完便理解来龙去脉、并能自行判断什么时候适用、是否非用不可”,相信阅读后你会耳目一新。
文章目录一览
- 前言
- 响应式编程
- 为何要用响应式编程
- 意想不到的问题
- 响应式编程的潜规则
- 响应式编程的困境
- MVI 的存在意义
- MVI 的实现
- 函数式编程
- 为什么用函数式编程
- MVI 和函数式编程的关系
- MVI 怎样实现纯函数效果
- 控件渲染算不算副作用
- 存在哪些副作用
- 三个现实问题
- 1.通过 diff 机制改善性能
- 2.通过 copy 使状态延续
- 3.通过 Action 将结果分流
- MVI 整体流程
- MVI 额外的好处
- 当下开发现状的反思
- 从源头把问题消灭
- 边界感缺失,如何避免
- 平替方案的探索
- 综上
响应式编程
谈到 MVI,首先要提的是 “响应式编程”,响应式是 Reactive 翻译成中文叫法,对应 Java 语言实现是 RxJava,
ReactiveX 官方对 Rx 框架描述是:使用 “可观察流” 进行异步编程的 API,
用人话翻译一下即,响应式编程暗示人们 应当总是向数据源请求数据,然后在指定的观察者中响应数据的变化,也即数据动,控件动,数据不动,控件不动 —— 控件的渲染总是以数据源的数据为指导,跟随数据的变化而变化,
常见的 “响应式编程” 流程用伪代码表示如下:

为何要用响应式编程
通过上述代码可以发现,在响应式编程下,业务逻辑在 ViewModel / Presenter 处集中管理,处理过程中通知 Activity 渲染 Loading 或是 Text 等状态,
由于控件只在指定的观察者中响应,所以 这种模式下很容易做单元测试,有输入必有回响。反之如像往常一样,将控件渲染的代码分散在观察者以外的各个方法中,便很难做到这一点。
从开发体验的角度来说,响应式编程实现了 “关注点分离”,也即 ViewModel 只关注业务逻辑,Activity 只关注渲染。不过这不是重点。
意想不到的问题
注:LiveData 是效仿响应式编程 BehaviorSubject 粘性观察者的设计。环境变化(例如旋屏重建)时,托管于 Jetpack ViewModel 的 LiveData 都会自动回推最后一次数据。
以下以 LiveData 为例。
随着业务发展,人们开始往 “粘性观察者” 回调中添加各种控件渲染,
如果同一控件实例(比如 textView)出现在不同的粘性观察者回调中:

假设用户操作使得 textView 先接收到 liveData_B 消息,再接收到 liveData_A 消息,那么旋屏重建后,由于 liveData_B 的注册晚于 liveData_A,textView 被回推的最后一次数据反而是来自 liveData_B,
给用户的感觉是,旋屏后展示老数据,不符预期。
响应式编程的潜规则
由此可得,响应式编程存在 1 个 “不显眼但重要” 的潜规则:
一个控件应当只从唯一来源获取数据,也即每个控件的渲染代码应放在唯一一个观察者回调中,响应数据源发来的数据。
响应式编程的困境
但这么做会有什么问题呢,首先每个页面控件往往多达十数个,等于说观察者就得配上十数个,也即观察者爆炸,
并且随着业务发展,开发者很容易误往多个粘性观察者中添加同一控件实例,导致上文提到的 “回推不符预期数据” 问题,
为统一解决这两个问题,人们想到可以将数据聚合到一个 LiveData 中,然后页面所有控件都在这唯一的 LiveData 观察者中响应 —— 至此有人反应过来:“这不就是 MVI 么”。
MVI 的存在意义
经过上文的铺垫易知,MVI 是 在响应式编程背景下,为解决 “多个粘性观察者回推不符预期数据” 萌生的更优解,
通过将页面状态数据聚合,使所有控件在同一个观察者中响应数据的变化。
具体该如何实现?对此业内有个简单粗暴的办法,即遵循 “函数式编程思想” ——
MVI 的实现
函数式编程
函数式编程的核心主要是纯函数,即 “唯一入口 + 唯一出口 + 无副作用”,用人话来说,即
这种函数只有 “参数列表” 这一个入口来传入初值,只有 “返回值” 这一个出口来返回结果,且 “运算过程中” 不调用和改变函数作用域外的变量,
举个例子:

易得 calculate 和 changeB 运算过程中 未调用和改变 “函数作用域外的变量”,changeA 运算过程中调用和改变了外部变量 a,也即产生副作用,所以前二者是纯函数,changeA 不是。
为什么用函数式编程
根据上述分析易得,纯函数可以闭着眼使用,因为有怎样的输入,就会有怎样的输出,也即不会有不符预期的输出。
MVI 和函数式编程的关系
许多文章喜欢借用网上这张图来说明 MVI 中 M、V、I 的关系,其实这图看看就好了,
MVI 并非真的纯函数实现,而只是 “纯函数思想” 的实现,目的是借助 “纯函数” 的 “纯”,来消除输出结果的不符预期。
也即我们实际上都是以面向对象的方式在编程,所以只要能从效果上达到纯函数,就能算是 “纯”,
反之如果钻牛角尖,看什么都 “有副作用、不纯”,则容易画地为牢陷入悲观,忽视本可改善的环节和举措,有点得不偿失。
MVI 怎样实现纯函数效果
在 MVI 架构中,包含 Intent、Model、View 这三个角色,
Model 通常是继承 Jetpack ViewModel 来实现,负责处理业务逻辑;
Intent 是指发起本次请求的意图,告诉 Model 本次执行哪个业务。它可以携带或不带参数;
View 通常对应 Activity/Fragment,根据 Model 返回的 UiStates 进行渲染。
这里通过一张图来表示大体流程:

也即我们让 Model 只暴露一个入口,用于输入 intent;只暴露一个出口,用于回调 UiStates;并且 UiStates 的字段都设置为不可变(final / val),如此即达成 Model 的 “纯”,
那有人可能会问,Intent 和 View 的 “纯” 怎么达成呢?
Intent 达成 “纯” 比较简单,由于它只是个入参,所以字段都设置为不可变即可。
View 同样不难,只要确保 View 的入口就是 Model 的出口,也即 View 的控件都集中放置在 Model 的 output 回调中渲染,即可达成 “纯”。
控件渲染算不算副作用
那么有人可能会说,“不对啊,View 在入口中调用了控件实例,也即函数作用域外的成员变量,是副作用呀” …… 个人认为这是误解,
因为 MVI 的 View 从事实上来说就不是一个函数,而是一个类。前面我们已经说了,MVI 实际上是 通过面向对象编程的方式实现 “纯函数” 效果,而非真的纯函数,
所以我们可以站在类的角度重新审视 —— 控件是类成员,对应的是纯函数的自动变量,伪代码描述即:

换言之,控件渲染并没有调用和影响到 View 以外的元素,所以不算副作用。
事实上 DataBinding 就是这么做的,通过自动生成代码,来防止控件实例在使用过程中被篡改。
当然这要求开发者不去使用 binding 实例调用控件,否则 DataBinding 的努力前功尽弃。