MVC(既当爹又当妈的痛苦)
MVC的缺点就不啰嗦了,来张图总结一下,你看着图上画的挺好挺清楚,实际上它们都在一个Activity类里面呢,互相纠缠在一起,就问你怕不怕。(说出来你可能不信,我在项目中曾经有幸见过超过2000行业务代码的Adapter类,你猜里面都干了啥)
关于MVP架构的心得总结 (过去式)
这部分重点总结一下项目中实践的一些心得和思考,先来简单回忆一下MVP架构的分层:
V层和P层交互:
P层和M层交互:
-
P层调用M层的接口,iUserInfo.login(callback), 在回调接口中,P层调用V层接口处理,其中iUserInfo.login()中调用HttpClient.get()/post()方法
-
P层直接调用M层的统一数据管理类DataManager.getData()这种是同步请求本地数据(当然直接同步IO读取是不可取的,但是有时我们会因为耗时任务太短而忽略这一点,比如主线程读取一个sp中的值)
还有一种是添加叫做契约类的接口,其实也是接口约束,P层实现的契约接口有request() response(T)方法,在request()中调用Model层接口请求,在response(T)中调用View的接口刷新。(这样写接口比传统写法还要多)还有更麻烦的是在P层与M层又定义了一层转发层,而这一层与P和M的交换也是通过接口实现。(这样写接口就更多了。。)
其实不管怎么写,本质上是一样的,都是通过具体的业务接口来约束。
这种看似很完美,但实际上存在很严重的问题,那就是即便是一个非常简单的功能(如登录一下),就需要写一大堆的接口类,导致调用链增长,调试麻烦,类爆炸、包体积增大,
如果是完全没接触过MVP概念的新手,会觉得你在搞鸡毛啊,认为你在瞎JB搞。。。
解决MVP中的接口类过多带来的烦恼:
这样任何业务请求通过统一getData()方法去请求,任何回调UI层的通过setDataToView()方法来设置View,业务的区分主要通过 常量来区分,然后开发者在自己的业务类中根据不同常量去分发处理,可以自己写不同的方法去实现具体的View设置代码。
也就是说 由大量的接口约束,变成了仅仅实现基类接口的两个固定方法+常量约束
也就是说P层可能不是只有绑定到某个具体页面才行,它在实际业务中甚至可以给任意页面回调数据。如果按照传统的MVP接口约束,上来就要先创建一些接口,哪个页面需要接受P回传的数据,哪个页面就应该实现对应的View层的具体业务接口,显然这种约束性太强(比如某个页面发起P层请求后,P层要给10个页面回传数据,不可能10个页面都去实现一遍接口)。
这时结合方案1中的就很有优势了,因为只要应用中的所有View控件、Activity基类、Fragment基类等都实现了IBaseView,就可以在P层中直接发送消息来处理,接受页面只需要在固定方法中根据常量来接受属于自己的业务数据,而不需要实现一个接口。
总结以上2种方案就是在宏观上遵守MVP的整体思想,但是在具体细节上进行调整,减少大量无必要的接口类,虽然在细节上牺牲了接口约束的设计原则,但是它却使得业务实现变得更加的简单、高效、快速,改造成本非常低。其实作为App端的业务,不像后端业务那样庞大,本身不需要太复杂的架构,在设计时也可以完全不拘泥于某些无意义的条条框框。
一些细节:
-
1)在P层调用统一方法 iBaseView.setDataToView(int code,Object data)回调UI层处理时,这里的数据data并没有使用泛型,因为是所有人共用的方法,如果使用泛型就得分开接口类设计,不同的页面实现不同的接口类,在不同的接口类上持有的泛型不同,这样其实又绕回去了。这一步可以改成泛型实现,如果传泛型data,使用时不用强转,但是在同一个页面中请求返回的可能是不同的实体类,在使用一个统一方法的前提下,是无法区分不同的业务实体类的,这也算是一种细节上的妥协和牺牲,但是问题不大。
-
2)P层持有的V层接口成员如iLoginView应该是弱引用 WeakReference,防止P层持有View层实例导致 内存泄漏
-
3) 关于P层与M层的交互,个人更倾向于在M层向外暴露回调callback,P层中实现这个callback处理,而不是在M层自己的内部去调用P层的接口,这是两种不同的实现模式,前者可以保证M层是干净的,因为它不会依赖任何P层的类,这样就很方便的做成一个独立的Module或插件,甚至可以在MVC的页面中也可以调用,而后者会依赖P层的接口,也就是说它的处理接口只会交给P层接口,这样专门为P而生为P而死,无法独立存在和复用。
MVVM(被抛弃的人)
正如前面提到的,在MVP中最严重的问题就是需要定义的接口类太多,不管你怎么搞这点终究是绕不过去(即便是我以其它方式绕开了接口类的增加,也会导致其它的代价,比如常量的增加和主观约束,以一种牺牲换取另一种便利也是无奈之举),这是由于模式本身的定位思想是职责分离,而不同职责角色之间的交互最符合OO设计原则的就是接口隔离,面向接口的设计本身没有问题,但是用在MVP中却显得过于繁琐,这也是很多新接触MVP的人对它非常抵触的原因。
Google之前推出了MVVM,基于数据绑定DataBinding方式的架构,很大程度上解决了这一痛点,但是仍有不足(例如其中一点就是添加了依赖之后每个页面都会自动生成一个DataBinding,不管你用不用)。
MVVM 模式将 Presenter 改名为 ViewModel,概念上基本上与 MVP 模式完全一致,换汤不换药。唯一的区别是,如果采用双向数据绑定(data-binding)来实现View层和ViewModel层之间的交互,那么Model的变动,自动反映在 View,反之亦然。
MVVM与MVP的主要区别在于,你不用去主动去刷新UI了,只要Model数据变了,会自动反映到UI上。换句话说,MVVM更像是自动化的MVP。
MVVM中的View和ViewModel二者之间的交互主要通过双向数据绑定DataBinding实现,但即便没有 DataBinding 也照样可以使用 MVVM 架构,比如说借用 LiveData, RxJava, Flow 等。主要是能够实现基于观察者与被观察者的设计模式即可。
MVI ? ??
看到这个单词先不要慌,相信很多开发者尤其是Android开发者都有同感,对一些感到陌生的名词会有点恐慌,因为不认识它,不知道它是什么意思,就代表着它是一种新的技术,那就意味着又要开始学(内)习(卷)了。(因为要学习的东西太多了)
其实由于某些原因,DataBinding方式的开发方式在国内并没有普及,而现在,Google也选择了自我放弃,转而又推出了 基于单向数据流的架构(所以有人称它为 MVI?就是这个)。但是Google官方并没有给出明确的定义,说这种就叫做MVI,所以名词之类的,只不过是大家自己YY罢了。
其主要分为以下几部分
- Model: 与MVVM中的Model不同的是,MVI的Model主要指UI状态(State)。例如页面加载状态、控件位置等都是一种UI状态
- View: 与其他MVX中的View一致,可能是一个Activity或者任意UI承载单元。MVI中的View通过订阅Model的变化实现界面刷新
- Intent: 此Intent不是Activity的Intent,用户的任何操作都被包装成Intent后发送给Model层进行数据请求
有的文章中将这种基于 单向数据流的架构称为MVI,这里的 I 代表的是Intent, 中文是意图的意思,跟启动Activity的Intent意思差不多。这里只要知道是 单向数据流这个概念就可以了。
单向数据流
MVI强调数据的单向流动,主要分为以下几步:
-
用户操作以Intent的形式通知Model
-
Model基于Intent更新State
-
View接收到State变化刷新UI。
数据永远在一个环形结构中单向流动,不能反向流动:
相信有的人还是很难理解,这是因为你还是按照三个角色去理解它,不要被网友创造的单词MVI给迷惑了,MVI中的 I 在这里其实并不能算是一直角色,它是一种动作,一种意图,更直白的说就是View控件上的事件触发,如点击事件啊,文本框的onTextChanged啊。也就是说,实际上这种单向数据流的架构只有2层,非常简单。看一下官方的介绍你就明白了。
以下是摘自官方的部分介绍
Google推荐架构图(目前最新)
其中,中间网域层可选。(也就是说中间这层是可以拿掉的,拿掉之后,不就是两层了吗)
界面层
界面层由以下两部分组成:
数据层的作用是存储和管理应用数据,以及提供对应用数据的访问权限,因此界面层必须执行以下步骤:
-
获取应用数据,并将其转换为UI可以轻松呈现的UI State。
-
订阅UI State,当页面状态发生改变时刷新UI
-
接收用户的输入事件,并根据相应的事件进行处理,从而刷新UI State
-
根据需要重复第 1-3 步。
数据层
数据层主要负责获取与处理数据的逻辑,数据层由多个Repository组成,其中每个Repository可包含零到多个Data Source。您应该为应用处理的每种不同类型的数据创建一个Repository类。例如,您可以为与电影相关的数据创建 MoviesRepository 类,或者为与付款相关的数据创建 PaymentsRepository 类。当然为了方便,针对只有一个数据源的Repository,也可以将数据源的代码也写在Repository,后续有多个数据源时再做拆分。
架构原则中最重要的设计原则就是分离关注点,因此单向数据流也是为了实现关注点分离这一原则。
架构原则最佳实践:
- 不要将数据存储在应用组件中。请避免将应用的入口点(如 Activity、Service 和广播接收器)指定为数据源。相反,您应只将其与其他组件协调,以检索与该入口点相关的数据子集。每个应用组件存在的时间都很短暂,具体取决于用户与其设备的交互情况以及系统当前的整体运行状况。
- 减少对 Android 类的依赖。您的应用组件应该是唯一依赖于 Android 框架 SDK API (例如 Context 或 Toast)的类。将应用中的其他类与这些类分离开来有助于改善可测试性,并减少应用中的耦合。
- 在应用的各个模块之间设定明确定义的职责界限。例如,请勿在代码库中将从网络加载数据的代码散布到多个类或软件包中。同样,也不要将不相关的职责(如数据缓存和数据绑定)定义到同一个类中。遵循推荐的应用架构可以帮助您解决此问题。
- 尽量少公开每个模块中的代码。例如,请勿试图创建从模块提供内部实现细节的快捷方式。短期内,您可能会省点时间,但随着代码库的不断发展,您可能会反复陷入技术上的麻烦。
- 专注于应用的独特核心,以使其从其他应用中脱颖而出。不要一次又一次地编写相同的样板代码,这是在做无用功。 相反,您应将时间和精力集中放在能让应用与众不同的方面上,并让 Jetpack 库以及建议的其他库处理重复的样板。
- 考虑如何使应用的每个部分可独立测试。例如,如果使用明确定义的 API 从网络获取数据,将会更容易测试在本地数据库中保留该数据的模块。如果您将这两个模块的逻辑混放在一处,或将网络代码分散在整个代码库中,那么即便能够进行有效测试,难度也会大很多。
- 保留尽可能多的相关数据和最新数据。这样,即使用户的设备处于离线模式,他们也可以使用您应用的功能。请记住,并非所有用户都能享受到稳定的高速连接 - 即使有时可以使用,在比较拥挤的地方网络信号也可能不佳。
从官网的介绍中可以得知,这种单向数据流的设计,主要是为了实现分离关注点的架构设计原则。如果只从分层上看,它足够简单,在层次划分上,几乎抹平了以前的所有角色,只分为两层:UI层和数据层(回归到本质了有木有),由ViewModel来承担数据业务的管理。但是对状态的抽象和管理才是这种架构中最难的部分。
JetpackCompose :摒弃过去,面向未来
Google目前在Android端主推的 Jetpack Compose声明式UI框架正是基于 单向数据流的架构模型的一种具体实现,在这种架构中,只有两种角色UI和数据,但影响UI更新的只能是状态,普通的数据是不能直接影响UI的,必须变成 mutableState(可被观察的可变状态)才行。注意 mutable这个单词是可变的,易变的意思,有的文章中连单词都翻译错了。
为什么是可变的?
- 因为View控件要修改啊,文本框中输入了文字后要让它(数据)变化啊。
为什么是可被观察的?
- 如果不可被观察,我(View)怎么知道你(数据)变了呢?要知道,我(View)可是一个观察者。(观察者模式)
只有变成了mutableState这样之后,UI产生的事件对数据造成的变化会反馈到承载数据的状态容器中(这点需要我们写代码时去遵循),而状态容器中对状态(数据)的修改也会自动反馈的UI上(这点是Compose框架为我们完成的),如此循环往复,形成一个环型单向流。
(其实这背后的本质还是观察者模式的应用,但不管怎样,在声明式的UI框架中,如React、Flutter等,都是由状态驱动UI界面更新,这种思想大家是统一的)
之所以这么设计,是由于其他声明式的UI框架都是这样设计的,并不是Google独有的发明。
以前在项目中实践 MVP框架时,我就曾经思考过这样的问题: 那些与UI相关的操作究竟到底应该放在哪里更加合适?要知道逻辑一般分为两种: 一是UI无关的纯业务逻辑(也就是说你拿掉UI层它能正常工作), 二是UI相关的业务逻辑(比如界面跳转,点击事件后处理各种UI控件的状态,原则上只要存在if-else类的分支语句就一定会存在逻辑),这一部分比较适合放在Presenter层中,但是全部放在P层中,又会细节太多,会向P层添加太多的方法,导致来回在V和P之间操作太过于细节的UI逻辑,为此我甚至曾经一度陷入纠结与自我怀疑之中,认为自身对这种架构逻辑的把握还不是特别的到位。
但是,即使在Google推出的Compose设计中,显然它也依然会存在这个问题, 还好Google设计了Composable可组合函数,那么UI相关的业务逻辑就有了它很好的去处,也就是可组合函数之中,其实Composable函数很像React中的JSX组件,类似于函数组件这样的概念,熟悉React的人非常容易接受它。Google设计的Composable函数直接就是一个kotlin函数,而不像React、Flutter那样会有函数式组件VS类组件的口水之战。但是Compose中也有比较困难的点,就是对state的抽象,会比以前要更加的抽象,因为不仅仅是纯业务数据,如data class,还有UI相关的state,也要尽可能的抽象成mutableState,这往往更考验人的思考能力。只有将这两种state抽离出来,剩下的部分才好复用、或者测试,用官方的话说就是 状态提升。
在MVP中,大家强调的是 职责按层分离(V层和M层不会直接交互),但是在Compose这样的声明式UI框架中,又将它们混合了起来(ViewModel和Composable函数是会交互的),这种演化非常有意思。这是因为在UI开发一类的业务中,状态逻辑、数据和UI本来就是紧密相关的,它们是耦合的,你无法百分之百将它们各自分开来,但是你只需要把UI组件无关的玩意都挑出来进行抽象即可( State)。
这里需要注意的一点是,Google官方并没有强制要求 状态的容器一定是 ViewModel,它只是一种推荐,在官方的介绍中,ViewModel被用作 页面级别的状态容器,如果是可组合的子项,一般不应该是ViewModel,更常见的应该是通过 remember 声明的 mutableState状态数据,因为ViewModel的生命周期是对标Activity/Fragment,而如果让一个页面中的某个可组合的子项持有比它生命周期还长的ViewModel是一件很危险的事。
再来谈一下MVP中的那些细节问题在Compose中是否还会存在,首先是数据传递的问题,由于Composable函数是直接调用 ViewModel暴露出来的状态数据类或者是 remember 声明的 mutableState状态数据,所以没有这个问题,kotlin中有个强大的功能就是你定义的一切变量编译器都会进行自动类型推导,也不存在不知道是哪个类型的问题(你不必担心在前面的函数中用var定义了对象,传到了后面的函数中用var接受就点不出来成员了)。再者内存泄漏问题,借助kotlin协程(官方提供的各种xxxScope中启动协程), 比如viewModelScope这样的协程作用域中去调用挂起函数执行耗时任务,即便此时页面退出也不用担心,因为协程会自动取消。
可以预见,Google对Android的未来规划应该是面向Composable编程,对齐现代声明式UI框架,传统的View/ViewGroup体系将逐步被抛弃,在不久的未来,Android将会彻底和xml布局说再见,对于新的Compose UI要想完美的使用它并不容易,尤其是以性能更优的方式,所以还需继续努力学习。
参考文章:
Google 推荐使用 MVI 架构?卷起来了~
一文了解MVI架构,学起来吧~
MVVM 进阶版:MVI 架构了解一下~
应用架构指南
Compose 编程思想
状态和 Jetpack Compose
如果有正在学习Compose的道友找不到资料的,推荐一个书籍《Jetpack Compose 从入门到实战》这个是今年8月出版,目前找到的最新的了,作者都是大佬,内容质量应该不会差。不喜欢看书的可以看官网或者这个网站学习都行。如果你不喜欢看书也不喜欢看文章,而更喜欢看视频?那么你可以去B站搜索输入"Jetpack Compose"试试,会得到惊人的发现。