『码』出高质量

本文从易理解、可维护、可扩展三个维度简要介绍了对高质量代码的理解。
同时,提出了一种新的 GUI 模式:MVVS。

©原创文章,转载请注明出处!

Overview


个人认为,高质量的代码首先应该是『简单的』。
『简单』又可以从以下三个维度去度量:

  • 易理解
  • 可维护
  • 可扩展

本文将从上述三点展开讨论,谈谈我个人的理解。

易理解


高质量的代码一定是易于理解的代码,读起来应该像言情小说,而不是诘诎的文言文。
高质量的代码可以是优雅的,但一定不是炫技的。
高质量的代码应该是深入浅出的,尽量用简单、朴实的方式去表达。

易理解面向的是整个团队,而不是写代码的那个人。
易理解是团队高效、无缝协作的基础。

影响代码可理解性的因素有很多,比如:

  • 『良好命名』

    好的命名即是成功的一半,也是最具挑战性的事情之一;
    总的原则就是体现『做什么』,而不是『怎么做』;
    在阅读优秀开源代码以及系统API时多留意、多思考。

  • 『清晰结构』

    ( 这里指的是代码的组织结构 )
    相关代码组织在一起,如:initdeinit/dealloc、UIViewController 生命周期方法viewDidLoadviewWillAppearviewDidAppear等;
    不同组织间用空行隔开;
    或者通过 Category、Extension 的方式来组织。

  • 『合理抽象』

    抽象的关键是隐藏细节(『细节是魔鬼』);
    在我们不关心具体细节时能很容易地忽略细节、抓住重点、抓住主干;
    抽象存在于每个层级:变量、方法、类、模块。

  • 『线性逻辑』

    每个实体 (变量、方法、类、模块) 在不同的层级上都只围绕一件事展开 –『高内聚』;
    模块内部的类、类内部的方法、方法内部的语句尽量都能通过线性的方式串连起来;
    而如果它们间形成的是一张网或离散的点都是坏代码的味道 –『低内聚』;
    如,类内部存在大量不相关的方法,方法内部存在过多的ifswitch语句。一个类动辄几千行、一个方法动辄几百行,此时就值得我们高度警惕;
    很明显,线性的逻辑比网状的逻辑更易于理解。

  • 『最小依赖』

    ( 依赖可以从两个角度来度量:依赖的多少以及强弱 )
    依赖肯定是越少越好 –『低耦合』;
    过多的依赖无疑会增加代码的复杂性,往往也是代码设计出问题的一个信号;
    提高内聚、面向接口编程等都是降低依赖的有效方式;
    同时,依赖关系越弱越好,继承无疑是最先考虑到的代码复用方式,但是继承也是最强的一种依赖、耦合关系。
    代码复用,应优先考虑组合。

  • 『精简体积』

    简单讲,就是无用代码及时删除;
    在阅读代码时经常会遇到一些莫名其妙的逻辑,经过一番调查,发现已是废弃的代码;
    无形中增加了理解成本,并且随着时间推移,后浪们也不敢去删这些代码,最终越积越多。

  • 『朴实表述』

    正如名言:代码首先是写给人看的,其次才是让机器执行的;
    因此,尽量用平易、朴实的方式去描述、去表达,让大家都能『看得懂』;
    总之,好的代码一定是简单的、清晰的。

易理解是高质量代码最基础的要求,上述只是影响代码易理解性的几个小点,更多的需要我们在实际编码过程中不断思考总结。

『 浅谈高质量移动开发 』一文中对类的设计、方法的设计等有更具体的讨论,感兴趣的同学可以看看。

可维护性


移动端开发最主要的场景就是基于 GUI 的业务开发。
因此,本文谈到的可维护性也主要是围绕 GUI 流程展开。

关于 GUI 的设计模式 (MV*) 在近几年也是老生常谈的话题之一。
无论 GUI 模式如何演化,个人认为其核心原则未曾改变:

  • 单向数据流
  • 数据完整性
  • 数据驱动 UI

『单向数据流』

无论哪种 GUI 模式,从大的方向上都可以分为两层:

  • Domain Layer:数据层(业务逻辑)
  • Presentation Layer:表现层(UI)

单向数据流是指『业务数据』一定是从『数据层』流向『表现层』,绝不允许反过来。
『表现层』可以响应 UI 事件并传递给『数据层』,
至于『数据层』如何处理这些事件纯属其『内政』,『表现层』无权干涉。

简单概括,『 单向数据流 』背后有两个『 流 』:

  • 数据流:从『 数据层 』流向『 表现层 』;
  • 事件流:从『 表现层 』流向『 数据层 』。

关于事件,很多响应式框架会将其定义为独立的数据结构,如:Redux中的ActionBLoC中的Event
我个人认为这种设计有一个比较严重的问题:不能通过『 command + 单击 』的方式『 链式 』地阅读代码,需要通过全局搜索。
这严重影响了开发效率。
个人觉得事件可以直接是『 数据层 』暴露给『 表现层 』的接口,即有事件需要处理时,直接调用相应的接口即可。

