浅谈高质量移动开发

本文从移动端构架设计、类设计、方法设计以及最佳实践等方面简单讨论了如何开发出高质量的代码。git

本文同时发表于个人我的博客github

Overview


高质量代码、架构设计以及重构等都是充满智慧且须要深厚功底和实战经验的话题,本不敢随意拿来讨论。只是最近在项目中对两个较大的模块作了一次重构,再加上补习了一下《代码大全》以及《重构》,所以尝试着作一次这方面的分享。代码设计自己也是一个仁者见仁、智者见智的话题,下述讨论若有不正确之处,请指正。数据库

首先须要回答的问题是什么样的代码是高质量代码,其次才是如何设计出高质量代码。编程

从宏观上说,高质量代码无外乎于:可扩展性强、可维护性高、可读性好。 从实现的层面说,主要包含:划分层次合理、数据流向简洁明了、模块间通讯合法、类有良好的封装和一致的抽象、实体内部高内聚、实体间低耦合等。json

架构设计


对于终端开发来讲,架构设计最经典莫过于 MVC,在此基础上又衍生出了像 MVVM 之类的架构。 以前看过一篇文章讨论是先分层再分模块,仍是先分模块再分层的问题。虽然,这两种方式各有道理,但我的仍是建议先划分模块,再在模块内部按 MVC 或其余架构分层,由于对于终端来讲模块有更强的独立性,一个模块基本上也是由M、V、C 三部分构成。数组

关于 MVC、MVVM 等构架的文章已有不少,本文再也不赘述。安全

以 MVC 为例,须要强调的是 M、V、C三者间的通讯规则: 微信

如上图所示,Model 与 View 之间严格禁止直接通讯,这也是常常会犯的错误。 禁止 Model 与 View 间的通讯,也就将底层数据与 UI 隔离开,下降了耦合。

项目中,每每一个模块由一我的单独负责,这就致使不一样的模块可能采用不一样的架构,MVC、MVP、MVVM 以及其余变体构架可能在一个项目中同时存在,显得有些混乱。最好是能统一一下,一个项目只用一种构架。多线程

类设计


在面向对象编程中,类的好坏直接影响代码的质量,那么什么样的类是设计良好的类? 一样,从宏观上说设计良好的类具备:良好的抽象与封装。架构

抽象与封装是两个关系很是紧密的概念。

代码大全对抽象的定义:抽象是一种能让你在关注某一律念的同时能够放心地忽略其中一些细节的能力。

具体到类上,抽象主要指接口的抽象,即对外宣称该类具备什么样的能力、能完成哪些工做。而类的具体实现对外界是个黑盒子,无需关心。 封装更强调的是隐藏细节,迫使外界没法了解类内部的细节信息。

良好的抽象

