iOS MVC、MVVM的思考和小结

2019-09-29  本文已影响0人  小白进城

本文参考了文章浅谈 MVC、MVP 和 MVVM 架构模式并引用了部分的内容,结合自己以往的经验,总结了一下自己对几种架构的理解。

1. iOS 中的MVC

iOS中经典MVC图解

上图是对 iOS 中MVC经典的图解:将整个应用分成 Model、View和Controller三个部分,这三个部分的职责如下:

1.1 图解示意

从上图我们可以看到,控制器可以数据模型、视图进行通信,而数据模型和视图之间是双黄线,是相互隔离的,它们之间的关联是间接的--通过桥梁控制器,因此控制器负责的任务相对繁杂,其中包括视图的管理,如视图的初始化、添加、布局等,来自视图操作事件的响应,如列表的数据源代理,按钮的操作事件,手势的响应等,对数据模型的获取、更新,已经数据模型和视图之间同步等等。这样一来,就可以让数据模型和视图实现解藕以提高两者的复用性和可扩展性。

数据层 Model

当数据层中的数据模型发生变化时,可以通过通知或者KVO被动监听等形式将消息传递出去,传递给哪些需要变化状态的对象,例如这里的控制器。控制器在得到数据层变化的消息后,通过数据层提供的接口(如属性或方法),拿到数据填充到视图中,达到数据层和视图层的同步。

视图层 View

类似的,视图层为了达到解藕的目的,通常并不会直接持有或操作数据层中的数据模型,而是通过几种解藕手段:action-targetdelegateblock等方式,将用户的操作事件传递给控制器,控制器拿到相应的事件后,操作数据层相应的数据模型进行更新数据模型、填充视图等等。

控制器 Controller

控制器在实现数据模型和视图解藕的过程中扮演着非常重要的角色。它不仅要管理视图和数据模型,完成两者之间的链接同步问题,还要管理应用页面的导航结构、当前页面的生命周期、存储一些状态信息等等。

1.2 层级之间负责的职责

1.2.1 Model层的职责

数据层的数据模型通常都是一些单纯的数据结构,将服务端返回的 JSON 或者说 Dictionary 字典对象中的字段一一取出并装填到预先定义好的数据结构中。

现有的大多数应用都会将网络服务组织成单独的一层,所以有时候你会看到所谓的 MVCS 架构模式,它其实只是在 MVC 的基础上加上了一层服务层(Service),即在控制器中进行网络请求,将回调中 JSON 数据交给模型层处理,接着模型层将数据传递给控制器,控制器传递给视图层进行展示。如果按照模型层的职责来讲,MVCS中的就服务层应用属于是模型层,服务层可以看作是模型层从服务器拉取数据的入口,同样的从本地数据库、缓存中获取的数据部分都应该属于模型层的职责。

客户端的数据持久化和缓存在用户体验上起到了非常重要的作用。和服务层一样,数据的缓存和持久化都是模型层数据产生的入口。

模型层为上层提供的接口实际上就是自身的一系列属性,它将服务器返回的 JSON 经过加工处理变成了即插即用的数据。

模型中除了一些数据结构外,有时还可能包含一些数据的转换,字段校验,又或者是多种数据的组合处理结果。

小结

iOS 中的数据模型层不应该是单纯的数据结构,它应该包含一些必要的数据获取入口,数据的处理以及持久化的职责,同时为上层一些获取、转换数据的接口。我们应该将更多的与数据有关的业务逻辑转移到模型层中,以此简化控制器的复杂性。

1.2.2 视图层

UIView 是 iOS 中用于渲染和展示内容的最小单元,UIKit 中绝大部分的视图类都以UIView为基础,而视图层最重要的职责就是渲染和显示内容给用户。

UIView 在接口中提供了操作和管理视图层级的属性和方法,比如superviewsubviews以及 -addSubview: 等方法。也就是说UIView以及它的子类都可以拥有子视图,成为容器并包含其他视图。

UIView提供了相当多的接口供上层使用,比如最基本的布局方式 frame,添加视图的 -addSubview:,获取子视图的subviews等等。UIView的演变出的其他子类视图根据使用情况拥有了更多的上层接口,例如UIButton的用户操作事件的接口action-targetUITableView获取样式的代理、数据源代理等。

