闲话设计模式认知与SOLID原则

设计模式是所有开发者都要学习的。

本文将基于实际开发经验谈谈对设计模式认知,再说一说实践当中的原则和个人的理解。

对设计模式的认知

在我有限经验的认知里,在复杂业务开发场景,能合理运用设计模式更是尤为重要。

对于设计模式每个人都可以说上一二,然而落地到实际开发当中,好的代码设计少之又少。

是什么造成了这种现象?

我回顾过去发现有 4 点原因:

  1. 设计思考是一项门槛。因为思维局限或惯性,大部分人(包括我自己)如没有专门的训练意识,是会直接动手的,用最简单的方式,想到哪写到哪。
  2. 设计实施是一项成本。稍有经验的工程师,是能预见产品某业务复杂度增长的,可按时交付的念头,会让人倾向更容易的选择。
  3. 没有被坏设计坑害过 : (
  4. 没有体验过好设计带来的舒适 :)

现在我来看这 4 点原因,也是作为一个工程师成长的历程。

思维局限

对于 1 阶段的工程师来说,由于经验缺乏,连系统 API 都没有熟悉,自身没有经历过复杂项目或看过开源项目,所以更谈不上什么设计模式,就是一张白纸。这阶段的开发者,首先就是要 启发认知,先知道有什么。

我当时以实习生转成 iOS 开发,靠一本基础知识书就开始上路,没有任何 iOS 的前人指导。所幸那时直播火起来,当时 iOS 大V免费直播,逐渐知道MVC ,基础的工程结构划分,业务逻辑能用 manager 来统一管理等等。感谢互联网,感谢前人们的分享。

当然我相信,想向上的人总能找到自己的出路。优秀的开源项目或者书籍都可以开阔认知,只是都不如一个前辈手把手来的好。

怠于实践

对于 2 阶段来说,起码工作几年,属于知道设计模式,也拥有处理某些复杂业务经验。掣肘的地方,常常在于工期的矛盾,让人做出 赶工 的决定。这时我们一定要有概念:坚持做正确的事

某种程度来说,把事情做对,比做快更重要。

就我自身而言,随着项目复杂,在几次上级亲自下场 coding 时问我,为什么当时不做 xxx 设计呢?我说我想到了,但是时间紧没有写。

随后开始进行改造,往往半天就完成了。更费时间 也许是心理上制造的拦路虎,有时对 2-3天的业务工期,只多花费 1-2 小时罢了,并不会有实质的阻碍。

自食恶果

对于 3 阶段来说,没有被坏设计坑害过在我看来是一个瓶颈

如果产品没有好的发展(持续的盈利/用户数增长),你是等不到产品业务复杂的那一天,大多数产品甚至停留在 2.0 版本以下就消亡了。这一点对于服务端来说都是一样。

宝剑锋从磨砺出,梅花香自苦寒来。作为 iOS 工程师,要在产品狗子复杂的业务中成长起来,实践是检验真理的唯一标准。

等你被坏设计坑害,然后想改又不敢的时候,你才会大呼 – 坑害我的竟是我自己!

你不用问我为什么会知道 : (

当然啦,坑害你的也可能是别人..总之,你需要擦屁股就对了。

这时你才会在小心翼翼的重构当中,痛定思痛,认真思考和设计。

毕竟,人总是对痛苦记忆感受深刻。

走向正轨

对于 4 阶段,如果能走出重构灾难,工程师一般都将吸取被毒打的教训,开始对接手的业务运用设计模式,最后再享受到扩展功能/修改功能/移除功能的好处。

严谨的设计,实施起来必定是稍复杂的,没有接触复杂业务以前,是无法深切体会所谓好设计带来的收益。

业务比基础技术挑战更大的地方,就在于因地制宜,判断什么地方要设计/不设计;而设计的话该怎么做。既要强调代码上做设计,也要避免过度设计

总之,一个能做好业务的工程师,必定是珍贵的。

对于 4 阶段来说,最重要是反复的:知道+做到->得到。验证好流程带来的收益,形成正向反馈,大呼:舒服了!

以上是我个人的小小经验之谈,难免局限,希望大家有看法的话,能多多评论交流。

如何落地设计模式

具体来说,一个好的业务设计在我眼里有这么几个特点:

  • 易于扩展。
  • 易于修改。
  • 易于删除。

其实就是 增/删/改 的成本最小。要改动的地方越少,它的危险系数也相对少。

相信大多数工程师都经历过 ‘牵一发动全身’ 的问题,也出现过遗漏了地方最后导致上线有 Bug 。

今年本想结合自己项目去好好改造,这里不做借口,只能说,想的有多美,等到自己干的时候就有多难 : )

一个建议就是平时尽量按照 SOLID 原则去思考。

