UIViewController是iOS应用的基础单位,每个iOS程序员都写过无数的Controller,今天和大家一起来深度解剖Controller,看看怎么来做一次深度重构。
重构的前提
我们应该谨慎的来重构我们的代码。iOS系统提供的UIViewController一定程度上可以很好的应付简单的页面单位,对于复杂的页面,我们也可以采用市上主流的MV(X)系列模式,比如MVP,MVVM等,但随着单个Controller内业务进一步增长,我们需要更细粒度的重构,或者是对MV(X)做进一步的定制。
以下图映客APP两个页面为主:
左边页面元素少且静态,一个UITableView就可以应付,右边的直播页面则元素多且动态,传统的MV(X)也会显得颗粒太粗,这类复杂页面虽然不常遇到,但往往体现一个APP的核心功能,合理的搭建或者重构这类界面非常重要。
重构的本质
如何去定义重构,以我的理解可以归纳为两个关键词:分解,链接。
重构的前提是复杂,臃肿,不直观,重构的手段是分解之后再连接。以映客的直播界面为例,UI元素,用户事件,服务器交互等基础元素都非常之多。以一个简单的MVP去归类代码犹嫌不足,我们需要进一步的分解成view1,view2...viewN,presenter1,presenter2...presenterN,model1,model2...modelN,第二个问题是如何把这一个个的类文件或者说功能单位合理的组织连接起来。完成上述两步我们就完成了一次重构,每一次将代码打乱再连接就是一次重构。
分解UIViewController
写了那么多Controller,让你来说一下Controller都细分为那些更小的功能单位,你能随口说出来吗?只有做了足够多的业务,才能慢慢对Controller的构成有自己的理解。
当然可以回答书MVP或者MVC,但这个答案粒度太粗,一个Controller内部会发生哪些事会说的更细,我们看下VIPER的答案:
- <strong>View:</strong> displays what it is told to by the Presenter and relays user input back to the Presenter.
- <strong>Interactor:</strong> contains the business logic as specified by a use case.
- <strong>Presenter:</strong>contains view logic for preparing content for display (as received from the Interactor) and for reacting to user inputs (by requesting new data from the Interactor).
- <strong>Entity:</strong>contains basic model objects used by the Interactor.
- <strong>Routing:</strong>contains navigation logic for describing which screens are shown in which order
view不用多说可以分解成更多的子View,最后形成一个树形结构。
Entity自然是代表的Model。
MVC中的C,MVP中的P,被细分成Interactor,Presenter,和Routing。这三个角色各自负责什么指责呢?
Routing比较清楚,处理页面之间的跳转,我见过的项目代码里,很少将这一部分单独拎出来,但其实很有意义,这部分代表的是不同Controller之间耦合依赖的方式,无论是从类关系描述的角度还是Debug的角度,都能帮助我们快速定位代码。
Interactor和Presenter初看起来很类似,似乎都是在处理业务逻辑。但业务逻辑其实是个大的归类,可以描述任何一种场景和行为。Interactor当中有个很重要的术语:use case,这个术语很多技术文章中都有遇见,它代表的是一个完整的,独立的,细分过后的业务流程,比如我们APP当中的登陆模块,它是一个业务单元,但它其实可以进一步的细分为很多use case:
use case1:验证邮箱长度
use case2:密码长度检验
use case3:从Server查询use name是否可用
...
use caseN
定义use case有什么好处呢?
好处当然是分门别类,结构清晰。把100本书堆一堆,或者放书架上按类别摆放,下次找书的时候那种方式你更舒服?独立出一个个的use case还有一个好处是方便unit test,如果项目对每一个use case都写有unit test,每次遇到“牵一发动全身”的业务更改,可以边喝茶边写代码。
我见过不少代码都体现不出use case 的分类,可以回头看下自己当前项目的登陆模块,上面我提到的这些use case有没有在类文件中合理摆放,还是都搅在一起?
所以VIPER当中Interactor的说法是强化大家写单独的use case的意识,打开interactor.m,看一个函数代表一个use case,同一类的use case再用#pragma mark归在一块,别人看你代码时能不赏心悦目吗?
再说到Presenter,Presenter时上面一个个use case的使用者和响应者。使用者将个个use case串联起来描述一个完整详细的业务流程,比如我们的登陆模块,每次用户点击按钮登陆的时候,会触发一系列的use case,从验证用户输入合法性,设备网络状态,服务器资源是否可用,到最后处理结果并展示,这就是一个完整的业务流程,这个流程由Presenter来描述。响应者表示Presenter在接受到服务器反馈之后进一步改变本地的状态,比如View的展示,新的数据修改等,甚至会调用Routing发生界面跳转。
说到这里就比较明了了,Interactor和Routing是服务的提供方,Presenter是服务的使用方和集成方。VIPER说白了不过是对传统的MVC当中的C做了进一步细分。
能不能分的更细呢?
当然可以,VIPER的做法是一种通用的做法,我们还可以从业务的角度去细分,那映客的直播页面做例子,比如Presenter当中包含了很多业务流程:
收到用户消息并展示
收到礼品消息并展示
收到弹幕消息并展示
收到用户进出房间的时间,并处理展示
收到XXX,处理并展示
以OC语言的特性,我们可以生成更多的Presenter Category,来安置这些流程,比如LivePresenter+Message, LivePresenter+Gift, LivePresenter+Danmu, LivePresenter+Room, LivePresenter+XXX。
不要觉的上面几个业务流程很简单,一个Presenter处理绰绰有余,我前段时间刚好看过别人的一个直播项目,一个Persenter类超过1000行代码很轻松。
还可以进一步细分,一个功能复杂繁多的页面基本上离不开UITableView,而tableView代码量基本在delegate和datasource。这两个职责当然可以放在presenter当中,或者我们向Android学习,把它们独立出来放在单独的类文件中来处理,比如叫做Adapter 用代码来说就是:
<pre>
<code>
_tableView.delegate = self.adapter;
_tableView.dataSource = self.adapter;
</code>
</pre>
和tableView相关的代码都搬到adapter当中:
<pre><code>
@protocol UITableViewDelegate
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath;
- (CGFloat)tableView:(UITableView *)tableView heightForHeaderInSection:(NSInteger)section;
- (CGFloat)tableView:(UITableView *)tableView heightForFooterInSection:(NSInteger)section;
@end
@protocol UITableViewDataSource<NSObject>
@required
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section;
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath;
@end
</code></pre>
我们的Presenter更加干净了,看起来和刚大扫除过的房间一个干净整洁,令人心情愉悦。
好了,到这里我们盘子里的牛排已经被切成很多小块了,可以开始享用这些美味的代码了,继续我们的第二部工作:链接。
链接
先看一下我们分解之后有哪些元素:
view(1…N), model(1…N), interactor, presenter(1…N), routing, adapter。看着应该粒度够细了,对于复杂的Controller,我个人习惯的做法和VIPER相近,但略有不同,Interactor当中的use case通过分层的架构被我们放到server layer,分层的架构是另一个话题,这里不做细述,其他元素基本一致。
至于怎样链接,手段无非就是OC的几种交互机制:
<strong>Delegate, Target-Action, Block, Notification, KVO</strong>
这几者之间的差异可以参考objc.io的一篇经典文章。选择不同对耦合度,开放便捷性,调试是否方便等都会产生影响,如何应用不同的机制将各个单位串联起来就看架构师自己的积累和理解了,任何一个选择都有其优势和局限性。
如果拿捏不准选哪个好的时候,我个人建议使用delegate,朴素可靠且直观。delegate需要在不同的元素之间传递,代码量会偏多一些,但优点在protocol定义清晰,耦合在哪里一目了然,记得要注意循环引用的问题。
我早些时候其他几种机制都在实际项目中做过尝试,最后综合比较还是倾向于选择delegate,一位iOS大神MrPeak利用runtime机制,做个一个CDD机制来自动串联各个功能单位。请看:CDD的详细介绍,其本质或者说最终目的还是在于链接。
说完了分解和链接,Controller的重构完成了一大半,还剩下一个重要的概念:状态分享。
尽量避免跨类,跨模块跨层共享状态
MrPeak博客里谈到过对于程序状态的维护.状态是否维护的好对于程序的整体稳定性很有影响,对于Controller中状态的维护,我有一个个简单的建议:
传递状态的时候尽可能copy
之前流行的函数式编程其实就很强调无状态性,无状态不是让大家不定义状态变量,而是避免函数之间的状态共享,具体到OC当中,不要在不同的功能单位里使用指向同一块内存拷贝的地址,为什么共享状态是一件危险的事?
一般来说,我们从Model Layer 或者是数据层拿到model的实例,扔给Controller使用的时候应该是一份新的copy,在不同的类单位里共享NSMutableString或者NSMutableArray,NSMutableDictionary很容易让你的代码变得不稳定,而且这类不稳定性很难调试,debug填坑的时候经常按下葫芦漂起瓢。
在Controller内部传递model或者satate的时候,我们应该也尽量使用copy行为,任何satate你一旦暴露出去就不再安全,自己创建,自己修改,自己销毁才是正途。
FaceBook当中的model layer就是由一个单独的开发团队维护的,应用层(Controller层)开发人员获取到的都是一个新的拷贝,要修改某个属性不一定有接口,甚至要向model维护团队提交增加接口的申请,对于state维护的谨慎性可见一斑。
使用脚本生成原型代码
说了这么多,Controller重构的关键点就说完了。最后再提个小Tip,一旦Controller做深度细分之后,团队成员需要对Controller的分法和构成有一定的认识,写出来的代码应该保持一致,我的做法是通过脚本的方式生成Controller各个相关的的类文件,比如我的Controller是如下结构:
通过脚本将文件名和文件内容当中Template全部替换成目标Controller的名字,就省去了很多体力代码的劳动,也达到了代码风格一致的问题。