小结

视图层主要用于渲染和展示内容以及作为其他视图的容器,展示怎么样的内容,如何展示则需要提供上层接口供上层定义和使用。如果你自定义了视图,同样需要实现相似功能。

1.2.3 控制器

控制器作为链接众多的模型和视图扮演着非常重要的角色,负责的了相当多的任务,因此在一些复杂的页面中,控制器看起来相当臃肿,在遵循MVC架构下,即使规范代码,使用pragma分块和分类extension(swift常用)的情况下,控制器依然堆积了相当多的代码,效果一般。

总体来说,控制器主要负责一下的职责(包括但不仅限于)。

由于每个控制器都持有一个根视图,而所有的视图都需要成为这个根视图的子视图在屏幕上展示,而控制器则承担了应用中路由的工作,福州处理界面之间的跳转。

视图层的对象需要添加到控制器中持有的根视图上,无论你是UIView对象,还是其衍生出来的其他子视图,又或者是自定义视图,最终都需要添加到根视图上,而视图的布局过程都由控制器承担,你需要在控制器中完成这些视图(视图容器)的布局过程,xibStoryboards都应该看作是控制器的组成部分。

在控制器中处理用户的行为是经常要做的事情,控制器中有着许多视图不具备的能力,例如页面之间的跳转依赖导航控制器,设备摇晃事件等,而且有些用户行为需要改变模型层中的对象、持久化数据等,这些都不应该出现在视图层而导致直接耦合,而应该秉承着MVC的设计理念,将这些耦合的“胶水”代码放在控制器中进行处理。

控制器作为整个MVC架构的中枢,承担着非常重要的职责,不仅要和Model层和View层进行交互,还需要通过 AppDelegate 和诸多的应用生命周期打交道。除此之外,由于每个控制器都持有一个视图对象,所有控制器还要负责这个根视图的加载、布局以及生命周期的管理。

在控制器中处理模型层和视图层的过程中,经常会产生一些状态信息,例如页面类型,当前数据的分页数量等等。

这是我们处理视图提供的上层接口的常用手段:代理,例如页面中经常使用的视图UITableView会将控制器作为它的代理UITableViewDelegate和数据源UITableViewDataSource。很多文章中都提供一种用于减少控制器代理方法的数量的技巧,就是使用来一个单独的类作为UITableView或者其他视图的代理。但是这种并方案 并没有实质上的用处,只是将代理方法挪到类另外一个地方,如果这个代理方法还依赖当前控制器的上下文,还需要向这个代理传入更多的数据对象,反而让原有的逻辑变得更加的复杂。

除了上面的一些责任,还有非常多的可能出现在任务,例如在一些简单的页面,我们通常不会创建Service层,而是将数据请求直接放在了控制器中;再比如对一些应用状态通知的监听和处理,又或者是其他业务产生的额外操作等。

小结

控制器层的职责就是处理视图层和数据模型层的上层接口的处理,因为其作用就是处理视图层的用户操作,更新视图内容并更新数据模型。除此之外,可以说整个应用在中,非视图层和数据模型层的任务都会成为控制器的任务,而这正是导致控制器经常变得臃肿的根源所在。

1.3 几点建议

在 iOS 开发过程中,给控制器瘦身一直是开发者努力的方向,因此在MVC的基础上衍化出了许许多多的架构模式,但无论是那种模式,代码遵循一套统一的规范都非常重要,规范能够让我们的代码具有非常高的可维护性,要知道,无论你的代码有多么的华丽酷炫,使用了多么高明的手段,在开发团队中,维护他人代码或者别人维护你的代码,是相当头痛的一件事,因此维护成本也是需要考虑的事情,能都快速迭代,符合需求才是硬道理。

这个问题就大去了,代码中的注释就像是一份开发文档,在维护代码时,我们最想要的就是注释,而注释恰恰却又是我们最不想写的东西了。不写注释看似减少了开发时间,实则不然:“我写的代码,今天只有我和上帝清楚明白,而明天就只有上帝知道这TM是啥!”。因此代码的注释真的是不能少,就算是简单明了的功能,至少也应该写上时间和作者吧。