为什么?

我相信任何一位移动开发者对于『单向数据流』都耳熟能详,但其背后深层次的原因不见得都能说清楚。
首先,数据流不能从『表现层』流向『数据层』,说的到底是什么?
其真正的含义是『表现层』不能直接修改『数据层』的数据。为什么?

从设计的角度讲,数据管理是『数据层』的职责,『表现层』不应越俎代庖。
否则,『表现层』就违反了『单一职责』原则,也违反了『高内聚』的设计理念。

从现实的角度讲,『表现层』直接修改数据对于代码维护性来说是一个『灾难』。
首先,直接后果就是可能造成『不同步』:

  • UI 刷新与数据修改不同步,数据修改后 UI 没有及时刷新;
  • 多个 UI 场景间不同步,有的数据可能被多个业务模块所使用,如果由其中某一处直接修改,其他模块很可能无法感知到这一修改,造成不同步。
    如下图,Scenes1 直接修改了底层数据,但并未通知 Scenes2、Scenes3 (Scenes1 可能根本不知道 Scenes2、Scenes3 的存在),造成数据不同步:

    『不同步』常见的后果就是在使用 TableView 时数组越界。
    对于此类问题,我们经常是对数组访问加个保护,而很少也很难从根源上解决问题。
    尤其是多业务场景共享数据时。因为你根本不知道是『 谁 』在『 什么时候 』修改了数据源。

其次,还可能引起多线程问题:
『数据层』可能有专职的线程去管理数据,如果『表现层』擅自修改数据,很可能引发多线程问题。

如何解

『数据层』一定不能向『表现层』直接暴露可修改 (mutable) 的属性。

对于引用类型来说,情况可能更复杂一些。理想情况下,『数据层』返回给『表现层』的数据应该是final的或是深拷贝的。
总之,要做到『表现层』毫无直接修改底层数据的可能性。

此时,大家可能有疑问了,即使是『表现层』通过事件触发『数据层』内部去修改数据,还是可能会引起不同步的问题。
对,这就需要通过『数据完整性』、『数据驱动 UI』来解决了。

『数据完整性』

数据完整性指的是数据不应该存在中间临时状态。
如上图左则所示,可能会导致意想不到的结果。

在需要修改时,应该对数据作整体替换,这也是我们常说的『数据不可变性』。

在函数式编程中,所有数据都是不可变的,所有操作的结果都是生成新的数据,而不是在原有数据上作修改。

严格遵守『数据完整性』、『数据不可变性』原则,能很好地避免中间状态问题。

『数据驱动 UI』

底层数据发生变化后,上层 UI 如何感知并刷新?
总的原则就是『数据驱动 UI』
直白点,就是『数据层』有渠道、有方法在数据变化时能主动通知到所有关注该数据的『表现层』对象。

看似很简单?实则很多项目都没能做到这一点。

有没有闻到『响应式编程』的味道。
有同学一听到『响应式编程』就觉得很复杂,难于接受。
其实,我个人认为『响应式编程』并非一定要使用诸如Rx*ReactiveCocoa或 iOS 原生的KVO这样的框架或技术。(它们只是一种实现手段而以)
其核心在于:

  • 『数据层』主动通知,『表现层』被动响应;
  • 数据的所有使用方都能及时感知到数据的变化。

因此,像 Delegate、Callback 甚至 Notification 都可以用来实现『响应式编程』。

『响应式编程』其实就是『观察者』设计模式的一种应用场景。

好了,我们来回顾总结一下:

  • 『单向数据流』:保证数据的修改仅发生在『数据层』内部,这也是『数据完整性』、『数据驱动 UI』得以实现的前提;
  • 『数据完整性』:任何数据的修改都是整体替换,实现『数据不可变』的语义,避免出现数据的中间状态;
  • 『数据驱动 UI』:保证数据修改后能及时通知 UI,避免状态不同步。

MVVS

基于『单向数据流』、『数据完整性』以及『数据驱动 UI』的原则,我们提出一种新的 GUI 模式:MVVS

  • M:Manager,处理业务逻辑,管理业务数据,将数据转换为 ViewState 并通知上层 UI;
  • V:View,UIViewController/UIView,面向 ViewState 编程;
  • VS:ViewState,当前的 UI 状态,将『数据驱动 UI』进一步拆分:数据驱动状态、状态驱动 UI。

MVVS vs. MVVM,相当于将 MVVM 中的 VIewModel 拆分为 MVVS 中的 Manager 和 ViewState,使得各自的职责更加清晰。

MVVS 中的 ViewState 受 Flutter 响应式框架 flutter_bloc 中 state 启发。

Manager、View、ViewState 间的关系如下图所示:

ViewState

ViewState 代表当前的 UI 状态,为 UI 提供展示用的数据。
原则上,ViewState 与 View 一一对应。
对于过于简单的 UI 也可以没有与之对应的 ViewState。

