概要
一个组件模块追求的技术目标是高内聚,低耦合,可扩展,易理解,但是这个目标过于形而上,落实到具体的实践过程中往往缺乏依归和遵照。本文重点强调的则是尽可能的以业务为中心,去贯彻和落实DDD(领域Domain-设计Design-驱动Driven)的设计思想和原则,通过领域模型表达业务需求和业务规则。这要求开发者必须首先尊重业务,逐步了解业务,深入业务的细枝末节,通过独立思考和在与需求人员不断沟通过程中,将心中对需求的困惑逐个解开,然后在自己的大脑中形成一张比较清晰完整的业务逻辑图谱。了解业务的最高境界就是能够对未来业务发展的走向做出合理预期,并且在代码层面为这个预期预留好空间。
1. 领域就是问题域,有边界,领域中有很多问题;
2. 任何一个系统要解决的那个大问题都对应一个领域;
3. 通过建立领域模型来解决领域中的核心问题,模型驱动的思想;
4. 领域建模的目标针对我们在领域中所关心的问题,即只针对核心关注点,而不是整个领域中的所有问题;
5. 领域模型在设计时应考虑一定的抽象性、通用性,以及复用价值;
6. 通过领域模型驱动代码的实现,确保代码让领域模型落地,代码最终能解决问题;
7. 领域模型是系统的核心,是领域内的业务的直接沉淀,具有非常大的业务价值;
8. 技术架构设计或数据存储等是在领域模型的外围,帮助领域模型进行落地;
目标
使用设计模式是为了可重用代码、让代码更容易被他人理解、保证代码可靠性。设计模式使代码编制真正工程化,提供程序的可移植性、可扩展、可维护性,能够使代码的更加结构化和层次感。原则
在使用基类的的地方可以任意使用其子类,能保证子类完美替换基类。
高层模块不应该依赖底层模块,二者都该依赖其抽象;抽象不应该依赖细节;细节应该依赖抽象。
类间的依赖关系应该建立在最小的接口之上。
低耦合,高内聚,通俗的讲是:类对自己依赖的类知道的越少越好。
尽量通过扩展软件实体来解决需求变化,而不是通过修改已有的代码来完成变化。
原则
2.1 开闭原则
一个软件实体应当对扩展开放,对修改关闭。即软件实体应尽量在不修改原有代码的情况下进行扩展。
当软件系统需要面对新的需求时,应该尽量保证系统的设计框架是稳定的。如果一个软件设计符合开闭原则,那么可以非常方便地对系统进行扩展,而且在扩展时无须修改现有代码,使得软件系统在拥有适应性和灵活性的同时具备较好的稳定性和延续性。随着软件规模越来越大,软件寿命越来越长,软件维护成本越来越高,设计满足开闭原则的软件系统也变得越来越重要。
为了满足开闭原则,需要对系统进行抽象化设计,抽象化是开闭原则的关键。在Java、C#等编程语言中,可以为系统定义一个相对稳定的抽象层,而将不同的实现行为移至具体的实现层中完成。在很多面向对象编程语言中都提供了接口、抽象类等机制,可以通过它们定义系统的抽象层,再通过具体类来进行扩展。如果需要修改系统的行为,无须对抽象层进行任何改动,只需要增加新的具体类来实现新的业务功能即可,实现在不修改已有代码的基础上扩展系统的功能,达到开闭原则的要求。
优点:实践开闭原则的优点在于可以在不改动原有代码的前提下给程序扩展功能。增加了程序的可扩展性,同时也降低了程序的维护成本。
2.2 里氏替换原则
所有引用基类对象的地方能够透明地使用其子类的对象
里氏代换原则告诉我们,在软件中将一个基类对象替换成它的子类对象,程序将不会产生任何错误和异常,反过来则不成立,如果一个软件实体使用的是一个子类对象的话,那么它不一定能够使用基类对象。例如:我喜欢动物,那我一定喜欢狗,因为狗是动物的子类。但是我喜欢狗,不能据此断定我喜欢动物,因为我并不喜欢老鼠,虽然它也是动物。
例如有两个类,一个类为BaseClass,另一个是SubClass类,并且SubClass类是BaseClass类的子类,那么一个方法如果可以接受一个BaseClass类型的基类对象base的话,如:method1(base),那么它必然可以接受一个BaseClass类型的子类对象sub,method1(sub)能够正常运行。反过来的代换不成立,如一个方法method2接受BaseClass类型的子类对象sub为参数:method2(sub),那么一般而言不可以有method2(base),除非是重载方法。
里氏代换原则是实现开闭原则的重要方式之一,由于使用基类对象的地方都可以使用子类对象,因此在程序中尽量使用基类类型来对对象进行定义,而在运行时再确定其子类类型,用子类对象来替换父类对象。
优点:可以检验继承使用的正确性,约束继承在使用上的泛滥。
2.3 依赖倒置原则
抽象不应该依赖于具体类,具体类应当依赖于抽象。换言之,要针对接口编程,而不是针对实现编程。
依赖倒转原则要求我们在程序代码中传递参数时或在关联关系中,尽量引用层次高的抽象层类,即使用接口和抽象类进行变量类型声明、参数类型声明、方法返回类型声明,以及数据类型的转换等,而不要用具体类来做这些事情。为了确保该原则的应用,一个具体类应当只实现接口或抽象类中声明过的方法,而不要给出多余的方法,否则将无法调用到在子类中增加的新方法。
在引入抽象层后,系统将具有很好的灵活性,在程序中尽量使用抽象层进行编程,而将具体类写在配置文件中,这样一来,如果系统行为发生变化,只需要对抽象层进行扩展,并修改配置文件,而无须修改原有系统的源代码,在不修改的情况下来扩展系统的功能,满足开闭原则的要求。
优点:通过抽象来搭建框架,建立类和类的关联,以减少类间的耦合性。而且以抽象搭建的系统要比以具体实现搭建的系统更加稳定,扩展性更高,同时也便于维护。
2.4 单一职责原则
一个类只负责一个功能领域中的相应职责,或者可以定义为:就一个类而言,应该只有一个引起它变化的原因。
单一职责原则告诉我们:一个类不能太“累”!在软件系统中,一个类(大到模块,小到方法)承担的职责越多,它被复用的可能性就越小,而且一个类承担的职责过多,就相当于将这些职责耦合在一起,当其中一个职责变化时,可能会影响其他职责的运作,因此要将这些职责进行分离,将不同的职责封装在不同的类中,即将不同的变化原因封装在不同的类中,如果多个职责总是同时发生改变则可将它们封装在同一类中。
单一职责原则是实现高内聚、低耦合的指导方针,它是最简单但又最难运用的原则,需要设计人员发现类的不同职责并将其分离,而发现类的多重职责需要设计人员具有较强的分析设计能力和相关实践经验。
优点:如果类与方法的职责划分得很清晰,不但可以提高代码的可读性,更实际性地更降低了程序出错的风险,因为清晰的代码会让 bug 无处藏身,也有利于 bug 的追踪,也就是降低了程序的维护成本。
2.5 迪米特法则(最少知道原则)
一个软件实体应当尽可能少地与其他实体发生相互作用
如果一个系统符合迪米特法则,那么当其中某一个模块发生修改时,就会尽量少地影响其他模块,扩展会相对容易,这是对软件实体之间通信的限制,迪米特法则要求限制软件实体之间通信的宽度和深度。迪米特法则可降低系统的耦合度,使类与类之间保持松散的耦合关系。
在将迪米特法则运用到系统设计中时,要注意下面的几点:在类的划分上,应当尽量创建松耦合的类,类之间的耦合度越低,就越有利于复用,一个处在松耦合中的类一旦被修改,不会对关联的类造成太大波及。在类的结构设计上,每一个类都应当尽量降低其成员变量和成员函数的访问权限。在类的设计上,只要有可能,一个类型应当设计成不变类。在对其他类的引用上,一个对象对其他对象的引用应当降到最低。
优点:实践迪米特法则可以良好地降低类与类之间的耦合,减少类与类之间的关联程度,让类与类之间的协作更加直接。
2.6 接口分离原则
使用多个专门的接口,而不使用单一的总接口,即客户端不应该依赖那些它不需要的接口。
根据接口隔离原则,当一个接口太大时,我们需要将它分割成一些更细小的接口,使用该接口的客户端仅需知道与之相关的方法即可。每一个接口应该承担一种相对独立的角色,不干不该干的事,该干的事都要干。
在使用接口隔离原则时,我们需要注意控制接口的粒度,接口不能太小,如果太小会导致系统中接口泛滥,不利于维护。接口也不能太大,太大的接口将违背接口隔离原则,灵活性较差,使用起来很不方便。
优点:避免同一个接口里面包含不同类职责的方法,接口责任划分更加明确,符合高内聚低耦合的思想。
分类
创建型设计模式:侧重于对象的创建。
结构型设计模式:侧重于接口的设计和系统的结构。
行为型设计模式:侧重于类或对象的行为。
创建型设计模式6个:
工厂方法模式()
抽象工厂模式()
单例模式()
生成器模式()
原型模式()
设计模式
iOS开发常用设计模式:
单例模式
观察者模式
适配器模式
外观模式
策略模式
中介者模式
代理模式
工厂模式
装饰模式
...
单例模式
单例模式能够确保指定类只有一个实例,并且全局的一个入口可以访问到该实例,iOS中用到许多单例模式,例如:UIApplication、 NSUserDefaults、NSNotificationCenter等,如下简单单例模式UML类图:
单例模式使用场景:经常用于一个类只有一个实例,提供一个单例访问的接口,整个系统进程中只需要一个实例对象。使用单例的目的:节省内存开销,因为单例在整个运行过程中只有一份内存资源,可以确保其他类获取的同一份数据。
单例模式的优缺点:
优点:
提供对唯一实例的受控访问。
可以节约系统资源,对于一些需要频繁创建和销毁的对象单例模式无疑可以提高系统的性能。
可以更加灵活修改实例化过程
缺点:
由于单利模式中没有抽象层,因此单例类的扩展有很大的困难。
单例类的职责过重,在一定程度上违背了“单一职责原则”。
iOS中单例模式的实现方法,一般分两种:Non-ARC和ARC+GCD
对于Non-ARC环境下的单例,特别注意retain、retainCount、release、autorelease、dealloc方法,其次是确保allocWithZone:(NSZone*)zone、copyWithZone、mutableCopyWithZone方法返回的唯一单例。
对实例化对象线程安全的控制。
+ (Singleton*)sharedInstance {
static Singleton *sharedInstance = nil;
@synchronized(self) {
if(sharedInstance == nil) {
sharedInstance = [[self alloc] init];
}
}
return sharedInstance;
}
+ (id)allocWithZone:(NSZone*)zone {
return [[self sharedInstance] retain];
}
- (id)copyWithZone:(NSZone *)zone {
return self;
}
- (id)mutableCopyWithZone:(NSZone *)zone {
return self;
}
通过ARC+GCD的方法实现比较简单,使用GCD中dispatch_once方法确保单例只被实例化一次,且该方法是线程安全的。
+ (Singleton*)sharedInstance {
static Singleton *sharedInstance = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^ {
sharedInstance = [[self alloc] init];
}
return sharedInstance;
}
当然以上方法也可以通过宏实现全局引用。
观察者模式
当对象间存在一对多关系时,则使用观察者模式(Observer Pattern)。比如,当一个对象被修改时,则会自动通知它的依赖对象。观察者模式属于行为型模式。
使用观察者模式:一个对象状态改变给其他对象通知的问题,而且要考虑到易用和低耦合,保证高度的协作。一个对象(目标对象)的状态发生改变,所有的依赖对象(观察者对象)都将得到通知,进行广播通知。
观察者模式的优缺点:
优点:
观察者和被观察者是抽象耦合的。
建立一套触发机制。
缺点:
如果一个被观察者对象有很多的直接和间接的观察者的话,将所有的观察者都通知到会花费很多时间。
如果在观察者和观察目标之间有循环依赖的话,观察目标会触发它们之间进行循环调用,可能导致系统崩溃。
观察者模式没有相应的机制让观察者知道所观察的目标对象是怎么发生变化的,而仅仅只是知道观察目标发生了变化。
iOS 中观察者模式的实现有三种方法 :
Notification机制通过 NotificationCenter实现对象之间通讯,观察对象的变化通知其他观察者。
通过KVO对象中特定属性发生改变返回观察对象的响应事件,对于KVO的具体实现另作论述。
标准方法自定义观察协议。
#import
@protocol StandardObserver
- (void)valueChanged:(NSString*)valueName newValue:(NSString*)newValue;
@end
@protocol StanardSubject
- (void)addObserver:(id
) observer; - (void)removeObserver:(id
) observer; - (void)notifyObjects;
具体实现参考:https://github.com/Arthurcsh/DesignPattern/tree/master/DesignPattern/Classes/Observer
适配器模式
适配器模式将一个类的接口适配成用户所期待的。一个适配器通常允许因为接口不兼容而不能一起工作的类能够在一起工作,做法是将类自己的接口包裹在一个已存在的类中。适配器模式将某个类的接口转换成客户端期望的另一个接口表示,主的目的是兼容性,让原本因接口不匹配不能一起工作的两个类可以协同工作。
以下情况比较适合使用 Adapter 模式:
当你想使用一个已经存在的类,而它的接口不符合你的需求;
你想创建一个可以复用的类,该类可以与其他不相关的类或不可预见的类协同工作;
你想使用一些已经存在的子类,但是不可能对每一个都进行子类化以匹配它们的接口,对象适配器可以适配它的父亲接口。
适配器模式的优缺点:
优点:降低数据层和视图层(对象)的耦合度,使之使用更加广泛,适应复杂多变的变化。
缺点:降低了可读性,代码量增加,对于不理解这种模式的人来说比较难看懂。
类适配器:通过继承来适配两个接口,首先需要有定义了客户端要使用的一套行为的协议,然后要用具体的适配器类来实现这个协议。适配器类同时也要继承被适配者。从图中可以看到,Adapter是一个Target类型,同时也是Adaptee类型。它重载了Target的request方法,没有重载Adaptee中的operation()方法,而是在Adapter的request方法的实现中,调用父类的operation方法。只有当Target是协议而不是类时,类适配器才能够用OC来实现,因为OC中是没有多重继承的。
对象适配器:与类适配器不同,对象适配器不继承被适配者,而是组合了一个对它的引用。很显然,OC中常用的委托(Delegate)模式属于对象适配器。
以常用的UITableViewDelegate为例,UITableView(对象适配器中的Client角色)处理选中行事件时,消息会传递给UITableViewDelegate(对象适配器中Target角色),然后调用MyViewController(对象适配器中Adapter角色)里面的- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath方法来进行处理,在MyViewController的这个方法中。
参考:https://github.com/Arthurcsh/DesignPattern/tree/master/DesignPattern/Classes/Adapter
外观模式
外观设计模式为复杂的子系统提供单一的接口,只公开一个简单统一的API,而不是将一组类及其API暴露给用户,API的用户完全不知道下面的复杂性。 这种模式在使用大量类时非常理想,特别是当它们复杂使用或难以理解时。外观模式将使用系统的代码与您隐藏的类的接口和实现相分离; 它也减少了外部代码对子系统内部工作的依赖性。 如果外观下面的类改变,那么外部类可以保留相同的API。
策略模式
定义一系列的算法,封装起来并且使它们可相互替换,策略模式使得算法可独立于调用它的客户端而变化。
测试模式的适用性:
许多相关的类仅仅是行为有异,「策略」提供类一种用多个行为中对应一个配置一个类的方法;
需要使用一个算法的不同变体。例如:可能会定义一些反映不同空间/时间权衡的算法,当这些变体实现为一个算法的类层次时,可以使用策略模式;
算法使用客户端不应该知道的数据,可使用策略模式以避免暴露复杂的、与算法相关的数据结构。
一个类定义类多种行为,并且这些行为在这个类的操作中以多个条件语句形式出现。将相关的条件分支封装到它们各自的Strategy类中以代替这些条件语句。
@protocol OYOStrategy
/**
* 策略方法
*/
- (void)doInvoke;
@end
/**
* A策略
*/
@interface: OYOStrategyA
- (void)doInvoke {
NSLog(@"-- 策略A.");
}
@end
/**
* B策略
*/
@interface: OYOStrategyB
- (void)doInvoke {
NSLog(@"-- 策略B.");
客户端调用策略
@interface OYOContext: NSObject
@property (nonatomic, strong) OYOStrategy *strategy;
-(instancetype)initContext:(OYOStrategy*)strategy {
self.strategy = strategy;
}
- (void)doInvoke {
[self.strategy doInvoke];
}
@end
使用策略模式与直接写if-else具体的优势, 要将模式放入架构中,不能孤立的看待设计模式,如果把设计模式放到实际的场景中,假设需要一个客户端来组装和调用这个设计模式,如下图:
不用策略模式的场景:
int main(int argc, char * argv[]) {
@autoreleasepool {
OYOContext *context = [[OYOContext alloc]init];
[context invoke:args[0]];
return UIApplicationMain(argc, argv, nil, NSStringFromClass([OYOAppDelegate class]));
}
}
@interface OYOContext: NSObject
- (void)invoke:(Type)type {
if([@"A" equals:type]) {
NSLog(@"-- Invoke策略A.");
}else if([@"B" equals:type]) {
NSLog(@"-- Invoke策略B.");
}
}
@end
边界不同,首先,使用策略模式使得架构的边界与使用if-else编码架构边界不同,策略模式将代码边界分成三部分:调用层,逻辑层和实现层。而if-else将代码分成两部分:调用层和逻辑层。
解决耦合,在if-else实现中,「逻辑流程」和「逻辑实现」是硬编码一起的,很明显的强耦合,而策略模式将「逻辑流程」和「逻辑实现」拆分两,对其进行解耦。「逻辑流程」和「逻辑实现」就可以独立进化,而不会相互影响。
独立进化,如果需要调整业务流程,对于策略模式来说,需要修改「实现层」;而对于if-else来说需要修改「逻辑层」。在软件开发中的单一职责原则,不仅仅针对类或方法,也适用于包、模块和子系统,这里if-else的实现方式违背了单一职责原则,使得逻辑层的职责不单一,当业务流程需要调整,需要调整逻辑层代码;当具体业务逻辑实现调整时,也需要调整逻辑层。而策略模式将业务流程和具体的业务逻辑拆分到不同的层内,使得每一层的职责相对单一,也可以独立进化。
对象聚集,在client中「调用层」使用了「实现层」的代码
可以看到client与StategyA和StategyB是强依赖,导致两个问题:
对象分散:如果StategyA或StategyB的实例化方法需要调整,所有实例化代码都需要进行调整。或者如果新增了StategyC,那么所有将Stategy设置到Context的相关代码都需要调整。
稳定层依赖不稳定层:一般情况下,「实现层」的变动频率较高;而对于「调用层」来说,调用流程确定后,基本就不会变化了。让一个基本不变的层去强依赖一个频繁变化的层,显然是有问题的。
对于「对象分散」的问题来说,创建型的设计模式基本能解决这个问题,对应到这里,可以直接使用工厂方法。使用了工厂方法后,构建代码被限制在了工厂方法内部,当策略对象的构造逻辑调整时,我们只需要调整对应的工厂方法就可以了。