经过抽象接口能够简化外界对类的使用,使其无需关心类内部复杂的实现细节。 在设计接口时,须要注意的问题:

  • 接口须要展示一致的抽象层次

    该类接口最大的问题在于没有隐藏内部使用数组的事实,很明显在抽象接口中不该暴露这一细节。 这样作的坏处在于,若从此修改了底层容器,再也不使用 Array 而改用 Dictionary,那么这组接口就难以理解,渐渐地也没法维护了。 正确的作法应该是:

  • 接口应隐藏细节 接口应该隐藏业务层无需关心的细节问题。在咱们项目中,支持游客登陆和 QQ 登陆,而登陆模块将这两种登陆方式区分开来以不一样的 notification 通知业务层相关的登陆信息。然而绝大多数业务逻辑并不关心具体的登陆方式,若在此基础上添加微信等其余登陆方式,接口将更加难以维护。 所以,在登陆模块重构后将这些细节信息都隐藏在登陆模块内部,对外提供统一的回调接口。

  • 高内聚 不管是在类层次仍是方法层次,高内聚一直是咱们追求的目标之一。对类来讲,抽象与内聚关系十分紧密,类具备良好抽象的接口通常也意味着有很高的内聚性。

    在咱们 QQ 阅读项目中,有一个很是重要的类:QRBookInfo,从名字看应该是一个用于表示书籍信息的类。其对外的接口应该有:id(惟一编号)、name(名称)、author(做者)以及 format(格式)等。但在现实中 QRBookInfo类却包含了不少不属于它的信息,如:阅读进度、阅读时间、添加到书架时间以及在书架上分组 id 等,最终该类暴露给外界的属性有将近40个,沦落为一个大杂烩,很明显已经不具有高内聚特征了。

    若是要对该类进行重构,则能够经过 Extract Class(提炼类)的手法进行,将不属于该类的职责提炼到新的类中。从 QRBookInfo 至少能够提炼出3个类:

    1. QRBook——用于描述书籍自己,抽象接口有:id(惟一编号)、name(名称)、author(做者)以及 format(格式)等;
    2. QRBookShelfItem——用于描述书架上每一个项的信息,抽象接口有:id(书籍编号)、addTime(添加到书架时间)以及 categoryId(该书在书架上的分组编号)等;
    3. 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:

    你能看出来该接口有什么问题吗? 没错,问题就出在其最后一个block参数上,因为类自己须要 hold 这个 block,而在 block 中每每须要经过 self 调用其余子方法或属性,这样就出现了cycle retain。通过一番排查发现有六、7个类存在这样的问题。 (ps: 上面的 actionBlock 属性应该使用 copy 而不是 strong)

良好的封装

抽象更可能是强调这个类是什么,能作什么,而封装则是强制外界没法了解实现的细节。 良好的封装通常须要注意:

  • 尽量地限制类中各成员的可访问性 使可访问性尽量低是促成封装的原则之一,在 Objective-C 中则是尽量将类的数据成员、属性以及方法放到类的匿名分类中,使类的接口(.h文件)文件尽可能简洁。

  • 不要暴露类的数据成员 在 Objective-C 中,属性做为特殊的数据成员,能够暴露给外界,但在这么作以前请认真思考是否真的须要这么作。 可是,做为容器类的数据成员(Array、Dictionary、Set 等)必定不要轻意暴露给外界,由于这属于很底层的实现细节。一旦暴露了这些细节,封装将被严重破坏。

    试想一下,如有一天须要将 Array 改成 Dictionary,影响范围有多大? 若是暴露的是容器的mutable版本,那么外界能够任意对该容器进行操做,你已失去各类控制、错误检查的能力,同时还可能有多线程问题。

  • 私有实现细节必定不要暴露在头文件中 头文件应该是简洁明了的,仅用于向外界表达该类能作什么。简洁的头文件也能减小类的使用者在使用该类时的成本。

  • 要格外警戒从语义上破坏封装性 语法上的封装能够经过 private、匿名 category 等方式实现,然而语义上的封装性更难以控制。如下是代码大全中列举的从语义上破坏封装性的例子:

    1. 不去调用类 A 的 InitialixeOpertaions()子程序,由于你知道 A 类的 PerformFirstOperation()子程序会自动调用它;
    2. 不在调用 employee.Retrive(database)以前去调用 database.Connect()子程序,由于你知道在未创建数据库链接时employee.Retrive(database)会去链接数据库;
    3. 不去调用类 A 的 Terminate()子程序,由于你知道 A 类的 PerformFinalOperation()子程序已经调用了该方法。

    上述例子的问题在于,其调用方不是依赖类的抽象接口,而是依赖于类的内部实现。当经过查看类的内部实现得知能够如何使用该类时,就不是针对接口编程,而是透过接口针对内部实现编程。这是一种十分危险的举动,类的封装性已被破坏,一旦类内部实现改变了,可能引发严重错误。

  • 低耦合 两个类之间的关联程度称为耦合,低耦合一直是咱们的追求。类的封装性直接影响到耦合程度,若类过多的将细节信息暴露出来,无疑会增长类与使用方间的耦合度。 理想状况下,类对于调用者来讲是个黑盒子,调用者经过类的接口就能完成对该类的使用,而无需深刻类内部了解实现细节,固然这首先须要该类有很好的抽象与封装。反之,若在使用一个类时须要了解其内部实现,则必然在二者之间造成很高的耦合。 抽象性与耦合间的关系也十分紧密,良好的抽象与封装通常也会有较低的耦合度。

    项目中,有个用于表示书架分组的类:QRCategoryViewController,该类不只用于处理分组相关的业务逻辑,连用户分组数据的读取、存储也全在这个类中。而书架在决定显示哪些书时也须要知道当前分组信息,所以,在书架初始化时必须也初始化一个QRCategoryViewController实例,用于获取当前分组信息。这里,正是因为QRCategoryViewController类在抽象与封装上没有处理好,致使本来几乎没有耦合关系的两个类QRCategoryViewControllerBookShelfViewController间高度耦合。在重构时,将分组数据的管理移到一个新类QRCategoryManager中,使QRCategoryViewControllerBookShelfViewController间完全解耦。

