业务架构的救世主是 MVI(一)

12,343 阅读12分钟

本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!

复杂度

Android 架构演进系列是围绕着复杂度向前推进的。

软件的首要技术使命是“管理复杂度” —— 《代码大全》

因为低复杂度才能降低理解成本和沟通难度,提升应对变更的灵活性,减少重复劳动,最终提高代码质量。

架构的目的在于“将复杂度分层”

复杂度为什么要被分层?

若不分层,复杂度会在同一层次展开,这样就太 ... 复杂了。

举一个复杂度不分层的例子:

小李:“你会做什么菜?”

小明:“我会做用土鸡生的土鸡蛋配上切片的番茄,放点油盐,开火翻炒的番茄炒蛋。”

听了小明的回答,你还会和他做朋友吗?

小明把不同层次的复杂度以不恰当的方式揉搓在一起,让人感觉是一种由“没有必要的具体”导致的“难以理解的复杂”。

小李其实并不关心土鸡蛋的来源、番茄的切法、添加的佐料、以及烹饪方式。

这样的回答除了难以理解之外,局限性也很大。因为它太具体了!只要把土鸡蛋换成洋鸡蛋、或是番茄片换成块、或是加点糖、或是换成电磁炉,其中任一因素发生变化,小明就不会做番茄炒蛋了。

再举个正面的例子,TCP/IP 协议分层模型自下到上定义了五层:

  1. 物理层
  2. 数据链路成
  3. 网络层
  4. 传输层
  5. 应用层

其中每一层的功能都独立且明确,这样设计的好处是缩小影响面,即单层的变动不会影响其他层。

这样设计的另一个好处是当专注于一层协议时,其余层的技术细节可以不予关注,同一时间只需要关注有限的复杂度,比如传输层不需要知道自己传输的是 HTTP 还是 FTP,传输层只需要专注于端到端的传输方式,是建立连接,还是无连接。

有限复杂度的另一面是“下层的可重用性”。当应用层的协议从 HTTP 换成 FTP 时,其下层的内容不需要做任何更改。

引子

该系列的前三篇结合“搜索”这个业务场景,讲述了不使用架构写业务代码会产生的痛点:

  1. 低内聚高耦合的绘制:控件的绘制逻辑散落在各处,散落在各种 Activity 的子程序中(子程序间相互耦合),分散在现在和将来的逻辑中。这样的设计增加了界面刷新的复杂度,导致代码难以理解、容易改出 Bug、难排查问题、无法复用。
  2. 耦合的非粘性通信:Activity 和 Fragment 通过获取对方引用并互调方法的方式完成通信。这种通信方式使得 Fragment 和 Activity 耦合,从而降低了界面的复用度。并且没有一种内建的机制来轻松的实现粘性通信。
  3. 上帝类:所有细节都在界面被铺开。比如数据存取,网络访问这些和界面无关的细节都在 Activity 被铺开。导致 Activity 代码不单纯、高耦合、代码量大、复杂度高、变化源不单一、改动影响范围大。
  4. 界面 & 业务:界面展示和业务逻辑耦合在一起。“界面该长什么样?”和“哪些事件会触发界面重绘?”这两个独立的变化源没有做到关注点分离。导致 Activity 代码不单纯、高耦合、代码量大、复杂度高、变化源不单一、改动影响范围大、易改出 Bug、界面和业务无法单独被复用。

详细分析过程可以点击下面的链接:

  1. 写业务不用架构会怎么样?(一)
  2. 写业务不用架构会怎么样?(二)
  3. 写业务不用架构会怎么样?(三)