试想一下,一份数千行的陌生代码摆在你的面前你会怎样?不熟悉这个模块的开发者想要快速定位自己想要的信息是非常麻烦,一种无力和绝望感让人想骂娘。逻辑异常复杂的代码如果没有被组织好的话,那分析起来更是让人身心俱疲。

既然我们无法避免的再简化代码,我们只能调理美化代码了,在OC中,有这用于分割代码的pragma对你的代码进行分割:

#pragma mark - 生命周期
...
#pragma mark - 设置UI
...
#pragma mark - getter/setter
...
#pragma mark - 获取数据
...
#pragma mark - 视图代理
...
#pragma mark - 操作事件
...
#pragma mark - 其他私有方法
...

在 Swift 中,我们常用的就是使用分类扩展extension加上MARK来对代码进行分块:

class ViewController: UIViewController {}

// MARK: - 设置UI
extension ViewController {}

// MARK: - Getter/Setter
extension ViewController {}

// MARK: - UITableViewDataSource
extension ViewController: UITableViewDataSource {}

// MARK: - UITableViewDelegate
extension ViewController: UITableViewDelegate {}

// MARK: - 操作事件
extension ViewController {}

// MARK: - 私有方法
extension ViewController {}

相比于使用主动对视图对象进行初始化,我更喜欢使用重写Getter懒加载方式对视图进行初始化,而在加到父视图上时在进行布局等操作,这样做的原因时可以在将所有视图的初始化延迟到需要的那一刻,并将所有视图独自分块,集中分类到Getter/Setter中。当然,这个只是个人喜好,你也可以主动对视图对象进行初始化,又或者使用xib或者** Storyboards**完成。

为了瘦身控制器,我们可以适当的将涉及模型层的东西移植到模型层去,因为这些逻辑本身和控制器没有太多的关系,例如,对服务器返回的时间戳将其转换为字符串格式、数据的网络请求、本地数据的获取等等。

因为UIKit框架涉及的原因,控制器和View层时强耦合的,每个控制器都会持有一个UIView视图对象,这也是导致我们将很多视图层代码直接放在控制器中的原因。

在页面中的视图层非常多时,大量的初始化、配置和布局代码占据了大量的位置,这样我们可以将视图层进行合并分块,集中到某几个或者一个单独的视图中,而只暴露出必要的上层接口供控制器调用和完成。

在控制器对象中,我么可以复写-loadView方法改变其本身持有的视图对象,并使用新的 @property声明以及 @dynamic 改变 Controller 持有的根视图,这样我们就把视图层的配置和布局代码从控制器中完全分离了。

@interface ViewController ()
@property (nonatomic, strong) RegisterView *view;
@end

@implementation ViewController
@dynamic view;
- (void)loadView {
    self.view = [[RegisterView alloc] initWithFrame:[[UIScreen mainScreen] bounds]];
}
- (void)viewDidLoad {
    [super viewDidLoad];
}
@end

1.4 MVC的意义

MVC 最重要的目的并不是规定各个模块应该如何进行交互和联系,而是将原有的混乱的应用程序划分出合理的层级,把一团混乱的代码,按照各自的责任分成相应部分。MVC结构并没有一个明确统一定义,因平台不同,所理解的MVC也不同,网上流传的架构图也是形态各异。无论何种架构模式,找到满足自己开发团队的项目架构模式,才是最重要的。


2. iOS 中的MVVM

上一节中,我们主要分析了MVC的各个层级之间的如何通信的以及各自的职责是什么。我们发现,模型层和视图层只负责了自己的部分,对外界只暴露出一些上层接口,提供给外界使用,谁在使用,为什么使用,它们都漠不关心,而控制器为两者牵线搭桥,费心费力,实现两者之间的信息同步,除此之外,控制器还要负担页面跳转,各种生命周期,其他额外的业务逻辑等等事情,简直为这个家,哦,不,这个应用操碎了心,而开发者则扮演了上帝的角色,如果功能模块相对简单,随他们怎么玩,都能应对自如,毫无压力,但是随着功能模块的迭代,功能越来越复杂,逻辑越堆越多,这位上帝有点坐不住了,控制器的内容越来越看不懂了,太过臃肿了,这可咋办呀?

