采用MVVM设计模式搭建项目基础架构初探

原文 : 与佳期的个人博客(gonghonglou.com)

前段时间将公司的 iOS 移动端项目采用 MVVM 设计模式重构了,说是重构,其实是新开了一个工程按照原工程业务逻辑重写了一遍。重构结束后整个工程清爽了不要太多,逻辑清晰,维护轻松,新功能开发起来效率得到明显提升。重构结束后就打算将项目基础架构总结一下,结合 MVVM 设计模式写份小结,但苦于一直没有时间,以致拖到了现在,马上过年了现在来还账。。。

首先声明的是,本篇文章是对自己过去几个月里重构工作中项目架构方面的总结,因为项目比较轻,所以文章讲的内容更适合一个轻量型工程,至于对复杂工程的适用程度则另当别论,小白经验求轻喷~

对于这篇博客的内容 MVVM 是重点,但不是目的,目的是讲清对一个项目怎样搭建基础架构,所以文章会以 MVVM 设计模式为中心展开讲解整个项目基础架构的搭建,以及在项目中对架构方面自己的处理方法。

MVC 与 MVVM

关于 MVCMVPMVVM 各种设计模式的区别及特点网上已经有了一大推的文章去讲,这里就不再详细赘述了,毕竟这不是本篇文章的重点。但我们总归要先认识 MVVM 设计模式嘛,提到 MVVM 就离不开 MVC 的延伸,毕竟 MVVM 设计模式是从 MVC 衍生出来的,请看图:

MVC-MVVM 设计模式

通过这张图我想尽量将这两种设计模式的不同点区分清楚

  • MVC 设计模式
    1. View 绑定事件并响应,传递给 Controller 处理
    2. Controller 处理逻辑,发起网络请求向 Model 存储数据,或调用缓存方法向 Model 获取数据以控制 View 的展示
    3. Model 负责数据存储
  • MVVM 设计模式
    1. View 绑定事件并响应,传递给 ViewModel 处理
    2. ViewModel 处理逻辑,发起网络请求向 Model 存储数据
    3. Model 负责数据存储
    4. Model 数据变动,ViewModel 会得到响应并进行逻辑处理
    5. ViewModel 数据变动,View 会得到响应并改变页面

文件分类与功能划分

对于每一模块如 LoginHomeDetail 等都有对应的自己的 ViewContainerViewControllerViewModel,基本一个模块对应一块页面,文件分区如下:

文件分类

简单讲一下各个文件的作用:

  • 1.ViewContainer
    • 1.在 MVVM 设计模式中与 ViewController 共同扮演 View 的角色
    • 2.负责页面子控件布局,可接收一个 VO 类决定该页面内容所需要的数据模型,对于页面
    • 3.对于页面内容所需要的数据可对外暴露一个接口,接收一个包含该页面所需数据的 VO
  • 2.ViewController
    • 1.在 MVVM 设计模式中与 ViewContainer 共同扮演 View 的角色
    • 2.负责绑定子控件事件、代理,实现绑定事件到 ViewModel、实现代理方法
    • 3.响应 ViewModel 数据变化以控制 View 层页面展示
  • 3.ViewModel
    • 1.在 MVVM 设计模式中扮演 ViewModel 的角色
    • 2.负责处理 ViewController 传递的点击等事件
    • 3.负责逻辑处理,业务处理。如:发起网络请求、处理缓存数据、生成 VO 类对象
    • 4.响应 Model 数据变化

以上涉及到额外的两个概念: VO 类和 Model 类:

  • 1.VO 类:
    • 1.专职于 View 层页面展示所需要的数据,提供给 View
    • 2.这样 View 层就不必关心源数据如何,而仅关心 VO 类有什么样的数据就展示什么样的页面,将源数据的处理操作交给 ViewModel 去做
    • 3.VO 类既可以给 Cell 提供为 CellVO,也可以给子视图提供为 SubViewVO
  • 2.Model 类:
    • 1.在 MVVM 设计模式中扮演 Model 的角色
    • 2.基本每个模块都有拥有一份 ViewContainerViewControllerViewModel,但并非每个模块都拥有一份 Model,所以只给需要 Model 类的模块添加该类文件
    • 3.并非因为该模块没有 Model 类文件就说这个模块没有 Model 层,我们所说的 MVVM 设计模式是一个抽象的概念,不要被上图中存在的一一对应的物理文件就忽略了工程中的 Model
    • 4.我们通常会将整个工程的 Model 层抽离出来放在一起,比如网络请求部分、缓存部分、配置文件这些都属于 MVVM 设计模式里的 Model 层的内容,而这些内容大都可以抽离成单独的工具类,所以不会存在于每个模块的文件夹分类中
    • 5.当然,如果某模块需要一份私有的 Model 类文件,我们仍然会为该模块新建 Model 类文件