紧接着又用了三篇讲述了如何使用 MVP 架构对该业务场景的重构过程。MVP 的确解决了一些问题,但也引入了新问题:

  1. 分层:MVP 最大的贡献在于将界面绘制与业务逻辑分层,前者是 MVP 中的 V(View),后者是 MVP 中的 P(Presenter)。分层实现了业务逻辑和界面绘制的解耦,让各自更加单纯,降低了代码复杂度。
  2. 面向接口通信:MVP 将业务和界面分层之后,各层之间就需要通信。通信通过接口实现,接口把做什么和怎么做分离,使得关注点分离成为可能:接口的持有者只关心做什么,而怎么做留给接口的实现者关心。界面通过业务接口向 Presenter 发出请求以触发业务逻辑,这使得它不需要关心业务逻辑的实现细节。Presenter 通过 view 层接口返回响应以指导界面刷新,这使得它不需要关心界面绘制的细节。
  3. 有限的解耦:因为 View 层接口的存在,迫使 Presenter 得了解该把哪个数据塞给哪个 View 层接口。这是一种耦合,Presenter 和这个具体的 View 层接口耦合,较难复用于其他业务。
  4. 有限内聚的界面绘制:MVP 并未向界面提供唯一 Model,而是将描述一个完整界面的 Model 分散在若干 View 层接口回调中。这使得界面的绘制无法内聚到一点,增加了界面绘制逻辑维护的复杂度。
  5. 困难重重的复用:理论上,界面和业务分层之后,各自都更加单纯,为复用提供了可能性。但不管是业务接口的复用,还是View层接口的复用都相当别扭。
  6. Presenter 与界面共存亡:这个特性使得 MVP 无法应对横竖屏切换的场景。
  7. 无内建跨界面(粘性)通信机制:MVP 无法优雅地实现跨界面通信,也未内建粘性通信机制,得借助第三方库实现。
  8. 生命周期不友好:MVP 并未内建生命周期管理机制,易造成内存泄漏、crash、资源浪费。

详细分析过程可以点击下面的链接:

  1. MVP 架构最终审判 —— MVP 解决了哪些痛点,又引入了哪些坑?(一)
  2. MVP 架构最终审判 —— MVP 解决了哪些痛点,又引入了哪些坑?(二)
  3. MVP 架构最终审判 —— MVP 解决了哪些痛点,又引入了哪些坑?(三)

再然后用了两篇讲述了 MVVM 架构是如何解决 MVP 的痛点:

  1. ViewModel 的引入使得“有免死金牌的业务层”成为可能,也使得跨界面之间的业务逻辑共享以及通信变得轻松。
  2. LiveData 的引入使得业务层成为数据持有者以数据驱动刷新界面,还避免了生命周期问题以及内存泄漏风险。
  3. 因为数据持有者,MVVM 也引入了新的复杂度,首先是不好处理的粘性数据问题,更棘手是更新数据的方法是带有副作用的,由此会引发界面状态不一致问题。

关于 MVVM 架构的详细分析可以点击下面的链接:

  1. “无架构”和“MVP”都救不了业务代码,MVVM能力挽狂澜?(一)
  2. “无架构”和“MVP”都救不了业务代码,MVVM能力挽狂澜?(二)

从这一篇开始,试着引入 MVI 架构的思想进行搜索业务场景的重构,看看是否能解决上述痛点。

在重构之前,再介绍下搜索的业务场景,该功能示意图如下:

1662106805162.gif

业务流程如下:在搜索条中输入关键词并同步展示联想词,点联想词跳转搜索结果页,若无匹配结果则展示推荐流,返回时搜索历史以标签形式横向铺开。点击历史可直接发起搜索跳转到结果页。

将搜索业务场景的界面做了如下设计:

微信截图_20220902171024.png

搜索页用Activity来承载,它被分成两个部分,头部是常驻在 Activity 的搜索条。下面的“搜索体”用Fragment承载,它可能出现三种状态 1.搜索历史页 2.搜索联想页 3.搜索结果页。

Fragment 之间的切换采用 Jetpack 的Navigation。关于 Navigation 详细的介绍可以点击Navigation 组件使用入门  |  Android 开发者  |  Android Developers

MVP & MVVM & MVI 架构图比对

MVI 和 MVP/MVVM 不是非此即彼的关系,它们是不同维度的。

MVP 和 MVVM 关心的是业务层的形态,MVP 中业务层用 Presenter 表达,如下图所示:

微信截图_20221215115554.png

而 MVVM 中业务层用 ViewModel 表达,如下图所示:

微信截图_20221216144711.png

MVI 不关心业务层形态,而是关心业务数据变换及流动的形态。

微信截图_20221215161701.png

将名词解释放在一边,先来看看架构图表达形式上的差异。

MVI 的图和其他两个相比有一个显著的区别,之前使用的是{},比如View{Presenter{}}表示 View 持有一个 Presenter。但 MVI 的架构图中使用的是(),表示一个函数,即 Model 是 Intent 的函数,View 是 Model 的函数。

函数 & 函数式编程

