写在前面
讨论下一代开发模式的演化、优化方向和可能,不一定正确希望和感兴趣的读者交流。
任何模式的选择一定要根据当时的开发需要来决定。比如:实验性、迭代很快的简单需求,一般会先选择MVC尝试,待明确方向后,再考虑改为MVVM。
背景
首先,看下iOS首推的MVC模式。
- M:单纯的从网络获取回来的数据模型
- V:视图界面
- C:ViewController
ViewController负责View和Model之间调度,View发生交互事件会通过target-action或者delegate方式回调给ViewController,与此同时ViewController还要承担把Model通过KVO、Notification方式传来的数据传输给View用于展示的责任。
随着业务越来越复杂,视图交互越复杂,导致Controller越来越臃肿,负重前行。福报修多了的结果就是,不行了就重构,重构不了就换掉。
为了解决MVC带来的问题,MVVM出现了。
把View和Controller都放在了View层(相当于把Controller一部分逻辑抽离了出来),Model层依然是服务端返回的数据模型。而ViewModel充当了一个UI适配器的角色,也就是说View中每个UI元素都应该在ViewModel找到与之对应的属性。除此之外,从Controller抽离出来的与UI有关的逻辑都放在了ViewModel中,这样就减轻了Controller的负担。
从以上的架构图中,我们可以很清晰的梳理出各自的分工。
- View层:视图展示。包含UIView以及UIViewController,View层是可以持有ViewModel的。
- ViewModel层:视图适配器。暴露属性与View元素显示内容或者元素状态一一对应。一般情况下ViewModel暴露的属性建议是readOnly的,还有一点,ViewModel层是可以持有Model的。
- Model层:数据模型与持久化抽象模型。数据模型很好理解,就是从服务器拉回来的JSON数据。而持久化抽象模型暂时放在Model层,是因为MVVM诞生之初就没有对这块进行很细致的描述。按照经验,我们通常把数据库、文件操作封装成Model,并对外提供操作接口。(有些公司把数据存取操作单拎出来一层,称之为DataAdapter层,所以在业内会有很多MVVM的变种,但其本质上都是MVVM)。
- Binder:MVVM的灵魂。可惜在MVVM这几个英文单词中并没有它的一席之地,它的最主要作用是在View和ViewModel之间做了双向数据绑定。如果MVVM没有Binder,那么它与MVC的差异不是很大。
需要注意一点,View持有ViewModel,ViewModel不能持有View(即ViewModel不能依赖UIKit中任何东西)。有两个原因:一是为了ViewModel可测性,即单元测试方便进行;二是团队人员可分离开发。可总结为:更加干净的解耦。
存在的问题
MVVM公认的问题
- 学习成本高
- 绑定数据比较繁琐,容易引起crash
- 犹豫要不要引入RAC
问题1
任何新技术,新模式都是有学习成本,MVVM也不例外,但是并不是非常高,阅读相关文章,以及技术大咖做的demo,经过一两个需求就基本掌握了。
问题2
在不使用RAC的情况下,一般使用KVO、类KVO来解决,需要在初始时绑定,dealloc移除绑定,还要避免主动修改viewModel值造成UI的循环响应修改。的确,相对繁琐,但已经有其他开源方案可以解决,既方便,又安全。
问题3
(个人意见)如果非必需,建议不要引用RAC,有两个原因,一是因为RAC学习成本需要颠覆之前的开发方式;二是RAC对性能影响非常大,一个简单的数据响应也会生成非常多信号量。
问题不止这些
经过长时间的使用,发现MVVM的viewModel的确可以抽离V/VC中的业务逻辑,但是不同VM由于对外接口不统一,相似的业务,如果通过VM之间的继承,可以解决当时问题,但是随着迭代,判断逻辑越来越多;如果创建各自独立VM,部分能力又会重复造轮子。
例如,页面A某个功能需要,先判断登陆,再上传修改;页面B某个功能需要,先判断登陆,再查看信息。
方案一
viewModelB继承viewModelA,viewModelA声明一个nextActionAfterLoginCheck public方法。viewModelA登录判断后调用nextActionAfterLoginCheck,而viewModelB重写即可。
但后期需要页面A在登录增加封禁账号不能修改信息,那么页面A就不用调用nextActionAfterLoginCheck,但是页面B需要。这时可以再声明一个-(bool)shouldToDoNextAction
方法,在登录判断后通过这个方法获取A、B各自返回值,决定是否调用nextActionAfterLoginCheck,既保证nextActionAfterLoginCheck业务独立性(不涉及账户相关判断),又保证继承关系不变。但是登录模块代码引入了新的判断,VM臃肿化前兆现象。方案二
页面A、页面B各自拥有自己的viewModel,这样登录逻辑便重复,但是日后修改,两个页面独立,互不影响。
有没有更好的解决方案呢?
分析
对比一下前后功能
* 开始时,页面A、B所包含的功能:登录判断、上传修改、查看信息。
* 后期迭代,页面A增加:封禁用户判断。
表面上,造成VM臃肿隐患的原因,是既要保留公用登录模块的前提下,完成页面A新的业务需要,又要最小影响页面B,则需要增加判断完成。那进一步分析业务链路,发现最终的形态如下:
根本上,VM自身承担的业务定义、执行,造成了各个功能无法灵活组织,随时可变,每一个功能修改都有可能影响到其他业务。
解决方案
如果VM只承担业务定义呢?通俗一点的说法,V/VC告诉VM要做一个什么业务,VM定义这个业务需要哪几个功能,具体功能实现不在VM实现,那么VM在后期迭代中只需要更新定义即可,无非是增删改定义的几个功能而已。用这个思路分析上面case,既然不用实现功能,那么viewModelB就不用继承viewModelB,两个页面独立,viewModelA业务定义:登录判断、封禁判断、上传修改,viewModelB业务定义:登录判断、查看信息。如此分析,可以发现,每个功能都被拆分成了相对“干净”的最小功能,那么这些功能在哪里实现呢?暂且叫它业务中心(Service)吧,新MVVMS模型图如下:
技术调研
若要实现上面的方案,必须解决3个问题:
- 如何定义
- 结果如何返回
- Service应该如何设计
需要一套语法解决定义
个人觉得VM最终目标只需要传入一串字符、参数,就能完成一个具体业务。那么定义了字符,就涉及到解析、翻译,借鉴编译原理,我们需要一套定义业务的语法。
将相对独立、闭环的功能,称为原子功能,每个原子功能为一个编码,相关功能为一个模块,一个模块为一个编码区。例如:账户为模块Account,则登录判断(Account01)、登录(Account02)、封禁判断(Account03);信息模块Info,上传修改(Info01)。
功能之间执行先后顺序、串行、并行、是否执行,通过操作符来决定。类似四则运算中,括号()决定执行的顺序,+、-、×、÷决定两侧的运算结果。那么可不可以定义+表示只有左侧执行成功,才执行右侧,-表示左侧执行失败再执行右侧呢?
那么按照上面的case,页面A的业务定义为:
(Account01-Account02)+Account03+Info01。
响应数据变化代替某个接口结果
通常我们是通过一个方法返回拿到结果,如果要拿到几个功能执行后的返回结果,这种方式便行不通。再加上原子功能要保持闭环,独立,不能相互依赖值传递,就更不能走传统方式。
将Model改造一下,使用监听者模式,页面需要监听哪些数据,就在初始化时注册,原子功能执行时,根据参数修改对应数据,页面实时响应数据变化即可。
Service设计
Service分为两部分:语法解析、原子功能模块注册+执行。考虑到后期模块积累过多,造成加载耗时,要支持分桶加载模块能力,例如:现在有A、B、C,3个模块,但是页面只需要调用A、C模块,那么在初始化时,可以指定自定义模块桶为:AC。
可行性分析
目前,2020年优酷相关业务建设中,有相关雏形,可以完成上述部分设计,但必须申明一点,框架还有不完善之处,后续会朝着这个方向继续探索。后续完善后,会及时更新本文档,以及对应技术文档。
好处是明显的,框架升级后,琐碎繁复的代码,再也不需要重新copy+修改,原子功能完成只需一次,但可多次使用,并且随着加入开发的同学增多,后续业务使用时,会越来越方便。
写在最后
MVVM优化后框架并不是最终解,就像文章开头讲的,本文只讨论开发模式的可能演化、优化方向;希望大家在评论区留言指正、交流。