聊聊iOS开发之MVVM的架构设计

前言

在开发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的方法监听executingerrorresponseObject的属性即可,代码大致如下:

_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(视图控制器)

    视图控制器通过调用viewModelloadBannerData:failure:loadData:failure:configFooter:来获取商品首页的广告数据(SUBanner)以及商品数据(SUGoods)视图控制器通过使用viewModel上的bannersdataSource数组中的对象来配置表格视图(tableView)的tableViewHeadercell。通常我们会期待展现 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 不就可以了么。所以相对于ViewControllerViewModel来说,Cell上配备的viewModel就是子viewModel
    你不总是需要 子viewModel。 比如,笔者可能用表格 tableHeaderView 视图来渲染简单的页面展示。它不是个可重用的组件,所以笔者可能仅将我们已经给视图控制器用过的相同的 viewModel传给那个自定义的 header 视图。它会用到 viewModel中它需要的信息,而无视余下的部分。
    针对上面👆发现的问题,笔者优化如下:

从上面👆可知,dataSource是一个里面装着SUGoodsItemViewModel的对象数组,在表格视图中的 tableView: cellForRowAtIndexPath:方法中,将会从视图控制器的viewModeldataSource中通过正确的索引获取到子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设计模式的那些事

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

推荐阅读更多精彩内容