本文从移动端构架设计、类设计、方法设计以及最佳实践等方面简单讨论了如何开发出高质量的代码。git
本文同时发表于个人我的博客github
高质量代码、架构设计以及重构等都是充满智慧且须要深厚功底和实战经验的话题,本不敢随意拿来讨论。只是最近在项目中对两个较大的模块作了一次重构,再加上补习了一下《代码大全》以及《重构》,所以尝试着作一次这方面的分享。代码设计自己也是一个仁者见仁、智者见智的话题,下述讨论若有不正确之处,请指正。数据库
首先须要回答的问题是什么样的代码是高质量代码,其次才是如何设计出高质量代码。编程
从宏观上说,高质量代码无外乎于:可扩展性强、可维护性高、可读性好。 从实现的层面说,主要包含:划分层次合理、数据流向简洁明了、模块间通讯合法、类有良好的封装和一致的抽象、实体内部高内聚、实体间低耦合等。json
对于终端开发来讲,架构设计最经典莫过于 MVC,在此基础上又衍生出了像 MVVM 之类的架构。 以前看过一篇文章讨论是先分层再分模块,仍是先分模块再分层的问题。虽然,这两种方式各有道理,但我的仍是建议先划分模块,再在模块内部按 MVC 或其余架构分层,由于对于终端来讲模块有更强的独立性,一个模块基本上也是由M、V、C 三部分构成。数组
关于 MVC、MVVM 等构架的文章已有不少,本文再也不赘述。安全
以 MVC 为例,须要强调的是 M、V、C三者间的通讯规则: 微信
项目中,每每一个模块由一我的单独负责,这就致使不一样的模块可能采用不一样的架构,MVC、MVP、MVVM 以及其余变体构架可能在一个项目中同时存在,显得有些混乱。最好是能统一一下,一个项目只用一种构架。多线程
在面向对象编程中,类的好坏直接影响代码的质量,那么什么样的类是设计良好的类? 一样,从宏观上说设计良好的类具备:良好的抽象与封装。架构
抽象与封装是两个关系很是紧密的概念。
代码大全对抽象的定义:抽象是一种能让你在关注某一律念的同时能够放心地忽略其中一些细节的能力。
具体到类上,抽象主要指接口的抽象,即对外宣称该类具备什么样的能力、能完成哪些工做。而类的具体实现对外界是个黑盒子,无需关心。 封装更强调的是隐藏细节,迫使外界没法了解类内部的细节信息。
经过抽象接口能够简化外界对类的使用,使其无需关心类内部复杂的实现细节。 在设计接口时,须要注意的问题:
接口须要展示一致的抽象层次
接口应隐藏细节 接口应该隐藏业务层无需关心的细节问题。在咱们项目中,支持游客登陆和 QQ 登陆,而登陆模块将这两种登陆方式区分开来以不一样的 notification 通知业务层相关的登陆信息。然而绝大多数业务逻辑并不关心具体的登陆方式,若在此基础上添加微信等其余登陆方式,接口将更加难以维护。 所以,在登陆模块重构后将这些细节信息都隐藏在登陆模块内部,对外提供统一的回调接口。
高内聚 不管是在类层次仍是方法层次,高内聚一直是咱们追求的目标之一。对类来讲,抽象与内聚关系十分紧密,类具备良好抽象的接口通常也意味着有很高的内聚性。
在咱们 QQ 阅读项目中,有一个很是重要的类:QRBookInfo
,从名字看应该是一个用于表示书籍信息的类。其对外的接口应该有:id(惟一编号)、name(名称)、author(做者)以及 format(格式)等。但在现实中 QRBookInfo
类却包含了不少不属于它的信息,如:阅读进度、阅读时间、添加到书架时间以及在书架上分组 id 等,最终该类暴露给外界的属性有将近40个,沦落为一个大杂烩,很明显已经不具有高内聚特征了。
若是要对该类进行重构,则能够经过 Extract Class(提炼类)的手法进行,将不属于该类的职责提炼到新的类中。从 QRBookInfo
至少能够提炼出3个类:
QRBook
——用于描述书籍自己,抽象接口有:id(惟一编号)、name(名称)、author(做者)以及 format(格式)等;QRBookShelfItem
——用于描述书架上每一个项的信息,抽象接口有:id(书籍编号)、addTime(添加到书架时间)以及 categoryId(该书在书架上的分组编号)等;QRReadingItem
——用于描述阅读信息,抽象接口有:id(书籍编号)、readTime(阅读时间)以及 readProgress(阅读进度)等。项目中,还有个书架类:BookShelfViewController
,有5000多行代码,注册了20多个通知,已经到了几乎没法维护的地步,其中不只包含了书架相关的逻辑,打开书的逻辑也所有扔在里面。后来在一个新项目中须要使用这个类时,稍微作一点小改动都没法正常工做。这次对书架进行重构时将打开书的逻辑所有移到其余类中,QRBookShelfViewController
这个类只专一于处理书架相关的逻辑。
高内聚做为管理类复杂度的一个重要原则,咱们应该时刻把握这一利器。
尽可能让接口可编程,拒绝隐藏的语义
来自代码大全:每一个接口都由一个可编程(programmatic)部分和一个语义(semantic)部分组成。其中,可编程部分由接口中的数据类型和其余属性构成,编译器能强制性地要求它们(在编译时检查错误),而语义部分则由『本接口将会被怎样使用』的假定组成,而这些是没法经过编译器来强制实施的。
好比:在调用 methodA 前必须先调用方法 methodB,这一要求则属于 methodA 的语义部分。因为没有编译器的强制检查,这一隐藏语义极可能被调用者忽略,引发错误。
所以,接口设计时尽可能不要包含语义部分,能够经过抽取新接口或添加 Asserts 等方法将语义接口转换为编程接口,确实没法避免也应在接口中经过注释说明其中的语义。如在上述例子中,能够添加新的接口methodC,在该接口中调用 methodB、methodA,从而消除 methodA 的语义。
谨防在修改时破坏接口的抽象 从前面讨论可知,一个类应该围绕一个中心职责,处理一个任务。在类设计之初可能有较高的内聚性,但在实际开发中,类会被不断扩展,不断加入新的功能和数据。类的内聚性和抽象性极可能在这个过程当中被破坏。 常常在代码中能够看到一个类有大相径庭风格、不一样抽象的接口,这每每是被"篡改"的结果。另外还会看到有2个、3个、4个甚至更多个相同功能,只是参数不同的接口。有的接口多出个 bool 型的参数,有的带个 block 类型的参数,有的带个 delegate 形式的参数,在这种粗暴式的背后每每隐藏着重复代码的危机。 在咱们的登陆模块中,登陆接口有10个之多,登陆回调也是五花八门,有 block、delegate 以及 notification。所以在业务层的类中常常看到这3种回调方式同时存在,维护成本极高。我相信,在设计之初绝非如此混乱,而是在往后开发过程当中慢慢引入的。 所以在修改已有类接口时必定要三思,切不可图一时之便随意添加修改,不然长此以往极可能致使类失控。若因业务须要必须修改,最好将需求提给类的做者,由他来修改。
设计接口时尽可能不要给调用者留坑 咱们有一个基类 controller:QRBaseViewController
,其定义了一个接口,做用是让子类自定义 NavigationBar 上的item:
self
调用其余子方法或属性,这样就出现了cycle retain。通过一番排查发现有六、7个类存在这样的问题。 (ps: 上面的 actionBlock
属性应该使用 copy
而不是 strong
) 抽象更可能是强调这个类是什么,能作什么,而封装则是强制外界没法了解实现的细节。 良好的封装通常须要注意:
尽量地限制类中各成员的可访问性 使可访问性尽量低是促成封装的原则之一,在 Objective-C 中则是尽量将类的数据成员、属性以及方法放到类的匿名分类中,使类的接口(.h文件)文件尽可能简洁。
不要暴露类的数据成员 在 Objective-C 中,属性做为特殊的数据成员,能够暴露给外界,但在这么作以前请认真思考是否真的须要这么作。 可是,做为容器类的数据成员(Array、Dictionary、Set 等)必定不要轻意暴露给外界,由于这属于很底层的实现细节。一旦暴露了这些细节,封装将被严重破坏。
试想一下,如有一天须要将 Array 改成 Dictionary,影响范围有多大? 若是暴露的是容器的mutable版本,那么外界能够任意对该容器进行操做,你已失去各类控制、错误检查的能力,同时还可能有多线程问题。
私有实现细节必定不要暴露在头文件中 头文件应该是简洁明了的,仅用于向外界表达该类能作什么。简洁的头文件也能减小类的使用者在使用该类时的成本。
要格外警戒从语义上破坏封装性 语法上的封装能够经过 private、匿名 category 等方式实现,然而语义上的封装性更难以控制。如下是代码大全中列举的从语义上破坏封装性的例子:
上述例子的问题在于,其调用方不是依赖类的抽象接口,而是依赖于类的内部实现。当经过查看类的内部实现得知能够如何使用该类时,就不是针对接口编程,而是透过接口针对内部实现编程。这是一种十分危险的举动,类的封装性已被破坏,一旦类内部实现改变了,可能引发严重错误。
低耦合 两个类之间的关联程度称为耦合,低耦合一直是咱们的追求。类的封装性直接影响到耦合程度,若类过多的将细节信息暴露出来,无疑会增长类与使用方间的耦合度。 理想状况下,类对于调用者来讲是个黑盒子,调用者经过类的接口就能完成对该类的使用,而无需深刻类内部了解实现细节,固然这首先须要该类有很好的抽象与封装。反之,若在使用一个类时须要了解其内部实现,则必然在二者之间造成很高的耦合。 抽象性与耦合间的关系也十分紧密,良好的抽象与封装通常也会有较低的耦合度。
项目中,有个用于表示书架分组的类:QRCategoryViewController
,该类不只用于处理分组相关的业务逻辑,连用户分组数据的读取、存储也全在这个类中。而书架在决定显示哪些书时也须要知道当前分组信息,所以,在书架初始化时必须也初始化一个QRCategoryViewController
实例,用于获取当前分组信息。这里,正是因为QRCategoryViewController
类在抽象与封装上没有处理好,致使本来几乎没有耦合关系的两个类QRCategoryViewController
与BookShelfViewController
间高度耦合。在重构时,将分组数据的管理移到一个新类QRCategoryManager
中,使QRCategoryViewController
与BookShelfViewController
间完全解耦。
做为面向对象三大特性之一的继承,重要性不言而喻,用好了能简化程序,反之会增长程序复杂性。 在决定使用继承以前,须要认真思考基类与派生类之间是不是"is...a"的关系,如若不是,那继承就不是正确的选择,此时可考虑"has...a"(包含)关系是否更合适。 代码大会中关于继承的几个经典描述:
项目中的书架类BookShelfViewController
因为要同时适配 iPhone 和 iPad 两个版本,所以在代码中随处可见:if(IS_IPHOEN)...else
这样的语句。 在重构的时候,将 iPhone 与 iPad 的UI逻辑抽取到两个子类,而它们共有的数据相关逻辑(如:云书架、章节更新等)放在基类。
QRAuthenticatorDelegate
:
QRQQAuthenticator
、
QRWeChatAuthenticator
以及
QRGuestAuthenticator
实现了
QRAuthenticatorDelegate
。
因为 Objective-C 语言的动态性,其成员函数天生就具备虚函数的特征,当继承体系过于复杂,函数重载将进一步加大问题的复杂性。 继承在使用前必定要三思,或许包含、接口是更好的选择,使用不当会增长程序复杂性。
船体外壳装有隔离舱、建筑物都有防火墙,其做用都是隔离危险。 在防护式编程中一样须要隔离危险,在系统层面能够有专门的类用于处理错误、隔离危险(来自代码大全):
在程序中错误又可分为2类:
对于上述2种错误应该分别使用断言(Assertions)和错误处理。回到前面那个问题,在类的公开接口中能够视状况使用错误处理或断言,而在类的私有方法中能够直接使用断言。 (ps: 断言主要用于在开发期间快速检测代码错误)
『代码首先是写给人看的,其次才是让机器执行的』,在方法设计时更应考虑这一点。
命名是个技术活,对代码维护性、可读性相当重要! 须要注意的是,方法名应该强调方法是作什么的,而不是怎么作的。 除此以外,方法名在内存管理上也有约束: 任何如下列名称为前缀的方法,若其返回值为 object,则方法调用者持有该 object:alloc、new、copy以及mutableCopy。 还有一个更为严格的规则:任何以 init 为前缀的方法必须遵照下列规则:
id
或其所属class、superclass、subclass 的对象;尤为是在 ARC 与 MRC 混用的项目中,必须遵照上述规则,详情可参看我以前的文章:Inside Memory Management for iOS。
一个方法只作一件事!并经过方法名很优雅的表达出来! 但在实际中不少方法每每作了更多,有的方法长达100多行,甚至几百上千行。 低内聚的方法带来的后果有:
将 if...else...
、for
、switch
等语句的 body 抽取为子方法,并经过好的方法名提升可读性。
这两段代码作了一样的事情,都是根据用户选择加载 grid 或 line 模式的书架,但其可读性差别仍是很大的。
常常会看到这样的代码:
另外,关于 if 语句的格式规范问题,虽各有各的习惯,但强烈不建议作成下面这样(事实上大量 if 被写成这样,由于 Xcode默认代码补全就是这样的):
若是条件表达式过于复杂为了提升可读性,若该表达式重复出现应该抽取为布尔方法,不然能够将其赋值给一个良好命名的变量。 咱们书架有个规则,在 iPhone 上对于自定义的分组,若该分组下有书籍,则须要在书籍列表的最后面添加一个导入书的按钮。
前面讲到复杂的布尔表达式能够转换为命名良好的中间变量,但并不意味着能够随意添加。多一个中间变量就多一份复杂,多一种状态,对于使用次数少的中间变量能够直接将表达式内联到语句中。
indexPath.row
含义清楚明确,不必再定义中间变量
row
。
对于类来讲也是如此,为了解决某些问题每每会引入一些状态成员变量,这种作法无可厚非,但仍是要谨慎。每每状态变量涉及何时 set、何时 reset,增长了类内部的复杂性,也增长了类内部方法间的耦合度。
若方法须要检查参数或其余条件,把检查操做放在方法开始部分,条件不知足时当即返回。
尤为是利用 switch 处理 enum 时,轻意不要写 default 分支,这样在漏掉哪一个枚举值没有处理时,编译器会发出警告。但在有 default 分支时,是不会有警告的。
该小节主要讨论在开发过程一些值得注意的小点,不必定与设计有关。
LLVM 7.0从编译器层面支持泛型,系统中经常使用的容器都增长了对泛型的支持,无疑是一个重大利好。
同泛型,nullability 也是编译器 LLVM 支持的一个特性,用于描述接口中的值是否能够为 nil。 对于 nonnull 的接口,若传入的是 nil,编译器会发出 warning。一样,nullability对接口的自我说明能力比 warning 更有意义。
Objective-C 语言的动态性给咱们带来了无限的想象空间,『只有想不到,没有作不到』。 典型的利用到 Objective-C 动态性的例子有:JSPatch、MJExtension、Swizzling、KVO 以及 AOP等等。
viewWillAppear:
方法以及 UIButton 的 sendAction:
方法,记录用户的操做路径,在 crash 时随 crash log 一块儿上报,经过该方法解决了很多疑难杂症的 crash;GCD做为实现多线程的方式之一,结合 block 使用时简单方便。所以,在代码中常常存在大量dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{
这样的用法。 虽然系统会控制 GCD 使用的线程数,但当线程被锁住时,仍是会建立新的线程以供其余 block 使用,所以某一时段系统可能会建立出大量线程,最终会抢占主线程的资源,影响到主线程的执行。 所以,必要时能够统一管理这些队列,YYDispatchQueuePool 就是一个很不错的实现。
虽然一直追求高内聚的类,但 UI 交互的类尤为是 UIViewController 通常功能都比较复杂,此时最好按功能排列方法,同一功能的方法放在一块儿。 如UIViewController的这些方法应该固定排在最前面:
#pragma mark -
那就更好了。还能够经过 category 将不一样的功能分到不一样的 category 中。 另外,也不要将多个类放到同一个文件中。
经过 category 能够扩展示有类的功能,在使用 category 时有几个点须要注意:
dealloc
方法,则主类中的 dealloc
方法将被覆盖。尤为是在没有源码的三方库中,更加不能这样作。首先须要代表的是,可以使用宏而不是硬编码,是一件值得鼓励的事情。 但宏的缺点也经常遭到诟病,主要有:非强类型、只是预编译期的文本替换,由于宏定义没有加括号而引起的错误家常便饭。 绝大多数状况下,宏均可以用 const 或者子方法代替。看个有意思的问题,下面这个宏定义有问题吗:
warning 表明程序存在病态,虽然有些 warning 看上去可有可无,但 warning 一旦多起来,一些重要的 warning 可能也隐藏其中,难以发现。
对于那些真的以为可有可无的 warning,能够经过#pragma clang diagnostic ignored
将其隐藏(固然并不推荐这么作)。
在现在的移动互联网时代,不少项目都是被需求推着往前走,都在追求敏捷开发、快速迭代、小步快跑,对代码质量有所忽视。 俗话说:『磨刀不误砍柴工』,在动手以前多一点思考,在开发过程当中少一点任性,就能写出质量更高的代码。 总之,高质量说来容易,作到难。不只要有丰富的实战经验,扎实的功底,更要有一颗执著的心!