MVVM是Model-View-ViewModel的简写。它本质上就是MVC 的改进版。MVVM 就是将其中的View 的状态和行为抽象化,让我们将视图 UI 和业务逻辑分开。当然这些事 ViewModel 已经帮我们做了,它可以取出 Model 的数据同时帮忙处理 View 中由于需要展示内容而涉及的业务逻辑。
他把View和Contrller都放在了View层(相当于把Controller一部分逻辑抽离了出来),Model层依然是服务端返回的数据模型。而ViewModel充当了一个UI适配器的角色,也就是说View中每个UI元素都应该在ViewModel找到与之对应的属性。除此之外,从Controller抽离出来的与UI有关的逻辑都放在了ViewModel中,这样就减轻了Controller的负担。
我简单的画了下MVVM的架构图。
从以上的架构图中,我们可以很清晰的梳理出各自的分工。
- 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以及Model间的清晰的持有关系,所以在三个模块间的数据流转有了很好的控制。
这里给大家推荐一篇博文猿题库iOS客户端架构设计,其架构图如下。
猿题库的架构本质上不是MVC也不是MVVM,它是两种架构演进的一种架构模式。博文中对于MVC和MVVM的优缺点做了简单的介绍。
- MVC缺点:Massive View Controller,也就是胖VC。
- MVVM缺点:1.学习成本高。2.DEBUG困难。
但博文中关于MVVM的阐述有两处笔者不太赞同。
- MVVM绝不等于RAC,所以MVVM并不存在DEBUG难的问题。
- MVVM正是因为跟RAC不对等,所以博文中“MVVM一个首要的缺点是,MVVM的学习成本和开发成本都很高”这句话也是不成立的。
MVVM架构本身并不复杂,而且不用RAC我们依然可以通过KVO、类KVO的方式来帮我们实现View和ViewModel绑定器功能。
关于猿题库iOS客户端架构设计是否合理,因为笔者不了解其具体业务,所以不能妄下结论。但是有一点可以肯定的是,MVVM ≠ RAC。
一年一度的QA环节来了。
Q:View和ViewModel之间是否一定要解耦?
A:View持有ViewModel,ViewModel不能持有View(即ViewModel不能依赖UIKit中任何东西)。说明白了吧? 解耦是有一定成本的,不管是通过Category或者中间件,消息链条都会无形之中变长,会有一定的DEBUG成本。Q:为什么ViewModel不能持有View?
A:这个很好理解啊兄dei,主要有两方面原因:1.ViewModel可测性,即单元测试方便进行。2.团队人员可分离开发(View和ViewModel开发可以是两个人同时进行)。
MVVM结合RAC
ReativeCocoa相信大家并不陌生,这个函数响应式框架在Github中已经有将近2w star 。RAC是个非常优秀的框架,它可以独立于MVVM而存在。如果只是把它理解成MVVM中View和ViewModel Binder角色的话,那就有点大材小用了。本文不会对RAC进行展开分析,感兴趣的可以自行实践一下。
RAC特点:
- 语法怪异,杂交种。(函数式+响应式编程组合)
- 万物皆可盘。(事件信号RACSignal贯穿整个框架)
- 把离散的函数调用撺成一坨。(个人感觉跟Promise很像)
总结:RAC是一种编程思维的改变,所以其缺点很明显,学习成本很大!!!
具体RAC的使用,可以参考官方文档,自行实践一下,这里不再展开。
MVVM结合非RAC(IQDataBinding)
通过MVVM扫盲部分,我们了解到,Binder在MVVM中扮演了View和ViewModel数据通信者的角色。
了解过Android开发的同学都知道,Java有个好东西,那就是注解(Annotation)。在开发Android App的时候,可以在XML中通过注解的方式标记View和ViewModel的绑定关系。编译器在编译过程中,会自动生成XML和ViewModel的绑定类(Binder)。
注解功能很强大,但是不幸的是,我们iOS(Objective-C)没有!!!Swift有没有注解笔者不太清楚,有知道的童鞋可以告诉我一下。
接下来我们将一步步实现一个View和ViewModel双向绑定的框架。
方案一:“躺爽法”
名次解释:所谓“躺爽法”(实在想不出用什么词描述这种最基础的方法了)和KVO,是相对于ViewModel >>> View而言的。
1.ViewModel >>> View:View不需要关心ViewModel属性的改变,View只需要提供更新视图的接口即可,ViewModel属性改变之后调用View提供的API更新视图。所以View这里没有做过多的事情,一切都是被动触发,所以我称作是“躺爽法”。
2.View >>> ViewModel:用户操作视图,比如一个开关按钮,这时候要同步给ViewModel。我们知道View是可以持有ViewModel的,所以在View中我们可以直接拿到ViewModel指针,进而通过ViewModel暴露的方法而更新值。
高能预警:这种最基础的方法,实际上是MVC!!!他本身没有解决“Massive View Controller”问题。也就是说为了ViewModel中不依赖于View,必须通过Controller中转,依然会有一堆胶水代码。所以这种解决方案并不是MVVM!!!不是故意给大家挖坑,只是意在提醒大家,阅读文章的时候要举一反三,更不要被一些脏乱差的文章混淆视听。
方案一:KVO
1.ViewModel >>> View:ViewModel属性改变之后,通知View进行视图布局。这种最熟悉不过,通过KVO即可实现。
2.View >>> ViewModel:用户操作视图,通过ViewModel暴露的更新方法而更新值(设置属性值时要避开触发KVO监听,否则会出现死循环)。
Talk is cheap,show me the code!
我们以大家最熟悉的Cell举例子。
ViewModel
//
// IQMVVMDemoViewModel.h
//
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
@interface IQMVVMDemoViewModel : NSObject
@property (nonatomic, copy, readonly) NSString *userName;
@property (nonatomic, copy, readonly) NSString *userPwd;
+ (IQMVVMDemoViewModel *)demoViewWithName:(NSString *)userName withPwd:(NSString *)userPwd;
- (void)updateViewModelWithName:(NSString *)userName withPwd:(NSString *)userPwd;
@end
NS_ASSUME_NONNULL_END
//
// IQMVVMDemoViewModel.m
//
#import "IQMVVMDemoViewModel.h"
@interface IQMVVMDemoViewModel ()
@property (nonatomic, copy, readwrite) NSString *userName;
@property (nonatomic, copy, readwrite) NSString *userPwd;
@end
@implementation IQMVVMDemoViewModel
+ (IQMVVMDemoViewModel *)demoViewWithName:(NSString *)userName withPwd:(NSString *)userPwd {
IQMVVMDemoViewModel *viewModel = [[IQMVVMDemoViewModel alloc]init];
viewModel.userName = userName;
viewModel.userPwd = userPwd;
return viewModel;
}
- (void)updateViewModelWithName:(NSString *)userName withPwd:(NSString *)userPwd {
_userName = userName;
_userPwd = userPwd;
}
@end
View
//
// IQMVVMDemoView.h
//
#import <UIKit/UIKit.h>
NS_ASSUME_NONNULL_BEGIN
@class IQMVVMDemoViewModel;
@interface IQMVVMDemoView : UITableViewCell
- (void)updateViewWithViewModel:(IQMVVMDemoViewModel *)viewModel;
@end
NS_ASSUME_NONNULL_END
//
// IQMVVMDemoView.m
//
#import "IQMVVMDemoView.h"
#import "IQMVVMDemoViewModel.h"
@interface IQMVVMDemoView ()<UITextFieldDelegate>
@property (nonatomic, strong) UITextField *userNameField;
@property (nonatomic, strong) UITextField *userPwdField;
@property (nonatomic, strong) IQMVVMDemoViewModel *viewModel;
@end
@implementation IQMVVMDemoView
#pragma mark--Life Cycle--
- (void)dealloc {
[self.viewModel removeObserver:self forKeyPath:@"userName"];
[self.viewModel removeObserver:self forKeyPath:@"userPwd"];
}
- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier {
if (self = [super initWithStyle:style reuseIdentifier:reuseIdentifier]) {
[self setupSubviews];
}
return self;
}
#pragma Public & Private Methods--
- (void)setupSubviews {
[self.contentView addSubview:self.userNameField];
[self.contentView addSubview:self.userPwdField];
/*
这里做布局,不写了啊
*/
}
- (void)updateViewWithViewModel:(IQMVVMDemoViewModel *)viewModel {
self.viewModel = viewModel;
[self.viewModel addObserver:self forKeyPath:@"userName" options:NSKeyValueObservingOptionNew context:NULL];
[self.viewModel addObserver:self forKeyPath:@"userPwd" options:NSKeyValueObservingOptionNew context:NULL];
}
#pragma mark--Delegates & KVO--
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
if ([keyPath isEqualToString:@"userName"]) {
self.userNameField.text = change[NSKeyValueChangeNewKey];
} else if([keyPath isEqualToString:@"userPwd"]) {
self.userPwdField.text = change[NSKeyValueChangeNewKey];
}
}
- (void)textFieldDidEndEditing:(UITextField *)textField {
/*更新ViewModel*/
if (textField == self.userNameField) {
self.userNameField.text = textField.text;
} else {
self.userPwdField