iOS 客户端应用架构看似简单,但实际上要考虑的事情不少。本文作者将以系列文章的形式来回答 iOS 应用架构中的种种问题,本文是其中的第二篇,主要讲 View 层的组织和调用方案。中篇主要讨论 MVC、MVCS、MVVM、VIPER 等架构在 iOS 开发中的应用。
关于 MVC、MVVM 等一大堆思想
其实这些都是相对通用的思想,万变不离其宗的还是在开篇里面我提到的那三个角色:数据管理者,数据加工者,数据展示者。这些五花八门的思想,不外乎就是制订了一个规范,规定了这三个角色应当如何进行数据交换。但同时这些也是争议最多的话题,所以我在这里来把几个主流思想做一个梳理,当你在做 View 层架构时,能够有个比较好的参考。
MVC
MVC(Model-View-Controller)是最老牌的的思想,老牌到 4 人帮的书里把它归成了一种模式,其中 Model 就是作为数据管理者,View 作为数据展示者,Controller 作为数据加工者,Model 和 View 又都是由 Controller 来根据业务需求调配,所以 Controller 还负担了一个数据流调配的功能。正在我写这篇文章的时候,我看到 InfoQ 发了这篇文章,里面提到了一个移动开发中的痛点是:对 MVC 架构划分的理解。我当时没能够去参加这个座谈会,也没办法发表个人意见,所以就只能在这里写写了。
在 iOS 开发领域,我们应当如何进行 MVC 的划分?
这里面其实有两个问题:
- 为什么我们会纠结于 iOS 开发领域中 MVC 的划分问题?
- 在 iOS 开发领域中,怎样才算是划分的正确姿势?
为什么我们会纠结于 iOS 开发领域中 MVC 的划分问题?
关于这个,每个人纠结的点可能不太一样,我也不知道当时座谈会上大家的观点。但请允许我猜一下:是不是因为 UIViewController 中自带了一个 View,且控制了 View 的整个生命周期(viewDidLoad,viewWillAppear…),而在常识中我们都知道 Controller 不应该和 View 有如此紧密的联系,所以才导致大家对划分产生困惑?,下面我会针对这个猜测来给出我的意见。
在服务端开发领域,Controller 和 View 的交互方式一般都是这样,比如 Yii:
/* ... 数据库取数据 ... 处理数据 ... */ // 此处 $this 就是 Controller $this->render("plan",array( 'planList' => $planList, 'plan_id' => $_GET['id'], ));
这里 Controller 和 View 之间区分得非常明显,Controller 做完自己的事情之后,就把所有关于 View 的工作交给了页面渲染引擎去做,Controller 不会去做任何关于 View 的事情,包括生成 View,这些都由渲染引擎代劳了。这是一个区别,但其实服务端 View 的概念和 Native 应用 View 的概念,真正的区别在于:从概念上严格划分的话,服务端其实根本没有 View,拜 HTTP 协议所赐,我们平时所讨论的 View 只是用于描述 View 的字符串(更实质的应该称之为数据),真正的 View 是浏览器。。
所以服务端只管生成对 View 的描述,至于对 View 的长相,UI 事件监听和处理,都是浏览器负责生成和维护的。但是在 Native 这边来看,原本属于浏览器的任务也逃不掉要自己做。那么这件事情由谁来做最合适?苹果给出的答案是:UIViewController。
鉴于苹果在这一层做了很多艰苦卓绝的努力,让 iOS 工程师们不必亲自去实现这些内容。而且,它把所有的功能都放在了 UIView 上,并且把 UIView 做成不光可以展示 UI,还可以作为容器的一个对象。
看到这儿你明白了吗?UIView 的另一个身份其实是容器!UIViewController 中自带的那个 view,它的主要任务就是作为一个容器。如果它所有的相关命名都改成 ViewContainer,那么代码就会变成这样:
- (void)viewContainerDidLoad { [self.viewContainer addSubview:self.label]; [self.viewContainer addSubview:self.tableView]; [self.viewContainer addSubview:self.button]; [self.viewContainer addSubview:self.textField]; } ... ...
仅仅改了个名字,现在是不是感觉清晰了很多?如果再要说详细一点,我们平常所认为的服务端 MVC 是这样划分的:
但事实上,整套流程的 MVC 划分是这样:
由图中可以看出,我们服务端开发在这个概念下,其实只涉及 M 和 C 的开发工作,浏览器作为 View 的容器,负责 View 的展示和事件的监听。那么对应到 iOS 客户端的 MVC 划分上面来,就是这样:
唯一区别在于,View 的容器在服务端,是由 Browser 负责,在整个网站的流程中,这个容器放在 Browser 是非常合理的。在 iOS 客户端,View 的容器是由 UIViewController 中的 view 负责,我也觉得苹果做的这个选择是非常正确明智的。
因为浏览器和服务端之间的关系非常松散,而且他们分属于两个不同阵营,服务端将对 View 的描述生成之后,交给浏览器去负责展示,然而一旦 view 上有什么事件产生,基本上是很少传递到服务器(也就是所谓的 Controller)的(要传也可以:AJAX),都是在浏览器这边把事情都做掉,所以在这种情况下,View 容器就适合放在浏览器(V)这边。
但是在 iOS 开发领域,虽然也有让 View 去监听事件的做法,但这种做法非常少,都是把事件回传给 Controller,然后 Controller 再另行调度。所以这时候,View 的容器放在 Controller 就非常合适。Controller 可以因为不同事件的产生去很方便地更改容器内容,比如加载失败时,把容器内容换成失败页面的 View,无网络时,把容器页面换成无网络的 View 等等。
在 iOS 开发领域中,怎样才算是 MVC 划分的正确姿势?
这个问题其实在上面已经解答掉一部分了,那么这个问题的答案就当是对上面问题的一个总结吧。
M 应该做的事:
- 给 ViewController 提供数据
- 给 ViewController 存储数据提供接口
- 提供经过抽象的业务基本组件,供 Controller 调度
C 应该做的事:
- 管理 View Container 的生命周期
- 负责生成所有的 View 实例,并放入 View Container
- 监听来自 View 与业务有关的事件,通过与 Model 的合作,来完成对应事件的业务。
V 应该做的事:
- 响应与业务无关的事件,并因此引发动画效果,点击反馈(如果合适的话,尽量还是放在 View 去做)等。
- 界面元素表达
我通过与服务端 MVC 划分的对比来回答了这两个问题,之所以这么做,是因为我知道有很多 iOS 工程师之前是从服务端转过来的。我也是这样,在进安居客之前,我也是做服务端开发的,在学习 iOS 的过程中,我也曾经对 iOS 领域的 MVC 划分问题产生过疑惑,我疑惑的点就是前面开篇我猜测的点。如果有人问我 iOS 中应该怎么做 MVC 的划分,我就会像上面这么回答。
MVCS
苹果自身就采用的是这种架构思路,从名字也能看出,也是基于 MVC 衍生出来的一套架构。从概念上来说,它拆分的部分是 Model 部分,拆出来一个 Store。这个 Store 专门负责数据存取。但从实际操作的角度上讲,它拆开的是 Controller。
这算是瘦 Model 的一种方案,瘦 Model 只是专门用于表达数据,然后存储、数据处理都交给外面的来做。MVCS 使用的前提是,它假设了你是瘦 Model,同时数据的存储和处理都在 Controller 去做。所以对应到 MVCS,它在一开始就是拆分的 Controller。因为 Controller 做了数据存储的事情,就会变得非常庞大,那么就把 Controller 专门负责存取数据的那部分抽离出来,交给另一个对象去做,这个对象就是 Store。这么调整之后,整个结构也就变成了真正意义上的 MVCS。
关于胖 Model 和瘦 Model
我在面试和跟别人聊天时,发现知道胖 Model 和瘦 Model 的概念的人不是很多。大约两三年前国外业界曾经对此有过非常激烈的讨论,主题就是 Fat model, skinny controller。现在关于这方面的讨论已经不多了,然而直到今天胖 Model 和瘦 Model 哪个更好,业界也还没有定论,所以这算是目前业界悬而未解的一个争议。我很少看到国内有讨论这个的资料,所以在这里我打算补充一下什么叫胖 Model 什么叫瘦 Model。以及他们的争论来源于何处。
什么叫胖 Model?
胖 Model 包含了部分弱业务逻辑。胖 Model 要达到的目的是,Controller 从胖 Model 这里拿到数据之后,不用额外做操作或者只要做非常少的操作,就能够将数据直接应用在 View 上。举个例子:
Raw Data: timestamp:1234567 FatModel: @property (nonatomic, assign) CGFloat timestamp; - (NSString *)ymdDateString; // 2015-04-20 15:16 - (NSString *)gapString; // 3 分钟前、1 小时前、一天前、2015-3-13 12:34 Controller: self.dateLabel.text = [FatModel ymdDateString]; self.gapLabel.text = [FatModel gapString];
把 timestamp 转换成具体业务上所需要的字符串,这属于业务代码,算是弱业务。FatModel 做了这些弱业务之后,Controller 就能变得非常 skinny,Controller 只需要关注强业务代码就行了。众所周知,强业务变动的可能性要比弱业务大得多,弱业务相对稳定,所以弱业务塞进 Model 里面是没问题的。另一方面,弱业务重复出现的频率要大于强业务,对复用性的要求更高,如果这部分业务写在 Controller,类似的代码会洒得到处都是,一旦弱业务有修改(弱业务修改频率低不代表就没有修改),这个事情就是一个灾难。如果塞到 Model 里面去,改一处很多地方就能跟着改,就能避免这场灾难。
然而其缺点就在于,胖 Model 相对比较难移植,虽然只是包含弱业务,但好歹也是业务,迁移的时候很容易拔出萝卜带出泥。另外一点,MVC 的架构思想更加倾向于 Model 是一个 Layer,而不是一个 Object,不应该把一个 Layer 应该做的事情交给一个 Object 去做。最后一点,软件是会成长的,FatModel 很有可能随着软件的成长越来越 Fat,最终难以维护。
什么叫瘦 Model?
瘦 Model 只负责业务数据的表达,所有业务无论强弱一律扔到 Controller。瘦 Model 要达到的目的是,尽一切可能去编写细粒度 Model,然后配套各种 helper 类或方法来对弱业务做抽象,强业务依旧交给 Controller。举个例子:
Raw Data: { "name":"casa", "sex":"male", } SlimModel: @property (nonatomic, strong) NSString *name; @property (nonatomic, strong) NSString *sex; Helper: #define Male 1; #define Female 0; + (BOOL)sexWithString:(NSString *)sex; Controller: if ([Helper sexWithString:SlimModel.sex] == Male) { ... }
由于 SlimModel 跟业务完全无关,它的数据可以交给任何一个能处理它数据的 Helper 或其他的对象,来完成业务。在代码迁移的时候独立性很强,很少会出现拔出萝卜带出泥的情况。另外,由于 SlimModel 只是数据表达,对它进行维护基本上是 0 成本,软件膨胀得再厉害,SlimModel 也不会大到哪儿去。
缺点就在于,Helper 这种做法也不见得很好,这里有一篇文章批判了这个事情。另外,由于Model 的操作会出现在各种地方,SlimModel 在一定程度上违背了DRY(Don’t Repeat Yourself)的思路,Controller 仍然不可避免在一定程度上出现代码膨胀。
我的态度?嗯,我会在本门心法这一节里面说。
说回来,MVCS 是基于瘦Model 的一种架构思路,把原本Model 要做的很多事情中的其中一部分关于数据存储的代码抽象成了Store,在一定程度上降低了Controller 的压力。
MVVM
MVVM 去年在业界讨论得非常多,无论国内还是国外都讨论得非常热烈,尤其是在 ReactiveCocoa 这个库成熟之后,ViewModel 和 View 的信号机制在 iOS 下终于有了一个相对优雅的实现。MVVM 本质上也是从 MVC 中派生出来的思想,MVVM 着重想要解决的问题是尽可能地减少 Controller 的任务。不管 MVVM 也好,MVCS 也好,他们的共识都是 Controller 会随着软件的成长,变很大很难维护很难测试。只不过两种架构思路的前提不同,MVCS 是认为 Controller 做了一部分 Model 的事情,要把它拆出来变成 Store,MVVM 是认为 Controller 做了太多数据加工的事情,所以 MVVM 把数据加工的任务从 Controller 中解放了出来,使得 Controller 只需要专注于数据调配的工作,ViewModel 则去负责数据加工并通过通知机制让 View 响应 ViewModel 的改变。
MVVM 是基于胖 Model 的架构思路建立的,然后在胖 Model 中拆出两部分:Model 和 ViewModel。关于这个观点我要做一个额外解释:胖 Model 做的事情是先为 Controller 减负,然后由于 Model 变胖,再在此基础上拆出 ViewModel,跟业界普遍认知的 MVVM 本质上是为 Controller 减负这个说法并不矛盾,因为胖 Model 做的事情也是为 Controller 减负。
另外,我前面说 MVVM 把数据加工的任务从 Controller 中解放出来,跟 MVVM 拆分的是胖 Model 也不矛盾。要做到解放 Controller,首先你得有个胖 Model,然后再把这个胖 Model 拆成 Model 和 ViewModel。
那么 MVVM 究竟应该如何实现?
这很有可能是大多数人纠结的问题,我打算凭我的个人经验试图在这里回答这个问题,欢迎交流。
在 iOS 领域大部分 MVVM 架构都会使用 ReactiveCocoa,但是使用 ReactiveCocoa 的 iOS 应用就是基于 MVVM 架构的吗?那当然不是,我觉得很多人都存在这个误区,我面试过的一些人提到了 ReactiveCocoa 也提到了 MVVM,但他们对此的理解肤浅得让我忍俊不禁。嗯,在网络层架构我会举出不使用 ReactiveCocoa 的例子,现在举我感觉有点儿早。
MVVM 的关键是要有 View Model!而不是 ReactiveCocoa
注:MVVM 要有 ViewModel,以及 ReactiveCocoa 带来的信号通知效果,在 ReactiveCocoa 里就是 RAC 等相关宏来实现。另外,使用 ReactiveCocoa 能够比较优雅地实现 MVVM 模式,就是因为有 RAC 等相关宏的存在。就像它的名字一样 Reactive- 响应式,这也是区分 MVVM 的 VM 和 MVC 的 C 和 MVP 的 P 的一个重要方面。
ViewModel 做什么事情?就是把 RawData 变成直接能被 View 使用的对象的一种 Model。举个例子:
Raw Data: { ( (123, 456), (234, 567), (345, 678) ) }
这里的 RawData 我们假设是经纬度,数字我随便写的不要太在意。然后你有一个模块是地图模块,把经纬度数组全部都转变成 MKAnnotation 或其派生类对于 Controller 来说是弱业务,(记住,胖 Model 就是用来做弱业务的),因此我们用 ViewModel 直接把它转变成 MKAnnotation 的 NSArray,交给 Controller 之后 Controller 直接就可以用了。
嗯,这就是 ViewModel 要做的事情,是不是觉得很简单,看不出优越性?
安居客 Pad 应用也有一个地图模块,在这里我设计了一个对象叫做 reformer(其实就是 ViewModel),专门用来干这个事情。那么这么做的优越性体现在哪儿呢?
安居客分三大业务:租房、二手房、新房。这三个业务对应移动开发团队有三个 API 开发团队,他们各自为政,这就造成了一个结果:三个 API 团队回馈给移动客户端的数据内容虽然一致,但是数据格式是不一致的,也就是相同 value 对应的 key 是不一致的。但展示地图的 ViewController 不可能写三个,所以肯定少不了要有一个 API 数据兼容的逻辑,这个逻辑我就放在 reformer 里面去做了,于是业务流程就变成了这样:
这么一来,原本复杂的 MKAnnotation 组装逻辑就从 Controller 里面拆分了出来,Controller 可以直接拿着 Reformer 返回的数据进行展示。APIManager 就属于 Model,reformer 就属于 ViewModel。具体关于 reformer 的东西我会放在网络层架构来详细解释。Reformer 此时扮演的 ViewModel 角色能够很好地给 Controller 减负,同时,维护成本也大大降低,经过 reformer 产出的永远都是 MKAnnotation,Controller 可以直接拿来使用。
然后另外一点,还有一个业务需求是取附近的房源,地图 API 请求是能够 hold 住这个需求的,那么其他地方都不用变,在 fetchDataWithReformer 的时候换一个 reformer 就可以了,其他的事情都交给 reformer。
那么 ReactiveCocoa 应该扮演什么角色?
不用 ReactiveCocoa 也能 MVVM,用 ReactiveCocoa 能更好地体现 MVVM 的精髓。前面我举到的例子只是数据从 API 到 View 的方向,View 的操作也会产生"数据",只不过这里的"数据"更多的是体现在表达用户的操作上,比如输入了什么内容,那么数据就是 text、选择了哪个 cell,那么数据就是 indexPath。那么在数据从 view 走向 API 或者 Controller 的方向上,就是 ReactiveCocoa 发挥的地方。
我们知道,ViewModel 本质上算是 Model 层(因为是胖 Model 里面分出来的一部分),所以 View 并不适合直接持有 ViewModel,那么 View 一旦产生数据了怎么办?扔信号扔给 ViewModel,用谁扔?ReactiveCocoa。
在 MVVM 中使用 ReactiveCocoa 的第一个目的就是如上所说,View 并不适合直接持有 ViewModel。第二个目的就在于,ViewModel 有可能并不是只服务于特定的一个 View,使用更加松散的绑定关系能够降低 ViewModel 和 View 之间的耦合度。
那么在 MVVM 中,Controller 扮演什么角色?
大部分国内外资料阐述 MVVM 的时候都是这样排布的:View <-> ViewModel <-> Model,造成了 MVVM 不需要 Controller 的错觉,现在似乎发展成业界开始出现 MVVM 是不需要 Controller 的。的声音了。其实 MVVM 是一定需要 Controller 的参与的,虽然 MVVM 在一定程度上弱化了 Controller 的存在感,并且给 Controller 做了减负瘦身(这也是 MVVM 的主要目的)。但是,这并不代表 MVVM 中不需要 Controller,MMVC 和 MVVM 他们之间的关系应该是这样:
(来源: http://www.sprynthesis.com/2014/12/06/reactivecocoa-mvvm-introduction/ )
View <-> C <-> ViewModel <-> Model,所以使用 MVVM 之后,就不需要 Controller 的说法是不正确的。严格来说 MVVM 其实是 MVCVM。从图中可以得知,Controller 夹在 View 和 ViewModel 之间做的其中一个主要事情就是将 View 和 ViewModel 进行绑定。在逻辑上,Controller 知道应当展示哪个 View,Controller 也知道应当使用哪个 ViewModel,然而 View 和 ViewModel 它们之间是互相不知道的,所以 Controller 就负责控制他们的绑定关系,所以叫 Controller/ 控制器就是这个原因。
前面扯了那么多,其实归根结底就是一句话:在 MVC 的基础上,把 C 拆出一个 ViewModel 专门负责数据处理的事情,就是 MVVM。然后,为了让 View 和 ViewModel 之间能够有比较松散的绑定关系,于是我们使用 ReactiveCocoa,因为苹果本身并没有提供一个比较适合这种情况的绑定方法。iOS 领域里 KVO,Notification,block,delegate 和 target-action 都可以用来做数据通信,从而来实现绑定,但都不如 ReactiveCocoa 提供的 RACSignal 来的优雅,如果不用 ReactiveCocoa,绑定关系可能就做不到那么松散那么好,但并不影响它还是 MVVM。
在实际 iOS 应用架构中,MVVM 应该出现在了大部分创业公司或者老牌公司新 App 的 iOS 应用架构图中,据我所知易宝支付旗下的某个 iOS 应用就整体采用了 MVVM 架构,他们抽出了一个 Action 层来装各种 ViewModel,也是属于相对合理的结构。
所以 Controller 在 MVVM 中,一方面负责 View 和 ViewModel 之间的绑定,另一方面也负责常规的 UI 逻辑处理。
VIPER
VIPER(View,Interactor,Presenter,Entity,Routing)。VIPER 我并没有实际使用过,我是在 objc.io 上第 13 期看到的。
但凡出现一个新架构或者我之前并不熟悉的新架构,有一点我能够非常肯定,这货一定又是把 MVC 的哪个部分给拆开了(坏笑,做这种判断的理论依据在第一篇文章里面我已经讲过了)。事实情况是 VIPER 确实拆了很多很多,除了 View 没拆,其它的都拆了。
我提到的这两篇文章关于 VIPER 都讲得很详细,一看就懂。但具体在使用 VIPER 的时候会有什么坑或者会有哪些争议我不是很清楚,硬要写这一节的话我只能靠 YY,所以我想想还是算了。如果各位读者有谁在实际 App 中采用 VIPER 架构的或者对 VIPER 很有兴趣的,可以评论区里面提出来,我们交流一下。
编后语
为了更好地向读者输出更优质的内容,InfoQ 将精选来自国内外的优秀文章,经过整理审校后,发布到网站。本篇文章作者为田伟宇,原文链接为 Casa Taloyum 。本文已由原作者授权 InfoQ 中文站转载。
感谢徐川对本文的审校。
给InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家通过新浪微博( @InfoQ , @丁晓昀),微信(微信号: InfoQChina )关注我们,并与我们的编辑和其他读者朋友交流(欢迎加入 InfoQ 读者交流群)。
评论