概要
在此工程中,本文将讨论将MVC改造为MVVM需要的一些基本方法,同时会适当穿插部分关于MVVM概念性的讨论!本文最大的意义在于,提供了一种读者可以复现的方式,逐步引出从MVC向MVVM尽可能平滑过渡的一种方案;此外,也是为数不多的ReactiveCocoa实例文章之一.本文是MVVM系列文文章的第二篇,在阅读之前,您可能需要先阅读下第一篇文章: 写给iOS小白的MVVM教程(一): 从MVC到MVVM之一个典型的MVC应用场景
Apple本身的UIKit框架是为MVC模式设计的,所以你在无形之中写就的代码其实就是MVC,而且你甚至会觉得代码就应该这么写,不这么写还能怎么写?!MVVM由于缺乏框架级别的支持,所以在iOS的开发中一直似乎是很鸡肋式的存在.直到出现了ReactiveCocoa!它从框架界别支持MVVM模式,它让你真切地感觉到自己以前的代码真的太乱了,它也让你真正有兴趣去尝试下一些比较流行的编程模式,比如响应式,函数式,MVVM等.出于自己的实际项目需要,必须最低支持 iOS 7版本,所以在进行本文之前,先对 RAC(ReactiveCocoa的简称,后文同)作了一番研究.虽然官方文档指明 3.0版本的RAC,最低支持的 是iOS 8.0,但是我们依然可以通过 CocoaPods 安装 2.5版本的ReactiveCocoa来在自己的项目中使用,具体细节参见: ReactiveCocoa,最受欢迎的iOS函数响应式编程库(2.5版),没有之一!
基本概念篇: MVC VS MVVM
MVC
提到MVC,你现在可以先自己回想一下自己写过的程序,然后再往下看.
- M 指的是Model,数据模型,它可以是一个系统自身的类型,比如字符串,数组等,也可以是一个自定义的类型. 以上篇文章为例,你可以认为 YFMVCPostListViewController 的 categoryName 属性是一个Model,也可以认为 articles 属性是Model.Model 就是那个用来存储数据的东西.
- V,指的是View,通俗点说,所有UIView及其子类都属于V部分.
- C,指的就是UIViewController 及其子类.
所以说, UIKit自身就是为MVC模式设计的,而你就算不清除什么是MVC,但你的代码其实就是MVC模式.当你阅读自己以前的代码或者别人的代码时,经常感觉这个代码写的好乱(shi)啊,其实这真的不是自己或别人的锅,这是MVC本身难以避免甚至必然会出现的一个坑!所以,后来有人借鉴其他语言,提出了MVVM模式,并躬身实践!
MVVM
首先,MVVM,从概念说上来说,真的很好,很吸引人,即使你可能看不太懂,也感觉很高大上的样子!但是,当你真的去百度相关概念时,往往会很纳闷,似乎比我现在还麻烦,甚至开始怀疑,MVVM应该还只停留在理论阶段吧!--NO,只是因为你没有找到合适的文章,没有找到合适的工具--ReactiveCocoa!还是先说一下 MVVM的基础概念吧,不然没法往下说了:
- 第一个M,和MVC中的M基本一样.但是要求更轻量级.MVC中的M,你可以会放一些和原始数据不相关的推断出来的属性或者工具方法,如Person类,你可能给他写一个方法来根据原始数据年龄来判断是否有资格做某事,比如结婚;但是MVVM中的M,根据我的理解,你直接用它来存放元数据(这里,可能还是有争议的,仅是个人的理解与实践).
- 第一个V,比MVC中的V要更广泛些,它包括 UIView 与 UIViewController及其子类,View用来显示和交互,UIViewController担当一种类似于桥梁的角色,来使 View 和 ViewModel部分更好通信.
- 余下的"VM",其实是一个整体,指的是ViewModel,视图数据模型.如果你以前的许多代码都放在Model中,比如没有数据自动联网请求相关的数据什么的话,那你的那个Model其实和这个ViewModel有些像.MVVM中,要求Model更薄,最好只存储原始数据信息;而对于其他的设计到逻辑的代码,建议都放到ViewModel中.你可能会说,这样ViewModel 会不会很乱呢?未必!ViewModel中的代码会很多,但是ViewModel的可复用性和灵活性要远远大于ViewController.更具体点说,以前的一个控制器里面的代码,现在可能会被拆分到1个甚至多个ViewModel中,而且你的ViewModel不仅这个控制器可以用,其他的控制器也可以用.虽然从单个控制器的逻辑代码量来看,优化不是很显著,但是ViewModel的模块化特性,将在涉及到页面复用以及后期维护时,让人感觉心旷神怡!
关于MVVM,网上还有一种观点是,其实可以不要Model层,直接使用ViewModel层来存储数据.个人感觉,如果考虑到单元测试,此时如果有单独的Model部分,可以根据一个Model,直接测试ViewModel的逻辑,是极好的,所以目前还是继续保留Model部分.另外,也是考虑到后期可能会设计到Model本身的变更,比如将Model由一个普通的NSObjet变为CoreData的一个实体,可以很容易地让代码支持本地化.
此时,我还在考虑的一点是,公司代码其实Model部分不是由我负责的,如果想继续引入MVVM改造项目,保留一个ViewModel层,也可以使我的代码对其他项目成员的影响降到最低.想来也是极好的!
变革: 从MVC到MVVM
接下来,会以第一篇文章的示例为基础,将逐步改造为MVVM模式.
为View写的数据模型: Model --> Model + ViewModel
我的观点是,尽量不要使用系统自带的数据类型,比如数组,字典等作为Model,要尽可能地使用自定义地类.使用自定义的类,方便后期维护,也可以避免一些基础错误,如:自定义的类,如果属性不匹配会编译失败,但是如果使用字典类型,key不匹配时,是不会有任何提示的(用过字典的童鞋,都懂我意思的吧).所以我们此处要:
- 新增Model: YFCategoryArticleListModel,表示按分类分组的文章列表,其中有两个字段:category,分类;articles,此分类下的文章列表.
- 新增ViewModel: YFBlogDetailViewModel 表示文章的视图模型;YFBlogListViewModel 表示 分类文章列表的视图模型; YFBlogListItemViewModel 表示文章列表单个单元格的视图模型;
Model仅用于存储数据,ViewModel的具体逻辑下面需要时,会具体分析.另外,必须提到一点的是 @青玉伏案,给我推荐了一个RAC的VM框架ReactiveViewModel,有兴趣的可以研究下.但是我不是很能理解这么做的必要性,所以暂时我还是按照我自己的理解,用最常规的方式来写ViewModel部分.
使用ViewModel作为模块入口: M + C --> VM + C
就像我开篇序言中提到的那样,MVVM系列的文章,不单单是关于MVVM的讨论,更是关于如何将已有MVC项目逐步过渡为MVVM架构的可行性以及方法步骤的探究.这里我采用的是一种折中的更具可行性的方案: 我对外暴露的接口是ViewModel,但是对应的会给这个ViewModel提供一个使用Model作为参数的便利初始化方法;控制器或模块内部,就直接使用传入的ViewModel.这样,我觉得才是极好的,一方面自己可以践行MVVM,提前踩踩坑,另一方面也基本不会对其他小伙伴的开发工作造成太多的困扰!具体到本文示例,具体指:
- 文章列表控制器: 为了与MVC模式区分,新建控制器YFMVVMPostListViewController,并添加够公有属性viewModel,它是YFCategoryArticleListViewModel 类型.
- 文章详情控制器: 为了与MVC模式区分,新建控制器YF
- MVVMPostListViewController,仅添加只读属性viewModel,它是YFArticleViewModel类型.
关于ViewModel的自定义下面会具体谈到.
实现ViewModel.
必须指出的一点是: ViewModel是为View服务的,它的命名和字段定义应该根据View的需要来进行.本例是一个非常简单的场景.在复杂的场景中,一个model可能对应多个viewModel,此时多个视图可能都是同一种数据的不同展示方式;一个viewModel可能对应多个model,此时页面比较复杂,设计到多种数据的展示.简言之,应该是一个View对应一个ViewModel(这一点,可能也有待商榷,但暂时我会采取此种方式).所以,你的ViewModel中的属性不必和某个Model有真正意义上的对应关系,而是应该根据它服务的View来写和命名.
YFBlogListItemViewModel 博客列表单个单元格的视图模型
- 添加属性intro: 这个viewModel 供展示博客列表中的单个单元格使用,但根据目前的UI显示,只需要一个字段即可,我们给它命名为 intro,字符串属性.这个后期可以根据UI变化动态更改.就像上面提到的,ViewModel是为Model服务的.
- 添加属性 blogId.
- 添加初始化方法 -initWithArticleModel: 以便于从一个YFArticleModel对象构建视图模型.
- 注意需要在初始化时设置 introl和model的title,desc属性的级联关系(我喜欢这么称呼,意会,有点重写getter方法的感觉).这一步本来是在Controller中完成的,现在挪到了 ViewModel中,Controller 不就瘦了一点了吗,而且把这个逻辑写到这里还更方便代码复用.
- (instancetype)initWithArticleModel:(YFArticleModel *)model{ self = [super init]; if (nil != self) { // 设置intro属性和model的属性的级联关系. RAC(self, intro) = [RACSignal combineLatest:@[RACObserve(model, title), RACObserve(model, desc)] reduce:^id(NSString * title, NSString * desc){ NSString * intro = [NSString stringWithFormat: @"标题:%@ 内容:%@", model.title, model.desc]; return intro; }]; // 设置self.blogId与model.id的相互关系. [RACObserve(model, id) subscribeNext:^(id x) { self.blogId = x; }]; } return self;}
YFBlogListViewModel 博文列表的视图模型.
- 添加属性blogListItemViewModels,NSArray 类型,用于存储文章列表单元格的视图模型.视图部分检测它的变化,然后动态刷新视图即可.
- 添加工具方法: -first 与 -next,用于支持常见的数据分页操作,配合blogListItemViewModels,可以实现常见的上拉刷新与加载加载的操作.
- 添加初始化方法 -initWithCategoryArtilceListModel, 用于快速使用一个分类文章列表数据模型来快速初始化.再次强调一次: model 和 viewModel 并不是一一对应的关系,这里只是为了简化从一种Model生成此种ViewModel的操作;即,以后如果有其他种类的可以使用此种ViewModel的话,我们再为其添加一个从新Model初始化的方法即可.
- 初始化时,涉及到网络请求,在此处我们额外引入了一个AFN扩展 AFNetworking-RACExtensions,用于使用RAC的语法格式使用AFN.
// 接口完整地址,肯定是受分类和页面的影响的.但是因为分类的变化最终会通过分页的变化来体现,所以此处仅需监测分页的变化情况即可.[RACObserve(self, nextPageNumber) subscribeNext:^(NSNumber * nextPageNumber) { NSString * path = [NSString stringWithFormat: @"http://www.ios122.com/find_php/index.php?viewController=YFPostListViewController&model[category]=%@&model[page]=%@", self.category, nextPageNumber]; self.requestPath = path;}]; // 每次数据完整接口变化时,必然要同步更新 blogListItemViewModels 的值.[RACObserve(self, requestPath) subscribeNext:^(NSString * path) { /** * 分两种情况: 如果是变为0,说明是重置数据;如果是大于0,说明是要加载更多数据;不处理向上翻页的情况. */ NSMutableArray * articls = [NSMutableArray arrayWithCapacity: 42]; if (YES != [self.nextPageNumber isEqualToNumber: @0]) { [articls addObjectsFromArray: self.blogListItemViewModels]; } [[self.httpClient rac_GET:path parameters:nil] subscribeNext:^(RACTuple *JSONAndHeaders) { // 使用MJExtension将JSON转换为对应的数据模型. NSArray * newArticles = [YFArticleModel objectArrayWithKeyValuesArray: JSONAndHeaders.first]; // RAC 风格的数组操作. RACSequence * newblogViewModels = [newArticles.rac_sequence map:^(YFArticleModel * model) { YFBlogListItemViewModel * vm = [[YFBlogListItemViewModel alloc] initWithArticleModel: model]; return vm; }]; [articls addObjectsFromArray: newblogViewModels.array]; self.blogListItemViewModels = articls; }];}];
关于MVVM的优势,此处已可见一斑!我们成功的从控制器中剥离了网络请求以及数据分页的相关代码.从整体代码量的角度,我们可能没少写几行代码;但是从代码复用性的角度考虑,我们的代码更具有可复用性,因为将来可能其他地方也会用到这个页面;与此同时,代码之间的耦合性也降低了很多;可扩展性大大提高[PS: 关于代码耦合性,可复用性什么的,真的很大程度上是由模式本身决定的!]
YFBlogDetailViewModel 文章详情页的视图模型.
- 添加属性content,用于直接在网页视图上显示,View内检测这个属性值,动态刷新视图即可.
- 添加初始化方法 -initWithModel: 用于方便从一个 YFArticleModel 数据模型新建相应的视图模型.
- 设计到网络请求部分的核心代码如下:
/** * 公共的与Model无关的初始化. */- (void)setup{ // 初始化网络请求相关的信息. self.httpClient = [AFHTTPRequestOperationManager manager]; self.httpClient.requestSerializer = [AFJSONRequestSerializer serializer]; self.httpClient.responseSerializer = [AFJSONResponseSerializer serializer]; // 接口完整地址,肯定是受id影响. [RACObserve(self, blogId) subscribeNext:^(NSString * blogId) { NSString * path = [NSString stringWithFormat: @"http://www.ios122.com/find_php/index.php?viewController=YFPostViewController&model[id]=%@", blogId]; self.requestPath = path; }]; // 每次完整的数据接口变化时,必然要同步更新 self.content 的值. [RACObserve(self, requestPath) subscribeNext:^(NSString * path) { [[self.httpClient rac_GET:path parameters:nil] subscribeNext:^(RACTuple *JSONAndHeaders) { // 使用MJExtension将JSON转换为对应的数据模型. YFArticleModel * model = [YFArticleModel objectWithKeyValues:JSONAndHeaders.first]; self.content = model.body; }]; }];}
如果耐心比较下 -setup 方法中的代码,会发现与上个VM的-setup有许多共同之处,这就启发我们,或许应该将网络请求类从VM中进一步剥离出来,制作一个通用的网络请求类.通用网络请求类与单元测试的相关话题,会在下篇MVVM系列文章中专门讲述,在此不再继续讨论.
不要为了RAC而RAC: 其实你可以使用你熟悉的方式写View的.
坦白说,RAC真的让人很喜欢;但是,我在这里想说的是, RAC 只是简化编码的工具而已--所谓工具,就是那种你掌握了可以走的更快,不会也无伤大雅的东西!国内,部分文章过分渲染 RAC 与UIKit 的差异,甚至有人宣称是另一条完全不同的学习曲线--真的很扯,逻辑上无异于就像宣称没有MFC,所有人都会饿死一样!在此,就不过多吐槽了,反正我是很早就看过国内某些博主的关于RAC的文章,被博主忽悠忽悠的不行,最终得出的结论是,太难了,暂时不学!如果,你刚好看到这篇文章,我想对你说的是: 耐下心,花一两天结合自己的工程和基础的RAC语法,尝试用RAC写写代码试试,真的很赞,而且是有足够的姿势完全兼容以前的自己写法的!View部分,在此我就暂时不用RAC中的写法来替代block,代理等,尽可能地在MVC的代码上,适当修正,以证明二者的某种程度上的协同作用.
控制器中的代码,真的被精简了不少,以博客列表控制器为例,几乎占据1/2控制器代码量的网络请求与数据分页的代码,被简化为一句话:
[RACObserve(self.viewModel, blogListItemViewModels) subscribeNext:^(id x) { [self updateView];}];
同样的,博客详情也精简了非常多,忍不住想晒下完整代码:
//// YFMVVMPostViewController.m// iOS122//// Created by 颜风 on 15/10/21.// Copyright (c) 2015年 iOS122. All rights reserved.//#import "YFMVVMPostViewController.h"#import "YFBlogDetailViewModel.h"#import <ReactiveCocoa.h>@interface YFMVVMPostViewController ()@property (strong, nonatomic) UIWebView * webView;@end@implementation YFMVVMPostViewController- (void)viewDidLoad { [super viewDidLoad]; // Do any additional setup after loading the view. [RACObserve(self.viewModel, content) subscribeNext:^(id x) { [self updateView]; }];}- (void)didReceiveMemoryWarning { [super didReceiveMemoryWarning]; // Dispose of any resources that can be recreated.}- (UIWebView *)webView{ if (nil == _webView) { _webView = [[UIWebView alloc] init]; [self.view addSubview: _webView]; [_webView makeConstraints:^(MASConstraintMaker *make) { make.edges.equalTo(UIEdgeInsetsMake(0, 0, 0, 0)); }]; } return _webView;}/** * 更新视图. */- (void) updateView{ [self.webView loadHTMLString: self.viewModel.content baseURL:nil];}@end
附
- 示例完整代码: iOS122
- 推荐阅读: 基于AFNetworking2.0和ReactiveCocoa2.1的iOS REST Client
- 下篇主题: MVVM系列的下一个主题,会对MVVM中网络请求的逻辑进一步从VM剥离以及单元测试的一些技术性讨论,欢迎回帖交流!
- 1楼睡后3k
- 博主block里面大量引用self,而并没有做弱引用处理
- Re: iOS122
- @睡后3k,是的,这样写可能是有问题的.但是我不确定,是否所有的block 都需要使用弱引用形式的self.根据已有的经验,对于可能存在循环引用问题的block,Xcode一般都是有提示的,而博文中的代码,Xcode没有给出任何提示.,这个问题,稍后 我会专门系统研究下 ,暂保留个人意见