基于ReactiveCocoa的MVVM编程模式架构


前言

什么是MVVM模式?其实我觉得对于这个问题,每个人理解可能都是不一样的。因为ViewModel本身就是非常抽象的概念,它的实现也可以是各不相同。像是传统的MVC模式,需要把cell的设置放在ViewController中。实际上我们会更多的直接用cell调用Model,这虽然违背了MVC的原则,却使得整个项目结构变得清晰。所以个人认为大谈什么是V什么是M什么是VM没有很大的意义,重要的是对设计模式的合理运用,我会结合代码和Demo讲解我对MVVM模式的理解。另外,框架是基于ReactiveCocoa写的,我是默认你已经掌握了RAC相关的内容。


Model for View

我们先不谈ViewModel究竟是什么,先说一下在我的ViewModel里写了什么。在Demo里我创建了一个自定义的视图TitleView, 它有着以下两个Label以及一个Button:

@interface SUIMVVMRootTitleView : UIView
@property (weak, nonatomic) IBOutlet UILabel *lbl01;
@property (weak, nonatomic) IBOutlet UILabel *lbl02;
@property (weak, nonatomic) IBOutlet UIButton *clickBtn;
@end

两个Label的文字和颜色都可能会改变,同时Button点击了会触发事件,为了满足以上的需求ViewModel写了以下的属性:

@interface SUIMVVMRootTitleVM : SUIViewModel
@property (nonatomic,copy) NSString *text1;
@property (nonatomic,copy) NSString *text2;
@property (nonatomic,strong) UIColor *textColo1;
@property (nonatomic,strong) UIColor *textColo2;
@property (nonatomic,strong) RACCommand *clickCommand;
@end

简单来说,每一个自定义的View都对应了一个ViewModel,这个ViewModel里写的就是View改变的部分。在TitleView的.m里我把ViewModel和View进行了绑定:

@implementation SUIMVVMRootTitleView
- (Class)sui_classOfViewModel
{
    return [SUIMVVMRootTitleVM class];
}
- (void)sui_bindWithViewModel
{
    SUIVIEWBIND(SUIMVVMRootTitleVM,
                RAC(self.lbl01, text) = SUIVIEWObserve(text1);
                RAC(self.lbl02, text) = SUIVIEWObserve(text2);
                RAC(self.lbl01, textColor) = SUIVIEWObserve(textColo1);
                RAC(self.lbl02, textColor) = SUIVIEWObserve(textColo2);
                )
    self.clickBtn.rac_command = [self.sui_vm clickCommand];
}
@end

第一眼是不是完全看不懂,咳,让我们来一句句分析。
sui_classOfViewModel() 返回了当前View对应的ViewModel类型。在我的设计中,自定义的View只对应同一个ViewModel,将ViewModel设置为View的属性,同时使用懒加载模式创建实例。在ViewModel创建实例的时候它的类型就是由sui_classOfViewModel()的返回值决定的。相当于你在View初始化时创建了一个ViewModel并将它赋值给View中对应的属性。

sui_bindWithViewModel() 我在这个方法里使用了很多的宏,好处是可以少写很多的重复代码,坏处是无法在其中设置断点,也不能在宏内部使用LLDB。简单说一下每个宏的作用,为了调试方便可以直接展开来写。SUIVIEWBIND宏第一个参数填入了ViewModel的类名,告诉编译器ViewModel的类型,在第二个参数里用RAC将View和ViewModel绑定。SUIVIEWObserve是简单封装了RAC的RACObserve,也就是少写了RACObserve里的第一个参数直接填入ViewModel的属性。

至此View的代码部分已经完整了,大家可以发现根本就没有Model的事,如果需要复用这个View那么它本身的代码不需要做任何修改。View的职责就是响应ViewModel的改变,View所有的数据来源都是ViewModel,在我看来ViewModel其实就是"Model for View"。


Bind with Model

为了便于大家理解附上一个不重要的Model,说不重要的原因在于ViewModel虽然只绑定一个Model,但是它可以绑定不同的Model。换句话说,在ViewModel绑定了RootTitleMD后,再去绑定SecondTitleMD,那么在SecondTitleMD绑定前RootTitleMD会先解绑。

@interface SUIMVVMRootTitleMD : NSObject
@property (nonatomic,copy) NSString *kw;
@property (nonatomic) NSInteger numOfAlbums;
@end

着重看一下ViewModel的.m部分:

@implementation SUIMVVMRootTitleVM
- (void)sui_commonInit
{
    SUIVMBIND(SUIMVVMRootTitleMD,
              SUIVMRAC(text1, kw);
              RAC(self, text2) = [SUIVMObserve(numOfAlbums)
                                  map:^id(NSNumber *cNum) {
                                      return gFormat(@"num:%@",cNum);
                                  }];
              RAC(self, textColo1) = [SUIVMObserve(numOfAlbums)
                                      map:^id(id value) {
                                          return gRandomColo;
                                      }];
              RAC(self, textColo2) = [SUIVMObserve(numOfAlbums)
                                      map:^id(id value) {
                                          return gRandomColo;
                                      }];
              )
}
@end

呃……好吧。就一段话,简单来看一下,感觉是不是和View中的代码很像。SUIVMBIND宏对应SUIVIEWBIND宏,SUIVMObserve宏对应SUIVIEWObserve,多了一个SUIVMRAC宏是极度偷懒使用的,展开其实就是:

RAC(self, text1) = SUIVMObserve(kw);

ViewModel的职责很明确,它做的其实就是胖Model做的事,关于胖Model限于篇幅这里就不多说了。大家可以看到Model里是不包含任何逻辑的,ViewModel负责对Model的数据加工,同时响应绑定Model的改变。

至此ViewModel的代码部分也已经完整了,再加上那个不重要的Model,一个 (V-VM)-M 版本的自定义视图就完成了。

……咦,等等,好像有哪里不对劲。你的Model是哪里来的啊?Model在哪里和ViewModel绑定的啊?在哪里响应Button的点击事件啊?你是把ViewController忘了么,完全没有提到啊喂?啊喂啊喂?


(V-VM)-M-(VM-V)

先来说一下ViewController,我们可以把ViewController看成View,依然配合Demo中的代码来看:

@interface SUIMVVMSecondVC ()
@property (weak, nonatomic) IBOutlet UIImageView *coverView;
@property (weak, nonatomic) IBOutlet UILabel *idLbl;
@end

@implementation SUIMVVMSecondVC
- (Class)sui_classOfViewModel
{
    return [SUIMVVMSecondVM class];
}
- (void)viewDidLoad
{
    [super viewDidLoad];
    SUIVIEWBIND(SUIMVVMSecondVM,
                @weakify(self)
                [[SUIVIEWObserve(cover) ignore:nil]
                 subscribeNext:^(NSString *cCover) {
                     @strongify(self)
                     [self.coverView setImageWithURL:cCover.sui_toURL];
                 }];
                RAC(self.idLbl, text) = SUIVIEWObserve(aId);
                )
}
@end

和前面的View部分的代码对比你会发现,其实他们没有多少区别,就连用的宏都是一模一样的。接着我们再看一下另一段代码(删减了部分代码):

@interface SUIMVVMRootVC ()
@property (nonatomic,strong) SUIMVVMRootVM *sui_vm;
@property (weak, nonatomic) IBOutlet SUIMVVMRootTitleView *currTitleView;
@end

@implementation SUIMVVMRootVC
@dynamic sui_vm;
- (Class)sui_classOfViewModel
{
    return [SUIMVVMRootVM class];
}
- (void)viewDidLoad
{
    [super viewDidLoad];
    // TitleView绑定model
    [self.currTitleView.sui_vm bindWithModel:self.sui_vm.rootTitleMD];
    // TitleView点击事件
    uTypeof(SUIMVVMRootTitleVM, self.currTitleView.sui_vm).clickCommand = self.sui_vm.rootTitleClickCommand;
}
@end

我们的主角TitleView终于登场了,TitleView作为ViewController的属性,那么TitleView的ViewModel获取Model的代码(bindWithModel())就是写在ViewController中。换句话就是TitleView的持有者提供Model绑定给TitleView的ViewModel。

那么这里说的Model哪里来的?这个Model可以是网络数据模型,可以是本地数据模型,可以是从其他ViewController传递来的模型,我们的TitleView并不关心,TitleView只关心它对应的ViewModel的改变,ViewModel只关心绑定的Model的改变,TitleView的持有者绑定ViewModel和Model,大伙都各司其职。

综上,我们可以把ViewController看成View,但是ViewController还有另外一个职责就是获取Model,将它绑定给持有的TitleView对应的ViewModel,若TitleView内存在自定义视图则TitleView负责提供Model绑定给持有的自定义视图对应的ViewModel。

再来说一下Button的点击事件,大家可以看到我现在是把事件丢给ViewController处理(当然,懒惰的ViewController转手就丢给了它自己的ViewModel),我个人的看法是看需求吧,就像前言所说的只要合理就好。


最后的最后

限于篇幅,简单说一下一些其他的问题。

  • 网络请求放在哪里?个人看法是封装成一个单独的模块,它和MVVM模式其实没有多大关系,一般是在ViewController的ViewModel里调用封装好的工具类得到Model。

  • TableView和CollectionView的代理?我为TableView写了一个Helper类,它的代理都丢过去了,具体可以看Demo。个人觉得再用代理或者Blocks回调到ViewController中处理其实不是很好。

  • 那些宏看着好危险的感觉,能不能换成其他方式?如果用swift是可以的,因为swift是有重载特性,实现方式也会比现在好很多。然而OC不支持重载,因此我选择用宏来简化代码。展开来写会发现还做了移除Model绑定之类的处理,所以用宏最重要的目的是预防出错。

  • 附上项目地址:https://github.com/randomprocess/SUIToolKit

这个框架主要是用来验证思路,有任何想法疑问或者Bug都欢迎提一个Issues。最后的最后弱弱的求个Star~

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 161,873评论 4 370
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 68,483评论 1 306
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 111,525评论 0 254
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,595评论 0 218
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 53,018评论 3 295
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,958评论 1 224
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 32,118评论 2 317
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,873评论 0 208
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,643评论 1 250
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,813评论 2 253
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,293评论 1 265
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,615评论 3 262
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,306评论 3 242
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,170评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,968评论 0 201
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 36,107评论 2 285
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,894评论 2 278

推荐阅读更多精彩内容