因此,MVC的缺点也就渐渐开始暴露了,功能简单的,它能够轻松应对,很好的让其分层,各负其责,但是随着功能的复杂化,操碎心的控制器变得非常臃肿,不是控制器完成不了它任务了,而是作为开发者的我们受不了了,因为控制器中的内容太难维护了,因此想找出一条为控制器瘦身的方法,因此在MVC的基础上衍化出了非常多的架构模式,目的就是为这位视图层和模型层的链接者减负。我们这里来介绍一下MVVM的设计模式。

2.1 VM所扮演的角色

VM,是View-Model的简称,视图模型,实质上是一个具有一定功能对象,帮助分担控制器的一些职责,既然VM的引入是为了控制器的减负任务,那么我们来看一下控制器目前的职责:

页面跳转VM能够完成吗?似乎可以,但是我们需要获取到控制的上下文,如导航控制器,似乎不太合适;
负责视图层的管理?额,让VM去管理视图层的初始化、添加、布局,似乎不太合适;
负责用户行为?嗯,似乎可以承担一些需要操作模型层的用户行为。
管理生命周期?页面的生命周期应该不行,但是可以负责应用AppDelegate中的部分逻辑;
存储状态信息?嗯,应该没什么大问题;
视图的代理或数据源?作为视图的代理的话,只要不涉及控制器上下文中的东西应该不成问题,作为数据源的话,也应该没问题,因为VM就可以承担控制器对数据模型层的管理;
其他业务逻辑?这个范围就大了,这些业务可能需要控制器上下问的数据,也可能不需要,似乎需要看情况来定。

总结一下,似乎VM应该去承担控制器中无需似上下文数据的任务,例如数据层的管理、部分用户行为的响应,功能页面的状态信息等等,而控制器去完成需要上下文的功能,如页面跳转,生命周期,以及视图层的管理,以及业务整体流程(无实现细节部分)。

MVVM的示意

上图是对MVVM的简单示意,我们可以看到,控制器和模型之间被VM插足了,控制器不再享有对模型的控制权,这个权利和责任分担给了引入VM这个新角色。

视图模型将模型层的数据与复杂的业务逻辑封装成属性或简单数据同时暴露给视图,让视图可以保持与其的属性同步。
视图模型中包含了视图渲染所需要的一些动态信息,例如视图内容(text文本,color颜色等等)、组件是否可用(enable)等等,除此之外还会将一些方法暴露给视图用于响应一些事件(通常是控制器在使用)。

2.2 模型和视图的同步

虽然很多人认为MVVM的架构最主要的是涉及模型和视图的双向绑定技术,但是我并不这样认为,因为双向绑定只是一种技术手段,实现的方式多种多样,另外MVC就不能双向绑定了吗?MVVM最重要的是分层架构思想,和其他架构一样,其目的并不是规定各个模块应该如何进行交互和联系,而是将原有的混乱的应用程序划分出合理的层级,把一团混乱的代码,按照各自的责任分成相应部分。

既然提到了双向绑定,我们这里稍微解释一下,双向绑定即视图响应用户行为改变着数据模型,相反地,数据模型的改变同样会反过来改变着视图的渲染。示例场景如登录功能的页面,两个文本输入框,记录着账号密码,一个暂时不可使用的登录按钮,响应登录请求,当用户在两个文本框中输入内容时,按钮需要变为可用状态,实时准备响应用户点击按钮是行为。这里涉及到数据模型记录者账号和密码以及一个按钮的状态enable,我们将输入框的内容绑定到数据模型的两个字段中,当两个字段都有内容时,数据模型改变按钮状态属性enableYES,当外界监听到该属性为真时,操作视图,让按钮视图变为可以用状态。

关于视图和模型之间的同步,在MVC的架构下,同步的代码一般都是放在控制器中,他们三者之间的通信在本文最开始的图片中已经详细演示,那么引进了VM对象之后,VM可以提供视图的状态(属性)和行为(动作)的抽象,但这同步代码现在应该放在哪里呢?

数据模型->视图层

Model、View、Controller和View-Model这四个层级,显然,Model绝不应该完成同步工作;