函数即两个集合之间的一种对应关系。若对集合 x 中的值施加法则 f 后都能唯一对应集合 y 中的一个值,则说 y 是 x 的函数,记为y = f(x)

这里的关键是唯一自变量 x 对应唯一应变量 y。在数学中这是简单的一元函数,在编程中这是一种低复杂度,低复杂度意味着不会出错。

把一元函数进一步具象化到界面刷新这个 case 上,可以表达为 “一个 Model 唯一对应一个界面状态”,记为view = f(model)

按照这个思想回看一下 MVVM 架构中的函数关系(同样的问题也存在于 MVP 中),界面的状态有若干个LiveData<Model>表达,即 viewState = f(model1, model2, model3, ...),其中任何一个 model 都可以独立发生变化,而任一 model 变化后,都会引起 viewState 的变化,即所有 model 的任一排列组合与一个 viewState 对应。这个复杂度就很高,出错的概率就很大,这样的错误称为界面状态不一致,即界面状态和你预想的不一样,因为有一种排列组合没有考虑到。关于实战中界面状态不一致的实例分析可以点击“无架构”和“MVP”都救不了业务代码,MVVM能力挽狂澜?(一)

MVI 把函数对应关系做到了极致,它把界面刷新的整个流程都表达成了一元函数。首先界面发起的动作被抽象为数据 Intent,Intent 的函数是 Model,即model = f1(intent),表示任一业务动作会产生唯一对应的 model,紧接着任一 Model 对应唯一界面状态,即view = f2(model)

f1 和 f2 这两个一元函数描述了业务意图、数据、界面状态之间一一对应的关系,在需求文档确定下来的同时,这一一对应的关系就已固定下来。

将函数的思想应用到编程,就产生了函数式编程

函数式编程是一种编程范式,即关于如何编写程序的方法论。它的主要思想是把运算过程尽量写成一系列嵌套的函数调用。

这种编程范式最大的好处是没有副作用。副作用是指函数内部与外部互动,产生运算以外的其他结果。最典型的情况是修改全局变量。关于 MVVM 中的副作用详解可以点击“无架构”和“MVP”都救不了业务代码,MVVM能力挽狂澜?(二)

而函数式编程中只有输入参数和返回值,不修改全局变量。从耦合的角度来看,函数式编程中的函数只包含运算且不与任何东西耦合,这使得它复杂度低、运行结果可预测、易于单元测试、调试。函数式编程是 MVI 架构相较于其他架构的一个显著不同点。

综上,使用 MVI 架构的开发过程即是:

运用函数式编程思想将需求翻译成业务意图(I)、数据(M)、界面状态(V)间的函数关系,再用响应式编程的方式将其串联成数据流的过程。

最后将函数串联的方式是响应式编程。它是一种面向数据流的编程范式。关于响应式编程的详细介绍可以点击Android 架构之 MVI 雏形 | 响应式编程 + 单向数据流 + 唯一可信数据源

除了编程范式上之外,MVI 架构还有一些其他的不同点,引用之前文章的总结:

MVI = 响应式编程 + 单向数据流 + 唯一可信数据源

关于这三条规范的剖析可以点击该系列文章:

  1. Android 架构之 MVI 雏形 | 响应式编程 + 单向数据流 + 唯一可信数据源
  2. Android 架构之 MVI 初级体 | Flow 替换 LiveData 重构数据链路
  3. Android 架构之 MVI 完全体 | 重新审视 MVVM 之殇,PartialChange & Reducer 来拯救
  4. Android 架构之 MVI 究极体 | 状态和事件分道扬镳,粘性不再是问题

其中的响应式编程会运用 Kotlin Flow,关于它的详细介绍可以点击:

  1. Kotlin 异步 | Flow 应用场景及原理
  2. Kotlin 异步 | Flow 限流的应用场景及原理

总结

在具体分析 MVI 的实现细节之前,对其做一个概念性总结:

MVI 用数据流来理解界面刷新:界面是数据流的起点(生产者)也是终点(消费者),界面发出的数据叫意图,意图会用函数式编程的方式被变换为状态,最终状态通过响应式编程的方式流向界面,界面消费状态完成刷新。在这个流动的过程中,若保证了唯一可信数据源,就能实现单向数据流。

下一篇会基于搜索这个业务场景,详细展开 MVI 的实现细节。