在这里并不想死记硬背,来罗列 23 种设计模式是什么,具体的设计模式可以说都是实现 SOLID 原则的手段。如果有方式可以达成 SOLID 原则 ,我认为即使不属于 23 种设计模式里也无妨。这方面网上文章或者书籍不说是汗牛充栋,也是非常之多了,可以自行了解。

一个建议是不要只看 iOS 相关,对于设计模式文章,很多搞 Java 写出来的不错,思想是通用的。

SOLID

  • S : 单一职责原则 (Single Responsibility Principle)
  • O : 开放封闭原则 (Open-closed Principle)
  • L : 里氏替换原则 (Liskov Substitution Principle)
  • I : 接口隔离原则 (Interface Segregation Principle)
  • D : 依赖倒置原则 (Dependence Inversion Principle)

学习建议

在逐一解释它们每个原则之前,我想说一下学习和理解的建议。有的弯路我曾经走过,如果能看到自己的文章,或许将来能走的快那么一点点…

其实上面说了 5 项原则,单纯看一遍,我们将很快就忘记了…

你会记得一周前你随意刷过一个搞笑视频的细节吗?

有时面试,面试官问到一个问题 X 时,明明很久前学过背过,但是临场记不全了..

核心原因还是在于,我们没有真正的得到它

最佳学习方式就是实践

对于部分问题,很遗憾,几乎没有机会实践它,那降低优先级,先用是什么/为什么/怎么做的方式大概了解即可。

最高优先级,最合适的方式,就是平时工作项目中的实践机会。

既符合实际场景,也能帮助自己和公司解决问题,还能提升自我的能力。

所以,工作中遇到问题时,务必好好抓住实践场景,去尝试解决/验证/总结。

同样的问题,大概率会再出现第二次,第三次。

这样反复下来,我们就能获得很好的反馈,以及解决经验 。

对于这 5 项原则,要做的就是刻意练习。就像学习客户端编程时,反复去写 UI ,写数据模型。

请谨记 – 没有人仅仅靠书本知识,就能成为一名真正的司机

单一职责

从字面上,很容易理解,就是一个对象只负责单一功能。

一个比较官方的指导说法是:

”A class should have only one reason to change.” – 仅有一个原因会使类变更

通俗具体的说,就是一个类的修改,不受多个功能修改的影响。

然而我相信它正如“蛋炒饭”一样,最简单也最困难,和上面提到的做业务最大的挑战,怎么衡量它的度?

业务划分不像性能指标一样,存在一条明显的分割线,去分割出我们的功能。

坦诚的说,包括我自己,9 成以上的工程师经常在违反着单一职责,而未加审视。

那什么时候要对一个类的功能进行代码拆分,或者什么时候需要合并几个功能到一个类中去?

同样也提供一个实践思路,就是随着业务每次迭代,不断的拆分和封装,持续重构我们自己的代码,正如烧烤时做的,不断调整“火候”。

我作为客户端工程师,感受比较强烈的一点,就是在 MVC 的设计架构下,View 的变化总是最多的,Controller 逻辑总是堆积最多的,反而是 Model 的变化相对少一点,通常也只会增加新 Model。

开闭原则

开闭原则的经典定义:

实体应该对扩展是开放的,对修改是封闭的。

总的来说,就是当增加一个新功能的时候,尽量在不修改既有代码的情况下,来进行扩展。使用增加代码的方式,如新的类或新的方法来实现新功能。

实现开闭原则,可以说是设计的终极目标,其它的原则多少都是在为开闭原则服务。

因此在写代码的时候,我们可以对开闭原则做重点思考。

在 iOS 来说,经典的就是 OC 中对分类的使用,例如我们常常为 UIView / UIButton / UIImage 等增加分类,来扩展它们的功能。

但是不要因为我举了这么一个例子,就都使用它来编码…

实现开闭原则的关键在于抽象

最终我们仍旧要回到学习面向对象的起点之一 – 抽象。

以前我们所做的抽象,更多其实只是建模,将需要的事情,抽象成对象模型。

而要实现开闭原则,更重是牢记一点 :

针对接口编程,而不是针对实现编程

具体对于 iOS 来说,需要学会使用协议来抽象行为的接口,实现协议的类来做真正的操作和执行。

以我工作项目当中使用多个第三方游戏 SDK 引擎来做说明。

先说只针对实例的编程的情况会是什么样。

只有 1 个SDK,我们的代码是:

1
[EngineA do];

增加到 2 个SDK,我们的代码是:

1
2
3
4
5
if(A){
[EngineA do];
}else{
[EngineB do];
}

等到 3 个SDK,我们的代码是:

1
2
3
4
5
6
7
8
9
10
11
if(A){
[EngineA do];
return;
}
if(B){
[EngineB do];
return;
}
if(C){
[EngineC do];
}

