设计模式是所有开发者都要学习的。
本文将基于实际开发经验谈谈对设计模式认知,再说一说实践当中的原则和个人的理解。
对设计模式的认知
在我有限经验的认知里,在复杂业务开发场景,能合理运用设计模式更是尤为重要。
对于设计模式每个人都可以说上一二,然而落地到实际开发当中,好的代码设计少之又少。
是什么造成了这种现象?
我回顾过去发现有 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 | if(A){ |
等到 3 个SDK,我们的代码是:
1 | if(A){ |
实际代码当中,随着增加的 SDK 引擎,每次需要修改的地方有 N 个,工作量 = N*引擎数量,完全靠手动增加非常容易出错。
如果我们使用协议的方式呢?
那么我们的代码是:
1 | id<EngineProtocol> engine = [Engine createWithType:type]; |
如果需增加SDK,只需要增加一个类型的返回逻辑:
1 | - (id<EngineProtocol>)createWithType:(EngineType)type |
然后对于新增的 SDK 的 Engine ,像原来一样实现,只是遵守 EngineProtocol 的协议方法即可。
实现新 Engine 的额外工作量,可以说是仅有 2 行 。
面向接口编程对比面向实例编程的方式,越是复杂的业务,它能节省的工作量就越多。
同时遵守协议一项项实现接口方法,也减少我们犯错的几率。
这就是面向接口编程的强大之处。
里氏替换原则
一个父类实例在它出现的任何地方,都可以用子类实例做替换,并且不会导致程序的错误。
看一下经典的正方形例子:
正方形(子类)是矩形(父类)的一种。
正方形对于面积定义的公式是:宽x宽;矩形对于面积定义的公式是:长x宽。
如果使用正方形类替换矩形,输入 长和宽(如 3 和 5),使用正方形公式得到的面积,显然跟原来矩形的结果不一致,是错误的。
联想到上面 SDK 引擎的例子,里氏替换原则某种程度上,就是在帮助我们完成开闭原则。
接口继承让我们可以用一个对象替换另一个对象,更重要是不影响业务的正确运行。
里氏替换可以说是开闭原则的解决方式之一。
接口隔离原则
接口隔离原则说的是使用者不应该被迫依赖于它不使用的方法。
具体来说,就是顾客来到水果店买西瓜,但是店主只有捆绑销售到组合: 苹果+梨子+西瓜。
那么怎么解决这一问题呢?
当然是从顾客需求出发,只提供顾客需要的 西瓜
,或者尽量减少捆绑,提供 梨子+西瓜
。
回到具体的设计层面来说,如果接口功能过多,非常容易违反单一职责。
而对于我们的编码来说,就是从调用方的需求出发,需要调用什么,我就提供什么。
尽可能提供瘦接口或者分割多个接口,而不是堆积接口方法。
同样从上面的多 SDK 引擎为例子,其实有些场景,不同的 SDK 引擎也并非都需要同样的方法。
例如: 手游时不需要游戏按键功能,而端游需要游戏按键。那么我们可以这么做:
1 | //通用场景,BaseEngineProtocl,最小化基本功能 |
在另外的场景下:
1 | //需要游戏按键场景,KeyboardEngineProtocl 提供额外的键盘功能。KeyboardEngineProtocl 继承于 BaseEngineProtocl |
通过完成接口隔离原则,我们的代码不依赖多余的接口方法,将变得更容易维护和清晰。
接口隔离原则最重要的就是,从使用者角度出发定义接口方法。
依赖倒置原则
在开闭原则的阐述里,有说到,面向接口编程,而不是针对实例编程。
依赖导致原则就是针对这一说法的指导:
抽象不应该依赖于细节,两者都应依赖各自的抽象。
还是使用多 SDK 引擎为例子.原来的是:
改造后是:
在高层业务的游戏功能模块,代码依赖于 EngineProtocol 协议作为接口,来完成我们的业务功能。
作为 EngineProtocol 的实现细节,EngineA/EngineB 也都依赖 EngineProtocol 去做实现。
如果是 EngineA 依赖游戏功能,或者游戏功能依赖于 EngineA,那么我们就违反了依赖倒置。
由此看出依赖倒置原则也是帮助我们实现开闭原则的方法之一。
总结
说了些对设计模式的感想,以及如何理解 SOLID 原则,属于人人都很容易说,实践起来却需要见功力的东西。
尤其希望刚入行(1-2年)的工程师,不要为了做而做,在手里拿着锤子,去找钉子。而是先存有个理念,遇到合适问题,再拿出我们的武器,在做中学。
对于有一些经验(3-5年)的工程师,就要开始着重锻炼我们的设计能力了,大胆的设计,小心的实践。
至于更久年限的程序员们…我还没到那个时候,无法建议😂
现在很少看到10年以上的前辈们发表文章,希望能多听到来自老江湖们的金玉良言。