双向绑定

MVVM 设计模式有一个最大的特点就是双向绑定,当 ViewModel 中的数据发生变化时,View 层会自动响应数据变化以更改页面,而实现这一功能的方法可以通过 KVO、通知等方式,只不过自己去实现这些功能难免会让 MVVM 的使用变得复杂,而作为函数式响应式编程的大神级作品 ReactiveCocoa 可以完美的帮我们实现这些操作。

网络上关于 MVVM + ReactiveCocoa 工作方式的文章也是一大推,这里也就不详细赘述了,这里用到的 ReactiveCocoa 的功能非常少,我们仅仅实现双向绑定能够响应数据变化即可。

方式一:监听属性

如,ViewModel 存在一个属性 dataArray,当该属性变化时,View 层的 UITableView 自动刷新。

@property (nonatomic, copy) NSArray *dataArray;

ViewController 里实现对 dataArray 属性的监听:

@weakify(self);
[RACObserve(self.viewModel, dataArray) subscribeNext:^(id  _Nullable x) {
   @strongify(self);
   [self.viewContainer.tableView reloadData];
}];

方式二:监听消息

如,ViewModel 存在一个属性 dataArray,但并不对外暴露,而是对外暴露一个 RACSignal 方法,当产生 RACSignal 信号时,View 层的 UITableView 自动刷新。

- (RACSignal *)arraySignal {
    return [RACObserve(self, dataArray) filter:^BOOL(id value) {
       return YES;
    }];
}

ViewController 里实现对 arraySignal 信号的监听:

@weakify(self);
[self.viewModel.arraySignal subscribeNext:^(id x) {
   @strongify(self);
   [self.viewContainer.tableView reloadData];
}];

这两种方式相比,方式一写法简单,但需要对外暴露属性,方式二写法复杂但不需要暴露属性,只对外暴露一个信号方法。

页面跳转

View 响应事件并驱动 ViewModel 进行业务逻辑处理涉及到页面变动或跳转时,我们的逻辑应当是这样的:

页面操作流程

对于页面内局部内容的改变,不涉及 pushpresent 等操作时,页面操作由当前 View 控制,流程如下:

页面操作

上图为页面操作的流程:
1、View 响应事件并传递给 ViewModel
2、ViewModel 逻辑处理完成后通知 View
3、View 接收 ViewModel 的通知并进行页面操作

页面跳转流程

对于页面跳转,比如 pushpresent 等操作时,跳转行为由 Mediator 控制,流程如下:

页面跳转

上图为页面跳转的流程:
1、View 响应事件并传递给 ViewModel
2、ViewModel 逻辑处理完成后通知中介者
3、Mediator 中介者操作页面进行跳转

中介者(Mediator)

其中,这里涉及到 中介者(Mediator) 的概念:
为了方便进行页面跳转的操作及避免依赖,我们引入了 中介者(Mediator) 的概念,ViewModel 持有 MediatorMediator 控制所有页面的跳转,只对外暴露跳转接口。
这样,ViewModel 并不关心页面跳转的细节,只需调用 Mediator 的方法即可,而页面跳转的实现,甚至是跳转的方向都可以有 Mediator 来决定。

代码实践

我们努力以最简单的例子展现整个架构的流程,涉及到大概三个页面:
1、登录页:点击按钮执行登录操作并推出主页面
2、主页:展示一个 UITableView,并且可点击 UITableViewCell 推出详情页面
3、详情页:展示详情信息

登录页(Login)

1、ViewContainer 完成布局,添加登录按钮:

- (instancetype)init {
    self = [super init];
    if (self) {
        self.confirmButton = [UIButton buttonWithType:UIButtonTypeSystem];
        self.confirmButton.frame = CGRectMake(100, 300, 175, 40);
        self.confirmButton.layer.cornerRadius = 4.0;
        self.confirmButton.backgroundColor = [UIColor redColor];
        [self.confirmButton setTitle:@"点击登录" forState:UIControlStateNormal];
        [self.confirmButton setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal];
        [self addSubview:self.confirmButton];
    }
    return self;
}

2、ViewController 绑定登录按钮方法:

- (void)clickConfirmButton {
    [self.viewModel login];
}

3、ViewModel 处理登录逻辑,操作 MACoordinatingController 进行页面跳转:

- (void)login {    
    [[MACoordinatingController sharedInstance] pushToHomeViewController];
}

4、MACoordinatingController 执行页面跳转:

- (void)pushToHomeViewController {
    if (!self.homeVC) self.homeVC = [MAHomeViewController new];
    [_activeViewController.navigationController pushViewController:self.homeVC animated:YES];
}

