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

前段时间将公司的 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 自动刷新。

1
@property (nonatomic, copy) NSArray *dataArray;

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

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

方式二:监听消息

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

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

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

1
2
3
4
5
@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 完成布局,添加登录按钮:

1
2
3
4
5
6
7
8
9
10
11
12
13
- (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 绑定登录按钮方法:

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

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

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

4、MACoordinatingController 执行页面跳转:

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

主页(Home)

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 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 数据监听:

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

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

1
2
3
4
5
6
7
8
9
10
- (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 进行逻辑处理,生成数据

整体流程大概如下图:

关于页面跳转

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

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

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

方式一:

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

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

方式二:

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@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 地址