慎用继承,用好继承

做为面向对象三大特性之一的继承,重要性不言而喻,用好了能简化程序,反之会增长程序复杂性。 在决定使用继承以前,须要认真思考基类与派生类之间是不是"is...a"的关系,如若不是,那继承就不是正确的选择,此时可考虑"has...a"(包含)关系是否更合适。 代码大会中关于继承的几个经典描述:

  • 继承的目的在于经过"定义能为多个派生类提供共有元素的基类"的方式简化代码。
  • 基类即对派生类将会作什么设定了预期,也对派生类能怎么运做提出了限制。
  • 派生类必须能经过基类的接口而被使用,且使用者无须了解二者之间的差别。
  • 若是派生类不许备彻底遵照由基类定义的同一个接口契约,继承就不是正确的选择。 (你作到了吗^-^)

项目中的书架类BookShelfViewController因为要同时适配 iPhone 和 iPad 两个版本,所以在代码中随处可见:if(IS_IPHOEN)...else这样的语句。 在重构的时候,将 iPhone 与 iPad 的UI逻辑抽取到两个子类,而它们共有的数据相关逻辑(如:云书架、章节更新等)放在基类。

在登陆重构时,咱们有 QQ 登陆、微信登陆以及游客登陆,3种登陆方式都有相同的操做:登陆、续期、获取 accessToke 等。从表面上看,彷佛能够生成一个基类,再派生出3个子类分别用于实现上述不一样的登陆方式。但仔细一想,它们之间除有相同语义的接口,并无共享的数据或实现,所以继承并不合适,而 Objective-C 支持的接口在这里再合适不过了。 所以,咱们定义了一个 protocol QRAuthenticatorDelegate
QRQQAuthenticatorQRWeChatAuthenticator 以及 QRGuestAuthenticator实现了 QRAuthenticatorDelegate

因为 Objective-C 语言的动态性,其成员函数天生就具备虚函数的特征,当继承体系过于复杂,函数重载将进一步加大问题的复杂性。 继承在使用前必定要三思,或许包含、接口是更好的选择,使用不当会增长程序复杂性。

隔离错误

船体外壳装有隔离舱、建筑物都有防火墙,其做用都是隔离危险。 在防护式编程中一样须要隔离危险,在系统层面能够有专门的类用于处理错误、隔离危险(来自代码大全):

在此我更想从类的层面讨论这一问题,曾经一直苦恼这样一个问题: 在类的公开接口中检查了传入的参数,而后该参数又传入到私有方法中,那么在私有方法中应不该该再次检查该参数的合法性。 根据上面的隔离理论,应该只要在公开接口中检查参数的合法性便可,在私有方法中能够认为参数是通过筛选的、正确的。

在程序中错误又可分为2类:

  • 毫不应该出现的、意料以外的错误(之因此会出现多是程序存在 bug);
  • 不常常出现的、非正常状况。

对于上述2种错误应该分别使用断言(Assertions)和错误处理。回到前面那个问题,在类的公开接口中能够视状况使用错误处理或断言,而在类的私有方法中能够直接使用断言。 (ps: 断言主要用于在开发期间快速检测代码错误)