主页(Home)

1、ViewContainer 完成布局,添加 UITableView
2、ViewController 设置 UITableView 代理并实现代理方法:

// self.viewContainer.tableView.dataSource = self;
// self.viewContainer.tableView.delegate = self;

#pragma mark - UITableViewDataSource
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
    return self.viewModel.dataArray.count;
}

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
    MAHomeTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:[MAHomeTableViewCell cellIdentifier]];
    if (!cell) {
        cell = [[MAHomeTableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:[MAHomeTableViewCell cellIdentifier]];
    }
    [cell setHomeTableViewCellWithVO:self.viewModel.dataArray[indexPath.row]];
    return cell;
}

#pragma mark - UITableViewDelegate
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
    [self.viewModel didSelectedCellWithIndexPath:indexPath];
}

并设置 ViewModel 数据监听:

- (void)setObserve {
    @weakify(self);
    [RACObserve(self.viewModel, dataArray) subscribeNext:^(id  _Nullable x) {
        @strongify(self);
        [self.viewContainer.tableView reloadData];
    }];
}

3、ViewModel 进行逻辑处理,生成数据

- (void)operateDataArray {
    NSMutableArray *mutableArray = [NSMutableArray new];
    for (NSInteger i = 0; i < 10; i++) {
        MAHomeTableViewCellVO *cellVO = [MAHomeTableViewCellVO new];
        cellVO.title = [NSString stringWithFormat:@"title -- %ld", (long)i];
        cellVO.message = [NSString stringWithFormat:@"message -- %ld", (long)i];
        [mutableArray addObject:cellVO];
    }
    self.dataArray = [mutableArray copy];
}

并响应 cell 点击,操作 MACoordinatingController 进行页面跳转

4、MACoordinatingController 执行页面跳转

其中,MAHomeTableViewCell 的内容是由 MAHomeTableViewCellVO 来决定的。

详情页(Detail)

1、ViewContainer 完成布局
2、ViewController 设置 ViewModel 数据监听
3、ViewModel 进行逻辑处理,生成数据

整体流程大概如下图:

关于页面跳转

对于中介者跳转页面代码如:

- (void)pushToHomeViewController {
    if (!self.homeVC) self.homeVC = [MAHomeViewController new];
    [_activeViewController.navigationController pushViewController:self.homeVC animated:YES];
}

我们需要记录 _activeViewController 的值来对当前页面进行操作,其中处理方法有两种。

方式一:

令所有的 ViewController 继承 BaseViewController,在 BaseViewController- (void)viewWillAppear: 方法里设置 _activeViewController

- (void)viewWillAppear:(BOOL)animated {
    [super viewWillAppear:animated];
    [MACoordinatingController sharedInstance].activeViewController = self;
}

方式二:

采用第一种方式就需要令所有的 ViewController 继承 BaseViewController,显示不太友好,我们可以选用 Method Swizzling 的方式替换掉所有的 ViewController- (void)viewWillAppear: 方法,在已替换的 - (void)ma_viewWillAppear: 方法里设置 MACoordinatingControlleractiveViewController 为当前的 ViewController

@implementation UIViewController (MAAppear)

+ (void)load {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        Class class = [self class];

        SEL originalSelector = @selector(viewWillAppear:);
        SEL swizzledSelector = @selector(ma_viewWillAppear:);

        Method originalMethod = class_getInstanceMethod(class, originalSelector);
        Method swizzledMethod = class_getInstanceMethod(class, swizzledSelector);

        BOOL success = class_addMethod(class, originalSelector, method_getImplementation(swizzledMethod), method_getTypeEncoding(swizzledMethod));
        if (success) {
            class_replaceMethod(class, swizzledSelector, method_getImplementation(originalMethod), method_getTypeEncoding(originalMethod));
        } else {
            method_exchangeImplementations(originalMethod, swizzledMethod);
        }
    });
}

#pragma mark - Method Swizzling
- (void)ma_viewWillAppear:(BOOL)animated {
    [self ma_viewWillAppear:animated];
    
    if (![self isKindOfClass:NSClassFromString(@"UIInputWindowController")]) {
        [MACoordinatingController sharedInstance].activeViewController = self;
    }
}

@end

关于 Method Swizzling 可以参考我之前的文章:从 SafeKit 看异常保护及 Method Swizzling 使用分析

到这里,采用MVVM设计模式搭建项目基础架构初探大概就结束了,这是篇对自己项目经验的总结,也希望这篇文章能够帮到大家一点点。新年快乐!

附上 MVVMArchitectureDemo GitHub 地址

后记

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

推荐阅读更多精彩内容