前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >聊聊iOS开发之MVVM的架构设计

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

作者头像
進无尽
发布2018-09-12 17:08:36
8.7K0
发布2018-09-12 17:08:36
举报
文章被收录于专栏:進无尽的文章進无尽的文章

前言

代码语言:javascript
复制
在开发App的时候,我们的基本目标一般有以下几点:

- `可靠性 - App的功能能够正常使用`
- `健壮性 - 在用户非正常使用的时候,app也能够正常反应,不要崩溃`
- `效率性 - 启动时间,耗电,流量,界面反应速度在用户容忍的范围以内`

上面三点是表象层的东西,是大多数开发者或者团队会着重注意的。除了这三点,还有一些目标是工程方面的也是开发者要注意的:

- `可修改性/可扩展性 - 软件需要迭代,功能不断完善`
- `容易理解 - 代码能够容易理解`
- `可测试性 - 代码能够方便的编写单元测试和集成测试`
- `可复用性 - 不用一次又一次造轮子`

基于这些设计目标和理念,软件设计领域又有了设计模式。MVC/MVVM都是就是设计模式的一种。

在MVC的架构中,Model持有数据,View显示与用户交互的界面,而ViewController调解Model和View之间的交互。  
现在,MVC 依然是目前主流客户端编程框架,但同时它也被调侃成Massive View Controller(重量级视图控制器),
开发者在开发中无可避免被下面几个问题所困扰:

- 厚重的ViewController
- 遗失的网络逻辑(无立足之地)
- 较差的可测试性

而MVVM这种新的代码组织方式就可以解决这些问题,本文就MVVM的架构设计做个简单的个人总结。

MVVM概述

代码语言:javascript
复制
 从图中我们可以看到MVVM的关系基本是:View <-> C <-> ViewModel <-> Model,

严格来说MVVM其实是MVCVM。Controller夹在View和ViewModel之间做的其中一个主要事情就是将View和ViewModel进行绑定. 在逻辑上,Controller知道应当展示哪个View,Controller也知道应当使用哪个ViewModel, 然而View和ViewModel它们之间是互相不知道的,所以Controller就负责控制他们的绑定关系。

代码语言:javascript
复制
MVVM 一种可以很好地解决Massive View Controller问题的办法
就是将 Controller 中的展示逻辑抽取出来,放置到一个专门的地方,
而这个地方就是 viewModel 。MVVM衍生于MVC,是对 MVC 的一种演进,
它促进了 UI 代码与业务逻辑的分离。
它正式规范了视图和控制器紧耦合的性质,并引入新的组件。他们之间的结构关系如下:

不难看出,MVVM是对MVC的扩展,所以MVVM可以完美的兼容MVC。 对于一个界面来说,有时候View和ViewModel往往不止一个,MVVM也可以组合使用:

MVVM 的基本概念

代码语言:javascript
复制
- 在MVVM 中,view 和 view controller正式联系在一起,我们把它们视为一个组件,
  Controller可以当作一个重量级的View(负责界面切换和处理各类系统事件)。
- view 和 view controller 都不能直接引用model,而是引用视图模型(viewModel)
- viewModel 是一个放置用户输入验证逻辑,视图显示逻辑,发起网络请求和其他代码的地方, 
  它的职责之一就是作为一个表现视图显示自身所需数据的静态模型;但它也有收集, 解释和转换那些数据的责任。
  它是从 MVC 的 controller 中抽取出来的展示逻辑,负责从 model中获取 view 所需的数据,
  转换成 view可以展示的数据,并暴露公开的属性和命令供 view 进行绑定。
- 使用MVVM会轻微的增加代码量,但总体上减少了代码的复杂性。

MVVM 的注意事项

代码语言:javascript
复制
- 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

代码语言:javascript
复制
为了让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则简洁的多。

代码语言:javascript
复制
ReactiveCocoa或RXSwift通过这两个框架可以实现ViewModel和View的双向绑定,
但同样会存在几个比较重大的问题。 首先,ReactiveCocoa或RXSwift的学习成本很高;
其次,

数据绑定使得 Bug 很难被调试,当界面出现异常,可能是View的问题,也可能是数据ViewModel的问题。 而数据绑定会使一个位置的bug传递到其他位置,难以定位。

MVVM Without ReactiveCocoa的一个应用实例

下面的内容源自这篇文章,我觉得举例很得到就引用过来了:原文在这里

  • 效果图
  • 登录页面逻辑分析图
  • ViewModel的设计
代码语言:javascript
复制
/// 登录界面的视图模型 -- 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的呢?绑定呢?监听呢?....

代码语言:javascript
复制
/// 用户登录 为了减少View对viewModel的状态的监听 这里采用block回调来减少状态的处理
- (void)loginSuccess:(void(^)(id json))success
         failure:(void (^)(NSError *error))failure;

对方不想和笔者说话并向笔者扔了一个API设计

代码语言:javascript
复制
/// 是否正在执行
@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的属性即可,代码大致如下:

代码语言:javascript
复制
_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的回调实现,你们就会解惑,释怀了,起码好受点。

代码语言:javascript
复制
[MBProgressHUD mh_showProgressHUD:@"Loading..."];
@weakify(self);
[self.viewModel loginSuccess:^(id json) {
    @strongify(self);
    [MBProgressHUD mh_hideHUD];
    /// 成功的数据处理
} failure:^(NSError *error) {
   /// 失败的数据处理
}];
  • ViewController(视图控制器)在此中的作用

1、视图控制器从 viewModel获取的数据将用来:

代码语言:javascript
复制
当validLogin的值发生变化时,触发登录按钮的enabled的属性。
 监听avatarUrlString的变化,来更新视图控制器的头像的UIImageView。

2、视图控制器对 viewModel 起如下作用:

代码语言:javascript
复制
每当 UITextField 中的文本发生变化, 更新 viewModel上的 readwrite属性 mobilePhone或者verifyCode
登录按钮被点击时,调用viewModel上的loginSuccess:failure方法。

3、视图控制器不要做的事

代码语言:javascript
复制
发起登录的网络请求
判定登录按钮的有效性
来获取头像的地址(PS:有可能从本地数据库获取,也有可能通过网络请求来获取)
...

请再次注意视图控制器总的责任是处理viewModel中的变化。

商品首页界面的实践
  • ViewModel的设计
代码语言:javascript
复制
/// 商品首页的视图模型 -- 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)的数据结构如下:

代码语言:javascript
复制
/** 商品运费类型 */
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大致代码如下?:

代码语言:javascript
复制
/// 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大致代码如下?:

代码语言:javascript
复制
///  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中这样写即可,根本无伤大雅是吧。

代码语言:javascript
复制
  ///  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的显示逻辑:

代码语言:javascript
复制
/// 邮费情况
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 的更瘦身的架构设计方式

代码语言:javascript
复制
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 的更瘦身的架构设,这个架构中:

代码语言:javascript
复制
*   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设计模式的那些事

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2018.07.20 ,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 前言
  • MVVM概述
  • 关于MVVM Without ReactiveCocoa
  • MVVM Without ReactiveCocoa的一个应用实例
  • 基于 MVVM 的更瘦身的架构设计方式
  • 总结
相关产品与服务
验证码
腾讯云新一代行为验证码(Captcha),基于十道安全栅栏, 为网页、App、小程序开发者打造立体、全面的人机验证。最大程度保护注册登录、活动秒杀、点赞发帖、数据保护等各大场景下业务安全的同时,提供更精细化的用户体验。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档