方法设计


『代码首先是写给人看的,其次才是让机器执行的』,在方法设计时更应考虑这一点。

起个好名字

命名是个技术活,对代码维护性、可读性相当重要! 须要注意的是,方法名应该强调方法是作什么的,而不是怎么作的。 除此以外,方法名在内存管理上也有约束: 任何如下列名称为前缀的方法,若其返回值为 object,则方法调用者持有该 object:alloc、new、copy以及mutableCopy。 还有一个更为严格的规则:任何以 init 为前缀的方法必须遵照下列规则:

  • 该方法必须是实例方法;
  • 该方法必须返回类型为id或其所属class、superclass、subclass 的对象;
  • 该方法返回的 object 不能是 autorelese,即方法调用者持有返回的 object。

尤为是在 ARC 与 MRC 混用的项目中,必须遵照上述规则,详情可参看我以前的文章:Inside Memory Management for iOS

高内聚

一个方法只作一件事!并经过方法名很优雅的表达出来! 但在实际中不少方法每每作了更多,有的方法长达100多行,甚至几百上千行。 低内聚的方法带来的后果有:

  • 难以维护;
  • 由于作的事太多,没法取一个好的方法名;
  • 每每致使重复代码,试想一下,若 methodA 作了 task1 和 task2,methodB 作了 task1 和 task3,那 task1 相关的代码是否是就重复了。

将复合语句体抽取为子方法

if...else...forswitch 等语句的 body 抽取为子方法,并经过好的方法名提升可读性。

这两段代码作了一样的事情,都是根据用户选择加载 grid 或 line 模式的书架,但其可读性差别仍是很大的。

复合语句体加大括号

常常会看到这样的代码:

还记得,iOS 系统上那个由 gotofail 引发的 SSL/TSL 安全漏洞吗?
这就是引发问题的源码,虽说这个 bug 不能全赖 if 没有加"{}",但若是 if 加上了大括号该问题可能就避免了。 所以,不管 if 等复合语句的 body 是否只有一条语句都应加上"{}"。

另外,关于 if 语句的格式规范问题,虽各有各的习惯,但强烈不建议作成下面这样(事实上大量 if 被写成这样,由于 Xcode默认代码补全就是这样的):

当 if...else...较复杂时,根本分不清 else 与哪一个 if 配对。
我的更推荐这种写法,不只井井有条,也节省空间。

将复杂的条件表达式抽取为子方法或命名良好的赋值语句

若是条件表达式过于复杂为了提升可读性,若该表达式重复出现应该抽取为布尔方法,不然能够将其赋值给一个良好命名的变量。 咱们书架有个规则,在 iPhone 上对于自定义的分组,若该分组下有书籍,则须要在书籍列表的最后面添加一个导入书的按钮。

若是咱们把这些条件直接写在 if 里面:
下面是将其抽取到一个方法中:
对于不须要关心具体显示规则的人来讲,下面这种可读性好不少。

中间变量

前面讲到复杂的布尔表达式能够转换为命名良好的中间变量,但并不意味着能够随意添加。多一个中间变量就多一份复杂,多一种状态,对于使用次数少的中间变量能够直接将表达式内联到语句中。

像这里的 indexPath.row 含义清楚明确,不必再定义中间变量 row

对于类来讲也是如此,为了解决某些问题每每会引入一些状态成员变量,这种作法无可厚非,但仍是要谨慎。每每状态变量涉及何时 set、何时 reset,增长了类内部的复杂性,也增长了类内部方法间的耦合度。

优先处理错误状况

若方法须要检查参数或其余条件,把检查操做放在方法开始部分,条件不知足时当即返回。

第二种写法,一看就知道在条件不知足时方法什么也没作。 而第一种写法,尤为是在 if body 很复杂、嵌套了多层 if、代码超出一屏时就很难一眼看出在条件不成立时方法作了什么。

switch 语句不要有 default 分支

