前言
在开发App的时候,我们的基本目标一般有以下几点:
- `可靠性 - App的功能能够正常使用`
- `健壮性 - 在用户非正常使用的时候,app也能够正常反应,不要崩溃`
- `效率性 - 启动时间,耗电,流量,界面反应速度在用户容忍的范围以内`
上面三点是表象层的东西,是大多数开发者或者团队会着重注意的。除了这三点,还有一些目标是工程方面的也是开发者要注意的:
- `可修改性/可扩展性 - 软件需要迭代,功能不断完善`
- `容易理解 - 代码能够容易理解`
- `可测试性 - 代码能够方便的编写单元测试和集成测试`
- `可复用性 - 不用一次又一次造轮子`
基于这些设计目标和理念,软件设计领域又有了设计模式。MVC/MVVM都是就是设计模式的一种。
在MVC的架构中,Model持有数据,View显示与用户交互的界面,而ViewController调解Model和View之间的交互。
现在,MVC 依然是目前主流客户端编程框架,但同时它也被调侃成Massive View Controller(重量级视图控制器),
开发者在开发中无可避免被下面几个问题所困扰:
- 厚重的ViewController
- 遗失的网络逻辑(无立足之地)
- 较差的可测试性
而MVVM这种新的代码组织方式就可以解决这些问题,本文就MVVM的架构设计做个简单的个人总结。
MVVM概述
从图中我们可以看到MVVM的关系基本是:View <-> C <-> ViewModel <-> Model,
严格来说MVVM其实是MVCVM。Controller夹在View和ViewModel之间做的其中一个主要事情就是将View和ViewModel进行绑定. 在逻辑上,Controller知道应当展示哪个View,Controller也知道应当使用哪个ViewModel, 然而View和ViewModel它们之间是互相不知道的,所以Controller就负责控制他们的绑定关系。
MVVM 一种可以很好地解决Massive View Controller问题的办法
就是将 Controller 中的展示逻辑抽取出来,放置到一个专门的地方,
而这个地方就是 viewModel 。MVVM衍生于MVC,是对 MVC 的一种演进,
它促进了 UI 代码与业务逻辑的分离。
它正式规范了视图和控制器紧耦合的性质,并引入新的组件。他们之间的结构关系如下:
不难看出,MVVM是对MVC的扩展,所以MVVM可以完美的兼容MVC。
对于一个界面来说,有时候View和ViewModel往往不止一个,MVVM也可以组合使用:
MVVM 的基本概念
- 在MVVM 中,view 和 view controller正式联系在一起,我们把它们视为一个组件,
Controller可以当作一个重量级的View(负责界面切换和处理各类系统事件)。
- view 和 view controller 都不能直接引用model,而是引用视图模型(viewModel)
- viewModel 是一个放置用户输入验证逻辑,视图显示逻辑,发起网络请求和其他代码的地方,
它的职责之一就是作为一个表现视图显示自身所需数据的静态模型;但它也有收集, 解释和转换那些数据的责任。
它是从 MVC 的 controller 中抽取出来的展示逻辑,负责从 model中获取 view 所需的数据,
转换成 view可以展示的数据,并暴露公开的属性和命令供 view 进行绑定。
- 使用MVVM会轻微的增加代码量,但总体上减少了代码的复杂性。
MVVM 的注意事项
- viewController 尽量不涉及业务逻辑,让 viewModel 去做这些事情。
- viewController 只是一个中间人,接收 view 的事件、调用 viewModel 的方法、响应 viewModel 的变化。
一方面负责View和ViewModel之间的绑定,另一方面也负责常规的UI逻辑处理。
- view 引用viewModel ,但反过来不行(即不要在viewModel中引入#import UIKit.h,
任何视图本身的引用都不应该放在viewModel中)(PS:基本要求,必须满足)
- viewModel 引用model,但反过来不行
- viewModel 绝对不能包含视图 view(UIKit.h),不然就跟 view 产生了耦合,不方便复用和测试。
- viewModel之间可以有依赖。
- viewModel避免过于臃肿,否则重蹈Controller的覆辙,变得难以维护。
关于MVVM Without ReactiveCocoa
为了让View和ViewModel之间能够有比较松散的绑定关系,于是我们使用ReactiveCocoa,
KVO,Notification,block,delegate和target-action都可以用来做数据通信,
从而来实现绑定,但都不如ReactiveCocoa提供的RACSignal来的优雅,
使用函数响应式框架能更好的实现数据和视图的双向绑定(ViewModel的数据可以显示到View上,
View上的操作同样会引起ViewModel的变化),降低了ViewModel和View的耦合度。
如果不用ReactiveCocoa,绑定关系可能就做不到那么松散那么好,但并不影响它还是MVVM。
MVVM的关键是要有ViewModel。而不是ReactiveCocoa、RXSwift或RXJava等。
而在现实中我倾向于使用 block而不是 KVO,因为KVO的代码量太大了,block则简洁的多。
ReactiveCocoa或RXSwift通过这两个框架可以实现ViewModel和View的双向绑定,
但同样会存在几个比较重大的问题。 首先,ReactiveCocoa或RXSwift的学习成本很高;
其次,
数据绑定使得 Bug 很难被调试,当界面出现异常,可能是View的问题,也可能是数据ViewModel的问题。 而数据绑定会使一个位置的bug传递到其他位置,难以定位。
MVVM Without ReactiveCocoa的一个应用实例
下面的内容源自这篇文章,我觉得举例很得到就引用过来了:原文在这里
-
效果图
-
登录页面逻辑分析图
ViewModel的设计
/// 登录界面的视图模型 -- VM
@interface SULoginViewModel1 : NSObject
/// 手机号
@property (nonatomic, readwrite, copy) NSString *mobilePhone;
/// 验证码
@property (nonatomic, readwrite, copy) NSString *verifyCode;
/// 登录按钮的点击状态
@property (nonatomic, readonly, assign) BOOL validLogin;
/// 用户头像
@property (nonatomic, readonly, copy) NSString *avatarUrlString;
/// 用户登录 为了减少View对viewModel的状态的监听 这里采用block回调来减少状态的处理
- (void)loginSuccess:(void(^)(id json))success
failure:(void (^)(NSError *error))failure;
@end
很明显viewModel
仅仅只暴漏了视图控制器所必需的最小量的信息,设置readonly
属性很有必要,同时,视图控制器C
实际上并不在乎 viewModel
是如何获得这些信息的。切记:ViewModel
千万不要主动对视图控制器C
以任何形式直接起作用或直接通告其变化,而是等待视图控制器C
来主动获取。
想必大家可能对下面的代码存在疑惑,原因可能是:不是说好的 View
绑定ViewModel
的呢?绑定呢?监听呢?....
/// 用户登录 为了减少View对viewModel的状态的监听 这里采用block回调来减少状态的处理
- (void)loginSuccess:(void(^)(id json))success
failure:(void (^)(NSError *error))failure;
对方不想和笔者说话并向笔者扔了一个API
设计
/// 是否正在执行
@property (nonatomic, readonly, assign) BOOL executing;
/// 请求失败的信息
@property (nonatomic, readonly, strong) NSError *error;
/// 请求成功的数据
@property (nonatomic, readonly, strong) id responseObject;
/// 调起登录
- (void) login;
这样设计其实也合理的,ViewController
的登录
按钮被点击时,调用viewModel
上的login
方法,同时ViewController
通过KVO
的方法监听executing
、error
、responseObject
的属性即可,代码大致如下:
_KVOController = [FBKVOController controllerWithObserver:self];
@weakify(self);
/// binding self.viewModel.executing
[_KVOController mh_observe:self.viewModel keyPath:@"executing" block:^(id _Nullable observer, id _Nonnull object, NSDictionary<NSString *,id> * _Nonnull change) {
@strongify(self);
/// 根据executing的值,控制 HUD的显示和隐藏
if([change[NSKeyValueChangeNewKey] boolValue])
{
[MBProgressHUD mh_showProgressHUD:@"Loading..."];
}else{
[MBProgressHUD mh_hideHUD];
}
}];
/// binding self.viewModel.responseObject
[_KVOController mh_observe:self.viewModel keyPath:@"responseObject" block:^(id _Nullable observer, id _Nonnull object, NSDictionary<NSString *,id> * _Nonnull change) {
@strongify(self);
/// 成功的数据处理
}];
/// binding self.viewModel.error
[_KVOController mh_observe:self.viewModel keyPath:@"error" block:^(id _Nullable observer, id _Nonnull object, NSDictionary<NSString *,id> * _Nonnull change) {
@strongify(self);
/// 失败的数据处理
}];
笔者不想和你说话并向你扔了一个问题思考。上面👆一个登陆(login
)操作,我们就要编写这么多代码,试想如果再多一个操作呢?再多两个操作呢?.... 如果不用block
回调,不管你们会不会,总之,我会。下面👇再看看利用block
的回调实现,你们就会解惑,释怀了,起码好受点。
[MBProgressHUD mh_showProgressHUD:@"Loading..."];
@weakify(self);
[self.viewModel loginSuccess:^(id json) {
@strongify(self);
[MBProgressHUD mh_hideHUD];
/// 成功的数据处理
} failure:^(NSError *error) {
/// 失败的数据处理
}];
- ViewController(视图控制器)在此中的作用
1、视图控制器从 viewModel获取的数据将用来:
当validLogin的值发生变化时,触发登录按钮的enabled的属性。
监听avatarUrlString的变化,来更新视图控制器的头像的UIImageView。
2、视图控制器对 viewModel 起如下作用:
每当 UITextField 中的文本发生变化, 更新 viewModel上的 readwrite属性 mobilePhone或者verifyCode
登录按钮被点击时,调用viewModel上的loginSuccess:failure方法。
3、视图控制器不要做的事
发起登录的网络请求
判定登录按钮的有效性
来获取头像的地址(PS:有可能从本地数据库获取,也有可能通过网络请求来获取)
...
请再次注意视图控制器总的责任是处理viewModel中的变化。
商品首页界面的实践
- ViewModel的设计
/// 商品首页的视图模型 -- VM
@interface SUGoodsViewModel1 : NSObject
/// banners
@property (nonatomic, readonly, copy) NSArray <NSString *> *banners;
/// The data source of table view.
@property (nonatomic, readwrite, strong) NSMutableArray *dataSource;
/// load banners data
- (void)loadBannerData:(void (^)(id responseObject))success
failure:(void (^)(NSError *))failure;
/**
* 加载网络数据 通过block回调减轻view 对 viewModel 的状态的监听
@param success 成功的回调
@param failure 失败的回调
@param configFooter 底部刷新控件的状态 lastPage = YES ,底部刷新控件hidden,反之,show
*/
- (void)loadData:(void(^)(id json))success
failure:(void(^)(NSError *error))failure
configFooter:(void(^)(BOOL isLastPage))configFooter;
@end
-
ViewController(视图控制器)
视图控制器
通过调用viewModel
的loadBannerData:failure:
和loadData:failure:configFooter:
来获取商品首页的广告数据(SUBanner)
以及商品数据(SUGoods)
。视图控制器
通过使用viewModel
上的banners
和dataSource
数组中的对象来配置表格视图(tableView
)的tableViewHeader
和cell
。通常我们会期待展现dataSource
的是数据-模型
对象。同时你可能已经对其感到奇怪, 因为我们试图通过MVVM
模式不暴漏数据-模型
对象。 (前面提到过的)。
假设我们暴露数据-模型(SUGoods)
,那就分析如下:
我们不瞎,明显从上图👆可以看出视图 SUGoodsCell
直接引用了模型SUGoods
,这就有悖了MVVM
的初衷:view和 view controller 都不能直接引用model,而是引用视图模型(viewModel)
-
子ViewModel
我们必须明确:viewModel不必在屏幕上显示所有东西。在工作中如果遇到量级非常重的控制器,可以针对实际的业务,将一组业务逻辑相关的代码抽取到一个独立的视图模型中处理。你可用
子viewModel
来代表屏幕上更小的、更潜在的被封装的部分。
一般来说,viewController
可以带一个viewModel
,那如果出现Cell
时怎么办,Cell
里又包含了按钮,按钮又需要数据请求又怎么处理?这些都是比较常见的场景,也可以通过MVVM
来解决。
我们知道viewModel
的职责是为view
提供数据支持,Cell
也是一个View
,那么为Cell
配备一个viewModel
不就可以了么。所以相对于ViewController
的ViewModel
来说,Cell
上配备的viewModel
就是子viewModel
。
你不总是需要子viewModel
。 比如,笔者可能用表格tableHeaderView
视图来渲染简单的页面展示。它不是个可重用的组件,所以笔者可能仅将我们已经给视图控制器用过的相同的viewModel
传给那个自定义的header
视图。它会用到viewModel
中它需要的信息,而无视余下的部分。
针对上面👆发现的问题,笔者优化如下:
从上面👆可知,dataSource
是一个里面装着SUGoodsItemViewModel
的对象数组,在表格视图中的 tableView: cellForRowAtIndexPath:
方法中,将会从视图控制器的viewModel
的dataSource
中通过正确的索引获取到子viewModel
, 并把它赋值给 cell
上的 viewModel
属性。
想必大家还有一个疑惑,数据-模型(SUGoods)是否要通过属性的方式暴露在子视图模型(SUGoodsItemViewModel)的.h文件中?
我们假设要通过SUGoodsItemViewModel
来提供给SUGoodsCell
展示下面👇的界面的数据:
商品模型(SUGoods
)的数据结构如下:
/** 商品运费类型 */
typedef NS_ENUM(NSUInteger, SUGoodsExpressType) {
SUGoodsExpressTypeFree = 0, // 包邮
SUGoodsExpressTypeValue = 1, // 运费
SUGoodsExpressTypeFeeding = 2,// 待议
};
@interface SUGoods : SUModel
/// === 商品相关的属性 ===
....
/// === 商品中的用户相关的信息 ===
/// 用户ID
@property (nonatomic, readwrite, copy) NSString * userId;
/// 用户头像
@property (nonatomic, readwrite, copy) NSString * avatar;
/// 用户昵称:
@property (nonatomic, readwrite, copy) NSString * nickName;
/// 是否芝麻认证
@property (nonatomic, readwrite, assign) BOOL iszm;
@end
假设我们将数据-模型通过属性暴露
在子视图模型的.h中,笔者将设计SUGoodsItemViewModel.h/m
大致代码如下👇:
/// SUGoodsItemViewModel.h
/// 数据-模型(SUGoods)以属性的方式暴露
@interface SUGoodsItemViewModel : NSObject
/// 商品模型
@property (nonatomic, readonly, strong) SUGoods *goods;
/// 用户ID:101921
@property (nonatomic, readonly, copy) NSString * userId;
/// 初始化
- (instancetype)initWithGoods:(SUGoods *)goods;
@end
/// SUGoodsItemViewModel.m
@interface SUGoodsItemViewModel ()
/// 商品模型
@property (nonatomic, readwrite, strong) SUGoods *goods;
/// 用户id
@property (nonatomic, readwrite, copy) NSString *userId;
@end
@implementation SUGoodsItemViewModel
- (instancetype)initWithGoods:(SUGoods *)goods
{
self = [super init];
if (self) {
self.goods = goods;
self.userId = [NSString stringWithFormat:@"用户ID:%@",goods.userId]
}
return self;
}
笔者将设计SUGoodsCell.m
大致代码如下👇:
/// SUGoodsCell.m
- (void)bindViewModel:(SUGoodsItemViewModel *)viewModel
{
self.viewModel = viewModel;
/// 头像
[MHWebImageTool setImageWithURL:viewModel.goods.avatar placeholderImage:placeholderUserIcon() imageView:self.userHeadImageView];
/// 昵称
self.userNameLabel.text = viewModel.goods.nickName;
/// 芝麻认证
self.realNameIcon.hidden = !viewModel.goods.iszm;
/// 用户ID
self.userIdLabel.text = viewModel.userId;
}
既然通过属性暴露了数据-模型(SUGoods)了,为何还要暴露一个userId的属性?有必要吗?很有必要!!!
上面已经提到过ViewModel 提供额外数据转换的属性, 或为特定的视图计算数据。显然我们完全可以不暴露userId,仅仅只要我们在SUGoodsCell.m中这样写即可,根本无伤大雅是吧。
/// SUGoodsCell.m
- (void)bindViewModel:(SUGoodsItemViewModel *)viewModel
{
self.viewModel = viewModel;
/// 头像
[MHWebImageTool setImageWithURL:viewModel.goods.avatar placeholderImage:placeholderUserIcon() imageView:self.userHeadImageView];
/// 昵称
self.userNameLabel.text = viewModel.goods.nickName;
/// 芝麻认证
self.realNameIcon.hidden = !viewModel.goods.iszm;
/// 用户ID
self.userIdLabel.text =[NSString stringWithFormat:@"用户ID:%@",viewModel.goods.userId] ;
}
对此,笔者只能微微一笑很倾城了。因为这个数据的属性过于简单,仅仅只是数据的拼接,看不出viewModel的作用和强大。详情见下面👇商品运费Label的显示逻辑:
/// 邮费情况
NSString *freightExplain = nil;
SUGoodsExpressType expressType = goods.expressType;
if (expressType==SUGoodsExpressTypeFree) {
// 包邮
freightExplain = @"包邮";
}else if(expressType == SUGoodsExpressTypeValue){
// 指定运费
NSString *extralFee = [NSString stringWithFormat:@"运费 ¥%@",goods.expressFee];
freightExplain = extralFee;
}else if (expressType == SUGoodsExpressTypeFeeding){
freightExplain = @"运费待议";
}
self.freightExplain = freightExplain;
至此,笔者相信大家都会把上面👆这段代码写在ViewModel中,通过暴露一个只读(readonly)的freightExplain属性供cell获取展示,而不是Cell中编写这段又臭又长的逻辑代码。
基于 MVVM 的更瘦身的架构设计方式
MVVM的出现主要是为了解决在开发过程中Controller越来越庞大的问题,变得难以维护,
所以MVVM把数据加工的任务从Controller中解放了出来,使得Controller只需要专注于数据调配的工作,
ViewModel则去负责数据加工并通过通知机制让View响应ViewModel的改变。
MVVM是基于胖Model的架构思路建立的,然后在胖Model中拆出两部分:Model和ViewModel。
ViewModel本质上算是Model层(因为是胖Model里面分出来的一部分),所以 ViewModel里面不能包含任何 UIKit的内容。
而且View并不一定适合直接持有ViewModel,因为ViewModel有可能并不是只服务于特定的一个View,
如果我们对于单个复杂View设计一个 ViewModel 是可以让该 View 持有该 ViewModel的。
如图我们设计了一个基于 MVVM 的更瘦身的架构设,这个架构中:
* View - 用来呈现用户界面
* ViewManger - 用来处理View的常规事件,负责管理View
* Controller - 负责ViewManger和ViewModel之间的绑定,负责控制器本身的生命周期。
* ViewModel - 存放各种业务逻辑和网络请求,不能存在 UIKit 有关的东西。
* Model - 用来呈现数据
这种设计的目的是保持View和Model的高度纯洁,提高可扩展性和复用度。
在日常开发中,ViewModel是为了拆分Controller业务逻辑而存在的,
所以ViewModel需要提供公共的服务接口,以便为Controller提供数据。
而ViewManger的作用相当于一个小管家,帮助Controller来分别管理每个subView,ViewManger负责接管来自View的事件,
也负责接收来自Controller的模型数据,
而View进行自己所负责的视图数据绑定工作。
Controller则是最后的大家长,负责将ViewModel和ViewManger进行绑定,
进行数据转发工作。把合适的数据模型分发给合适的视图管理者。
这样的架构设计,就像一条生产线,ViewModel进行数据的采集和加工,Controller则进行数据的装配和转发工作,ViewManger进行接收转发分配来的数据,从而进行负责View的展示工作和管理View的事件。这样,不管哪个环节,都是可以更换的,同时也提高了复用性。
总结
iOS App是一个麻雀虽小,五脏俱全的软件。良好的架构和设计能够让代码容易理解和维护,并且不易出错。但是本文可能也存在错误之处,或者不足之处,希望大家看到有问题的地方在下方留言一起谈论学习,后续可能会持续更新更正本文。
参考文章:
https://github.com/lovemo/MVVMFramework/tree/master/source
MVVM与Controller瘦身实践
iOS 关于MVC和MVVM设计模式的那些事
iOS 关于MVVM Without ReactiveCocoa设计模式的那些事