实际代码当中,随着增加的 SDK 引擎,每次需要修改的地方有 N 个,工作量 = N*引擎数量,完全靠手动增加非常容易出错。

如果我们使用协议的方式呢?

那么我们的代码是:

1
2
id<EngineProtocol> engine = [Engine createWithType:type];
[engine do];

如果需增加SDK,只需要增加一个类型的返回逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
- (id<EngineProtocol>)createWithType:(EngineType)type
{
if(A){
return EngineA<EngineProtocol>;
}
if(B){
return EngineB<EngineProtocol>;
}
if(C){
return EngineB<EngineProtocol>;
}
...
}

然后对于新增的 SDK 的 Engine ,像原来一样实现,只是遵守 EngineProtocol 的协议方法即可。

实现新 Engine 的额外工作量,可以说是仅有 2 行 。

面向接口编程对比面向实例编程的方式,越是复杂的业务,它能节省的工作量就越多。

同时遵守协议一项项实现接口方法,也减少我们犯错的几率。

这就是面向接口编程的强大之处

里氏替换原则

一个父类实例在它出现的任何地方,都可以用子类实例做替换,并且不会导致程序的错误

看一下经典的正方形例子:

正方形(子类)是矩形(父类)的一种。

正方形对于面积定义的公式是:宽x宽;矩形对于面积定义的公式是:长x宽。

如果使用正方形类替换矩形,输入 长和宽(如 3 和 5),使用正方形公式得到的面积,显然跟原来矩形的结果不一致,是错误的。

联想到上面 SDK 引擎的例子,里氏替换原则某种程度上,就是在帮助我们完成开闭原则。

接口继承让我们可以用一个对象替换另一个对象,更重要是不影响业务的正确运行。

里氏替换可以说是开闭原则的解决方式之一。

接口隔离原则

接口隔离原则说的是使用者不应该被迫依赖于它不使用的方法

具体来说,就是顾客来到水果店买西瓜,但是店主只有捆绑销售到组合: 苹果+梨子+西瓜。

那么怎么解决这一问题呢?

当然是从顾客需求出发,只提供顾客需要的 西瓜,或者尽量减少捆绑,提供 梨子+西瓜

回到具体的设计层面来说,如果接口功能过多,非常容易违反单一职责

而对于我们的编码来说,就是从调用方的需求出发,需要调用什么,我就提供什么。

尽可能提供瘦接口或者分割多个接口,而不是堆积接口方法。

同样从上面的多 SDK 引擎为例子,其实有些场景,不同的 SDK 引擎也并非都需要同样的方法。

例如: 手游时不需要游戏按键功能,而端游需要游戏按键。那么我们可以这么做:

1
2
//通用场景,BaseEngineProtocl,最小化基本功能
id<BaseEngineProtocl> engineMobile = [Engine createWithType:type];

在另外的场景下:

1
2
3
//需要游戏按键场景,KeyboardEngineProtocl 提供额外的键盘功能。KeyboardEngineProtocl 继承于 BaseEngineProtocl
id<KeyboardEngineProtocl> engineKeyboard = [Engine createWithType:type];
[engineKeyboard tapA];

通过完成接口隔离原则,我们的代码不依赖多余的接口方法,将变得更容易维护和清晰。

接口隔离原则最重要的就是,从使用者角度出发定义接口方法。

依赖倒置原则

在开闭原则的阐述里,有说到,面向接口编程,而不是针对实例编程。

依赖导致原则就是针对这一说法的指导:

抽象不应该依赖于细节,两者都应依赖各自的抽象。

还是使用多 SDK 引擎为例子.原来的是:

改造后是:

在高层业务的游戏功能模块,代码依赖于 EngineProtocol 协议作为接口,来完成我们的业务功能。

作为 EngineProtocol 的实现细节,EngineA/EngineB 也都依赖 EngineProtocol 去做实现。

如果是 EngineA 依赖游戏功能,或者游戏功能依赖于 EngineA,那么我们就违反了依赖倒置。

由此看出依赖倒置原则也是帮助我们实现开闭原则的方法之一。

总结

说了些对设计模式的感想,以及如何理解 SOLID 原则,属于人人都很容易说,实践起来却需要见功力的东西。

尤其希望刚入行(1-2年)的工程师,不要为了做而做,在手里拿着锤子,去找钉子。而是先存有个理念,遇到合适问题,再拿出我们的武器,在做中学。

对于有一些经验(3-5年)的工程师,就要开始着重锻炼我们的设计能力了,大胆的设计,小心的实践。

至于更久年限的程序员们…我还没到那个时候,无法建议😂

现在很少看到10年以上的前辈们发表文章,希望能多听到来自老江湖们的金玉良言。