放在View层中?那么视图必然要持有View-Model,并在视图内部完成VM状态和行为的监听以及接下来内容的更新,这看起来很合理,因为同步工作分担到了每个视图中,一定程度上瘦身了原本控制器的,这也是网上非常多的文章中介绍到的双向绑定时,让VM持有Model,VM持有了View,但是这种设计恰恰违背了MVC中重要的一点,解藕!试想,MVC费心费力的让模型层和视图层解藕,让两者可以复用,而让视图View持有VM,直接让视图对当前的VM产生了依赖,如果想要复用该视图,要么提供对应的VM,要么提供新的VM,让该视图使用新VM,但这会提高视图的复杂性。

放在View-Model中?那势必需要将对应的视图对象传递给VM,随着需要同步的视图对象变多,需要传递的视图更多,而且控制器的臃肿慢慢转向了VM,这样导致我们仅仅将控制器中的部分代码搬至到了一个叫做View-Model对象中,毫无意义,而且让维护者同时看两份文件,显然并不合适。

放在控制器中?额,这个跟以前一样。

视图层->数据模型

因为View-Model保存了视图的状态和行为,我们同步视图层到数据模型层的信息只需要改变VM即可。能够同时接触到视图层和View-Model并且不能产生新的依赖只有控制器了。

综上所述,在MVVM架构下,关于双向绑定的问题,最好还是不要发生变动,这部分内容还是应该留给控制器去负责,而VM的责任就是隐藏模型层中复杂的业务逻辑,将其封装成属性或者简单的即拆即用的数据,另外保存一些控制器/视图的状态和行为接口,暴露给外界使用。

2.3 ReactiveCocoa

关于双向绑定实现信息同步的问题,iOS 中提供了大量的通信模式,如NotificationKVODelegateBlockAction-Target等等多种多样的方式,然后需要你在控制器中完成同步的任务。

ReactiveCocoa 则定义了事件的标准接口-数据流,并且可以任意的使用一组基本工具单元轻松地链接、过滤、组合他们,代替了上述的复杂方式。

我们拿上一节中,登录功能来演示ReactiveCocoa的双向绑定。

首先是数据模型和View-Model,View-Model中有一个登录按钮是否可用的抽象,记录用户名和密码的本可以抽出来作为数据模型,但这里为来省事就省去了。

#import <Foundation/Foundation.h>
@interface LoginViewModel : NSObject
@property (copy, nonatomic) NSString* usename;  // 用户名
@property (copy, nonatomic) NSString* password; // 密码
@property (assign, nonatomic) BOOL enable;      // 登录按钮是否可用
@end

@implementation LoginViewModel
@end

在我们的控制器中,导入View-Model和ReactiveCocoa三方库的头文件:

#import "LoginViewController.h"
#import "LoginViewModel.h"
#import <ReactiveCocoa/ReactiveCocoa.h>
#import <ReactiveCocoa/RACEXTScope.h>

@interface LoginViewController ()
@property (weak, nonatomic) IBOutlet UITextField *usernameTextField;
@property (weak, nonatomic) IBOutlet UITextField *passwordTextField;
@property (weak, nonatomic) IBOutlet UIButton *signInButton;
@property (strong, nonatomic)LoginViewModel* viewModel;
@end

@implementation LoginViewController

- (void)viewDidLoad {
  [super viewDidLoad];
    // 监听来自文本框的变化
    [self.usernameTextField addTarget:self action:@selector(usernameTextFieldChanged:) forControlEvents:UIControlEventEditingChanged];
    [self.passwordTextField addTarget:self action:@selector(passwordTextFieldChanged:) forControlEvents:UIControlEventEditingChanged];
}

// View-Model的懒加载初始化
-(LoginViewModel *)viewModel{
    if (_viewModel==nil) {
        _viewModel = LoginViewModel.new;
    }
    return _viewModel;
}

// 更新最新状态
- (void)updateUIState {
    // 模型层的变化同步到VM
    self.viewModel.enable = self.viewModel.usename.length > 0 && self.viewModel.password.length > 0;
    // VM变化同步到视图层
    self.signInButton.enabled = self.viewModel.enable;
}
// 验证
- (void)usernameTextFieldChanged:(UITextField*)textFiled{
    // 视图层的变换同步到模型层
    self.viewModel.usename = textFiled.text;
    [self updateUIState];
}
// 验证
- (void)passwordTextFieldChanged:(UITextField*)textFiled{
    // 视图层的变换同步到模型层
    self.viewModel.password = textFiled.text;
    [self updateUIState];
}