尤为是利用 switch 处理 enum 时,轻意不要写 default 分支,这样在漏掉哪一个枚举值没有处理时,编译器会发出警告。但在有 default 分支时,是不会有警告的。

最佳实践


该小节主要讨论在开发过程一些值得注意的小点,不必定与设计有关。

充分利用泛型

LLVM 7.0从编译器层面支持泛型,系统中经常使用的容器都增长了对泛型的支持,无疑是一个重大利好。

使用泛型容器时若类型不匹配编译器会有 warning。 其实,我我的以为泛型容器最大的意义并不是在类型不匹配时的 warning,而是泛型对接口的自我说明能力。 上面第个一代码片断,直到使用该容器时才能知道其中元素的类型,而第二个代码片断从容器定义中便能清楚地看到这点。

合理使用 nullable、nonnull

同泛型,nullability 也是编译器 LLVM 支持的一个特性,用于描述接口中的值是否能够为 nil。 对于 nonnull 的接口,若传入的是 nil,编译器会发出 warning。一样,nullability对接口的自我说明能力比 warning 更有意义。

用好 Objective-C 的动态性

Objective-C 语言的动态性给咱们带来了无限的想象空间,『只有想不到,没有作不到』。 典型的利用到 Objective-C 动态性的例子有:JSPatch、MJExtension、Swizzling、KVO 以及 AOP等等。

  • JSPatch 用于热补丁,已经很是火,无须多言;
  • MJExtension 主要用于将 json 自动转换成 Objective-C 的对象,能极大的提升开发效率;
  • Swizzling 的使用就更加常见了,咱们有一个典型的用途:经过 Swizzling UIViewController 的 viewWillAppear: 方法以及 UIButton 的 sendAction: 方法,记录用户的操做路径,在 crash 时随 crash log 一块儿上报,经过该方法解决了很多疑难杂症的 crash;
  • KVO 用处很大,争议也很大,用好了能够简化代码,具体能够参考我以前的文章:KVO漫谈
  • AOP 主要用于将不相关的逻辑独立出来,如打 log 等,在登陆模块重构时使用到 AOP 思想,具体可参考我以前的文章:AOP漫谈

统一管理队列

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 能够扩展示有类的功能,在使用 category 时有几个点须要注意:

  • category 独立于主类而存在,也就是说删除 category 不能影响主类(简单说就是不能在主类中引入 category 头文件);
  • 不能由于在 category 中须要使用成员变量或属性,而将其添加到主类头文件中,此时能够经过 associated object实现;
  • 不能在 category 中重写父类方法,当在主类与 category 中定义了同一个方法时,category 中的方法会覆盖主类的方法。好比:在主类与 category 中都定义了 dealloc 方法,则主类中的 dealloc 方法将被覆盖。尤为是在没有源码的三方库中,更加不能这样作。

避免使用宏

首先须要代表的是,可以使用宏而不是硬编码,是一件值得鼓励的事情。 但宏的缺点也经常遭到诟病,主要有:非强类型、只是预编译期的文本替换,由于宏定义没有加括号而引起的错误家常便饭。 绝大多数状况下,宏均可以用 const 或者子方法代替。看个有意思的问题,下面这个宏定义有问题吗:

在使用时会报错:
问题就出在的文本替换上面,上面这个调用最终的样子是:

拒绝 warning

warning 表明程序存在病态,虽然有些 warning 看上去可有可无,但 warning 一旦多起来,一些重要的 warning 可能也隐藏其中,难以发现。

对于那些真的以为可有可无的 warning,能够经过#pragma clang diagnostic ignored将其隐藏(固然并不推荐这么作)。

小结


在现在的移动互联网时代,不少项目都是被需求推着往前走,都在追求敏捷开发、快速迭代、小步快跑,对代码质量有所忽视。 俗话说:『磨刀不误砍柴工』,在动手以前多一点思考,在开发过程当中少一点任性,就能写出质量更高的代码。 总之,高质量说来容易,作到难。不只要有丰富的实战经验,扎实的功底,更要有一颗执著的心!

相关文章
相关标签/搜索