聊聊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设计模式的那些事

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏儿童编程

声音功能让儿童编程更有创造性

导读:Scratch中声音功能非常强大,除了常规的音效,你甚至可以模拟各种乐器的各个发音、设置节拍、休止……如果你愿意,甚至可以用它创作一个交响乐。我们可以引导...

13940
来自专栏儿童编程

天干地支五行八卦的对应关系

19790
来自专栏儿童编程

我不是算命先生,却对占卜有了疑惑——如何论证“占卜前提”的正确与否

事出有因,我对《周易》感兴趣了很多年。只是觉得特别有趣,断断续续学习了一些皮毛。这几天又偶然接触到了《梅花易数》,觉得很是精彩,将五行八卦天干地支都串联了起来。...

15610
来自专栏haifeiWu与他朋友们的专栏

复杂业务下向Mysql导入30万条数据代码优化的踩坑记录

从毕业到现在第一次接触到超过30万条数据导入MySQL的场景(有点low),就是在顺丰公司接入我司EMM产品时需要将AD中的员工数据导入MySQL中,因此楼主负...

30740
来自专栏儿童编程

《动物魔法学校》儿童学编程Scratch之“外观”部分

导读:本文通过一个案例《动物魔法学校》来学习Scratch语言的“外观”部分。之后通过一系列其他功能的综合运用对作品功能进行了扩展。

19440
来自专栏FSociety

SQL中GROUP BY用法示例

GROUP BY我们可以先从字面上来理解,GROUP表示分组,BY后面写字段名,就表示根据哪个字段进行分组,如果有用Excel比较多的话,GROUP BY比较类...

5.2K20
来自专栏儿童编程

儿童创造力教育与编程教育的碰撞——MIT雷斯尼克教授最新理论梗概

儿童编程教育已经在我国各一线二线城市疯狂出现,颇有“烂大街”的趋势。我们不禁要问很多很多问题:

22670
来自专栏Ken的杂谈

【系统设置】CentOS 修改机器名

18430
来自专栏儿童编程

一张图理清《梅花易数》梗概

学《易经》的目的不一定是为了卜卦,但是了解卜卦绝对能够让你更好地了解易学。今天用一张思维导图对《梅花易数》的主要内容进行概括,希望能够给学友们提供帮助。

32540
来自专栏儿童编程

什么样的人生才是有意义的人生——没有标准的标准答案

【导读】其实我们可以跳出这个小圈圈去更加科客观地看一下这个世界。在夜晚的时候我们仰望天空,浩瀚的宇宙中整个地球只是一粒浮尘,何况地球上一个小小的人类?在漫长的历...

1.8K50

扫码关注云+社区

领取腾讯云代金券

年度创作总结 领取年终奖励