如上图,与 ViewController 对应的是 Root ViewState,代表当前该模块的整体状态。
根据不同的状态,ViewState 可以是不同的子类型,如:LoadingViewState、ErrorViewState、EmptyViewState、LoadedViewState 等。ViewController 再根据不同的状态作出不同的响应。

其他

上面我们主要讲述了 GUI 架构上会影响代码可维护性的几个关键点
影响代码可维护性的因素还有很多,如:

  • 最少状态: 在代码中经常会看到很多的状态变量,如:is***(isFirstLoadisNewUser)、has***(hasLoaded)、***Count(userCount)等等。
    这些状态本身的维护以及其对代码整体的维护都是非常容易出问题的。
    是否在所有需要修改的地方都正确的修改了?
    修改后使用到这些状态的地方都及时通知到了?
    因此,状态要越少越好,能不加就不加。
    如,***Count是否可以从数据集上动态计算得到

  • 最小权限: 无论是模块还是类暴露的接口都应遵守最小权限原则
    在具体开发过程中可以通过『 依赖倒置 』原则,让接口需求方提出接口需求,避免由实现方直接提供接口而无意中暴露过多细节。
    具体可以参看『 面向对象设计原则『SOLID』在开发中的应用 』

  • 写纯函数: 纯函数几乎没有外界依赖,其在可维护性、易理解上有天然优势。
    一般类方法都有纯函数特性,因此,能写类方法的时候就不要写实例方法。
    『 函数式思维 』一文中对函数式编程有过简单讨论

可扩展性


『唯一不变的就是变化』
面对变化,我们唯以积极心态对之。
因此,代码的可扩展性也是我们需要重点思考的问题之一。
『 SOLID 』中的『 O — OCP,开放-封闭原则 』,就是用于指导可扩展性的原则之一。

OCP:『 对扩展开放,对修改封闭 』。

强调『可扩展性』就是要求我们写『活代码』。
在平时 Code Review 时,我经常开玩笑地说『你这个代码写的太死』。

23 种设计模式中的『 策略模式 』、『 模板方法 』都是用于指导提升可扩展性的方法。

『 策略模式 』

提高代码可扩展性最有效的方式之一就是面向接口编程。
『 论面向接口编程 』一文中对此有详细介绍,在此不再赘述。

在具体开发时如何写出扩展性高的代码呢?
『扩展性』面向的是未来,
因此,首先要思考的是『 什么是可能会变的 』
再将『 可变部分 』隔离出来,并以接口的形式去抽象它。

熟悉设计模式的同学可能已经看出来了,这其实就是『策略模式,Strategy 』。

在之前的文章中,我们也提到过,如:登录模块、多 Tab 页面都是典型的可通过『 策略模式 』来提高扩展性的场景。

登录模块的例子在『 面向对象设计原则『SOLID』在开发中的应用 』一文中有详细介绍
多 Tab 页面的例子在『 论面向接口编程 』一文中有详细介绍

『 模板方法 』

『 策略模式 』的基础是面向接口编程。
而『 模板方法,Template Method 』的基础是继承。
同样,在『 面向对象设计原则『SOLID』在开发中的应用 』一文中对『 模板方法 』模式有过简单的介绍。
『 iOS 高效开发解决方案 』一文中介绍过通过『 模板方法 』模式提高 UI 组件的扩展性。

提升代码可扩展性的方法绝不仅上述 2 种模式,但其背后的思想非常重要,在平时开发时可灵活应用。

其他


除了上述提到的『 易理解 』、『 可维护 』、『 可扩展 』之外,还有很多点是值得我们去思考和关注的,如:

  • 错误处理:如何优雅地处理错误其实非常重要,但往往被忽略。在设计接口时需要关注出错的情况;
  • 关键路径打 log:错误是无法避免的,除了在出错时给用户一个较好地提示外,我们也需要去了解出错的原因,此时 log 就显得尤为重要,要养成在关键路径打 log 的习惯。否则,用户反馈问题后,两眼一抹黑,无从下手;
  • 适时重构:重构不一定是要对代码做出『 翻天覆地 』的改变,小到对变量重命名、抽取一个方法等『 微小 』的优化都算是重构。总之,在当前代码结构已不再适应新业务需要时,就需要及时重构,切不可在原有基础上打补丁,代码的恶化往往就是从此开始的。
    对待代码我们同样要有敬畏之心:『 勿以善小而不为,勿以恶小而为之 』。

小结


写出高质量的代码可谓『 功在当时,利在未来 』
每一位开发者都应为开发出高质量的代码也努力
代码设计能力的提升非一日之功,需要我们长期不断地学习、实践、思考、总结
优秀的书籍、优秀的开源代码我们要学习
糟糕的代码我们也要去反思,去总结
在代码上同样要做到『 勿以善小而不为,勿以恶小而为之 』

诸君共勉!