在未使用到 ReactiveCocoa 时,我们的写法一般可能像上面那样,监听文本本框的改变事件,在内容发生改变时,进行校验,数据同步到数据莫模型层,数据莫模型层的按钮可用状态信息同步到视图层--注册按钮。

这里是我们一贯使用到的手段,虽然实现了功能需求,但是有几点问题需要考虑:

  1. 控制器中的双向绑定都是开发者手动串联起来,这里还没有使用KVO,通知等其他手段,如果使用可能会更加复杂;

  2. 双向绑定的逻辑相对分散,可能有人觉得逻辑一目了然,但是随着业务复杂,这些分散的逻辑会淹没在臃肿的控制器中,后期维护成本变高。

接下来我们来演示一下 ReactiveCocoa 的使用:

...
- (void)viewDidLoad {
  [super viewDidLoad];
    // 将文本的内容变化绑定到数据模型
    RAC(self.viewModel, usename) = self.usernameTextField.rac_textSignal;
    RAC(self.viewModel, password) = self.passwordTextField.rac_textSignal;
    RAC(self.viewModel, enable) = [RACSignal combineLatest:@[self.usernameTextField.rac_textSignal, self.passwordTextField.rac_textSignal] reduce:^id(NSString* username, NSString* password){
        return @(username.length > 0 && password.length > 0);
    }];
    // 将模型的变化绑定到按钮视图
    RAC(self.signInButton, enabled) = RACObserve(self.viewModel, enable);
}
...

可能没有接触过ReactiveCocoa的朋友会很懵,没关系,你只需要关心注释部分就好了,关于 ReactiveCocoa 的知识你可以在网上学到更多,这里不做讲解。

我们可以看到,使用了 ReactiveCocoa 之后,我们的绑定代码都集中到了一起,并且数据从哪里来到哪里去都非常清楚,这也是ReactiveCocoa 所谓的数据流的特点。我们将视图的用户行为都绑定到了数据模型层或者VM上,又将VM上的变化绑定到了视图层,而ReactiveCocoa也并没有让数据模型层和视图层产生关联。

实际上,因为业务非常简单,这里的数据模型和VM都可以省去的,只不过为了演示一下MVVM的分层思想而强制引入,如果省去这部分,你只需要像这样就可以完成之前的需求:

RAC(self.signInButton, enabled) = [RACSignal combineLatest:@[self.usernameTextField.rac_textSignal, self.passwordTextField.rac_textSignal] reduce:^id(NSString* username, NSString* password){
    return @(username.length > 0 && password.length > 0);
}];

我们使用了组合信号RACSignalusernameTextFieldpasswordTextField中的内容变化通过reduce订阅块回调出来,再将信息验证之后包装成NSNumber 类型再次扔了出去,并被下一个接收者 self.signInButton 捕获并作用于属性 enabled

这里只简单的演示双向绑定和ReactiveCocoa的基本使用,更多的使用方式或者实现原理请参考其他的网上博客,有非常多的讲解。我的上两篇简单讲解了使用教程,有兴趣的朋友可以参考。

MVVM+RAC 个人Demo。


3.0 总结

架构一直是我们开发工程师热议的问题,经过了几十年的发展和演变,从 MVC 架构到后面衍化出的繁多变种,这些架构没有谁优谁劣,都是工程师们心血,都值得我们那去了解和尊重。

在架构模式的选用上,我们往往都没有太多的发言权,因为平台本身往往有着自己的设计思路,我们在开发过程中,只需要遵循平台固有的设计就能完成开发;不过,在有些是有,由于工程变得庞大、业务逻辑变得异常复杂,我们也应对考虑在原有的架构之上实现一个新的架构来满足工程上的需求。

各种结构模式的作用就是分层分类,将属于不同模块的功能分散到合适的层级中,同时尽量降低各个模块的相互依赖并减少需要联系的胶水代码。本文中MVC和MVVM架构的描述很多都是作者的主观意见,如果文中有不对的欢迎指出。

上一篇下一篇

猜你喜欢

热点阅读