-
unity 设计模式
2016-04-26 09:50:15导致在项目过程中,经常出现哪里需要实现什么功能,就随便添加脚本, ...因此,一个好的设计模式是多么的重要啊, 那么,我们在使用unity3d开发东西的时候,脚本架构到底应该如何来写? 呵呵… 其实,我也给不了设计模式六大原则(1):单一职责原则
说到单一职责原则,很多人都会不屑一顾。
因为它太简单了,稍有经验的程序员即使从来没有读过设计模式、从来没有听说过单一职责原则,在设计软件时也会自觉的遵守这一重要原则,因为这是常识。
在软件编程中,谁也不希望因为修改了一个功能导致其他的功能发生故障。
而避免出现这一问题的方法便是遵循单一职责原则。
虽然单一职责原则如此简单,并且被认为是常识,但是即便是经验丰富的程序员写出的程序,也会有违背这一原则的代码存在。
为什么会出现这种现象呢?因为有职责扩散。所谓职责扩散,就是因为某种原因,职责被分化成了更细的职责。
如:用一个类描述动物呼吸这个场景。1234567891011121314151617class
Animal
{
public
void
breathe(
string
animal)
{
Debug.Log(animal+
"呼吸空气"
);
}
}
public
class
Client
{
Animal animal =
new
Animal();
void
Start()
{
animal.breathe(
"牛"
);
animal.breathe(
"羊"
);
animal.breathe(
"猪"
);
}
}
运行结果:
牛呼吸空气
羊呼吸空气
猪呼吸空气
程序上线后,发现问题了,并不是所有的动物都呼吸空气的,比如鱼就是呼吸水的。
修改时如果遵循单一职责原则,需要将Animal类细分为陆生动物类Terrestrial,水生动物Aquatic,代码如下:1234567891011121314151617181920212223242526class
Terrestrial
{
public
void
breathe(String animal){
Debug.Log(animal +
"呼吸空气"
);
}
}
class
Aquatic
{
public
void
breathe(String animal){
Debug.Log(animal +
"呼吸水"
);
}
}
public
class
Client
{
public
static
void
main(String[] args)
{
Terrestrial terrestrial =
new
Terrestrial();
Debug.Log(terrestrial.breathe(
"牛"
));
Debug.Log(terrestrial.breathe(
"羊"
));
Debug.Log(terrestrial.breathe(
"猪"
));
Aquatic aquatic =
new
Aquatic();
Debug.Log( aquatic.breathe(
"鱼"
));
}
}
运行结果:
牛呼吸空气
羊呼吸空气
猪呼吸空气
鱼呼吸水
我们会发现如果这样修改花销是很大的,除了将原来的类分解之外,还需要修改客户端。
而直接修改类Animal来达成目的虽然违背了单一职责原则,但花销却小的多,代码如下:1234567891011121314151617181920212223242526class
Animal
{
public
void
breathe(String animal)
{
if
(
"鱼"
.equals(animal))
{
Debug.Log((animal+
"呼吸水"
));
}
else
{
Debug.Log((animal+
"呼吸空气"
));
}
}
}
public
class
Client
{
public
static
void
main(String[] args)
{
Animal animal =
new
Animal();
Debug.Log(animal.breathe(
"牛"
));
Debug.Log(animal.breathe(
"羊"
));
Debug.Log(animal.breathe(
"猪"
));
Debug.Log(animal.breathe(
"鱼"
));
}
}
运行结果:
牛呼吸空气
羊呼吸空气
猪呼吸空气
鱼呼吸水
我们会发现如果这样修改花销是很大的,除了将原来的类分解之外,还需要修改客户端。
而直接修改类Animal来达成目的虽然违背了单一职责原则,但花销却小的多,代码如下:1234567891011121314151617181920212223242526class
Animal
{
public
void
breathe(String animal)
{
if
(
"鱼"
.equals(animal))
{
Debug.Log((animal+
"呼吸水"
));
}
else
{
Debug.Log((animal+
"呼吸空气"
));
}
}
}
public
class
Client
{
public
static
void
main(String[] args)
{
Animal animal =
new
Animal();
Debug.Log(animal.breathe(
"牛"
));
Debug.Log(animal.breathe(
"羊"
));
Debug.Log(animal.breathe(
"猪"
));
Debug.Log(animal.breathe(
"鱼"
));
}
}
可以看到,这种修改方式要简单的多。
但是却存在着隐患:有一天需要将鱼分为呼吸淡水的鱼和呼吸海水的鱼,
则又需要修改Animal类的breathe方法,而对原有代码的修改会对调用“猪”“牛”“羊”等相关功能带来风险,
也许某一天你会发现程序运行的结果变为“牛呼吸水”了。
这种修改方式直接在代码级别上违背了单一职责原则,虽然修改起来最简单,但隐患却是最大的。
还有一种修改方式:12345678910111213141516171819202122class
Animal
{
public
void
breathe(String animal){
Debug.Log(animal+
"呼吸空气"
);
}
public
void
breathe2(String animal){
Debug.Log(animal+
"呼吸水"
);
}
}
public
class
Client
{
public
static
void
main(String[] args)
{
Animal animal =
new
Animal();
Debug.Log(animal.breathe(
"牛"
));
Debug.Log(animal.breathe(
"羊"
));
Debug.Log(animal.breathe(
"猪"
));
Debug.Log(animal.breathe2(
"鱼"
));
}
}
可以看到,这种修改方式没有改动原来的方法,而是在类中新加了一个方法,这样虽然也违背了单一职责原则,
但在方法级别上却是符合单一职责原则的,因为它并没有动原来方法的代码。这三种方式各有优缺点,
那么在实际编程中,采用哪一中呢?
其实这真的比较难说,需要根据实际情况来确定。
我的原则是:只有逻辑足够简单,才可以在代码级别上违反单一职责原则;只有类中方法数量足够少,才可以在方法级别上违反单一职责原则。遵循单一职责原的优点有:
- 可以降低类的复杂度,一个类只负责一项职责,其逻辑肯定要比负责多项职责简单的多;
- 提高类的可读性,提高系统的可维护性;
- 变更引起的风险降低,变更是必然的,如果单一职责原则遵守的好,当修改一个功能时,可以显著降低对其他功能的影响。
需要说明的一点是单一职责原则不只是面向对象编程思想所特有的,只要是模块化的程序设计,都适用单一职责原则。
设计模式六大原则(2):里氏替换原则
肯定有不少人跟我刚看到这项原则的时候一样,对这个原则的名字充满疑惑。
其实原因就是这项原则最早是在1988年,由麻省理工学院的一位姓里的女士(Barbara Liskov)提出来的。
简单来说的话,就是当我们使用继承时,遵循里氏替换原则。
注:类B继承类A时,除添加新的方法完成新增功外,尽量不要重写父类A的方法,也尽量不要重载父类A的方法。
继承包含这样一层含义:父类中凡是已经实现好的方法(相对于抽象方法而言),实际上是在设定一系列的规范和契约,
虽然它不强制要求所有的子类必须遵从这些契约,但是如果子类对这些非抽象方法任意修改,
就会对整个继承体系造成破坏。而里氏替换原则就是表达了这一层含义。
继承作为面向对象三大特性之一,在给程序设计带来巨大便利的同时,也带来了弊端。
比如使用继承会给程序带来侵入性,程序的可移植性降低,增加了对象间的耦合性,如果一个类被其他的类所继承,
则当这个类需要修改时,必须考虑到所有的子类,并且父类修改后,
所有涉及到子类的功能都有可能会产生故障。
那就让我们一起看看继承的风险,如下:1234567891011121314151617class
A
{
public
int
func1(
int
a,
int
b)
{
return
a - b;
}
}
public
class
Client
{
void
Start()
{
A a =
new
A();
Debug.Log((
"100-50="
+a.func1(100, 50));
Debug.Log((
"100-80="
+a.func1(100, 80)));
}
}
运行结果:
100-50=50
100-80=20
后来,我们需要增加一个新的功能:完成两数相加,然后再与100求和,由类B来负责。
即类B需要完成两个功能:
两数相减。
两数相加,然后再加100。
由于类A已经实现了第一个功能,所以类B继承类A后,只需要再完成第二个功能就可以了,代码如下123456789101112131415161718class
B:A{
public
int
func1(
int
a,
int
b){
return
a+b;
}
public
int
func2(
int
a,
int
b){
return
func1(a,b)+100;
}
}
public
class
Client{
void
Start()
{
B b =
new
B();
Debug.Log(
"100-50="
+b.func1(100, 50));
Debug.Log(
"100-80="
+b.func1(100, 80));
Debug.Log(
"100+20+100="
+b.func2(100, 20));
}
类B完成后,运行结果:
100-50=150
100-80=180
100+20+100=220
我们发现原本运行正常的相减功能发生了错误。
原因就是类B在给方法起名时无意中重写了父类的方法,造成所有运行相减功能的代码全部调用了类B重写后的方法,造成原本运行正常的功能出现了错误。
在本例中,引用基类A完成的功能,换成子类B之后,发生了异常。
在实际编程中,我们常常会通过重写父类的方法来完成新的功能,这样写起来虽然简单,
但是整个继承体系的可复用性会比较差,特别是运用多态比较频繁时,程序运行出错的几率非常大。
如果非要重写父类的方法,比较通用的做法是:原来的父类和子类都继承一个更通俗的基类,原有的继承关系去掉,采用依赖、聚合,组合等关系代替。
里氏替换原则通俗的来讲就是:子类可以扩展父类的功能,但不能改变父类原有的功能。它包含以下4层含义:
1.子类可以实现父类的抽象方法,但不能覆盖父类的非抽象方法。
2.子类中可以增加自己特有的方法。
3.当子类的方法重载父类的方法时,方法的前置条件(即方法的形参)要比父类方法的输入参数更宽松。
4.当子类的方法实现父类的抽象方法时,方法的后置条件(即方法的返回值)要比父类更严格。
看上去很不可思议,因为我们会发现在自己编程中常常会违反里氏替换原则,程序照样跑的好好的。
所以大家都会产生这样的疑问,假如我非要不遵循里氏替换原则会有什么后果?
后果就是:你写的代码出问题的几率将会大大增加。
设计模式六大原则(3):依赖倒置原则
定义:高层模块不应该依赖低层模块,二者都应该依赖其抽象;抽象不应该依赖细节;细节应该依赖抽象。以抽象为基础搭建起来的架构比以细节为基础搭建起来的架构要稳定的多。
抽象指的是接口或者抽象类,细节就是具体的实现类,使用接口或者抽象类的目的是制定好规范和契约,而不去涉及任何具体的操作,把展现细节的任务交给他们的实现类去完成。依赖倒置原则的核心思想是面向接口编程,我们依旧用一个例子来说明面向接口编程比相对于面向实现编程好在什么地方。
场景是这样的,母亲给孩子讲故事,只要给她一本书,她就可以照着书给孩子讲故事了。代码如下:1234567891011121314151617181920212223242526272829303132333435class
Book{
public
String getContent(){
return
"很久很久以前有一个阿拉伯的故事……"
;
}
}
class
Mother{
public
void
narrate(Book book){
Debug.Log(
"妈妈开始讲故事"
);
Debug.Log(book.getContent());
}
}
public
class
Client{
void
Start()
{
Mother mother =
new
Mother();
Debug.Log(mother.narrate(
new
Book()));
}
}
运行结果:
妈妈开始讲故事
很久很久以前有一个阿拉伯的故事……
运行良好,假如有一天,需求变成这样:不是给书而是给一份报纸,让这位母亲讲一下报纸上的故事,报纸的代码如下:123456789class
Newspaper{
public
String getContent(){
return
"林书豪38+7领导尼克斯击败湖人……"
;
}
}
这位母亲却办不到,因为她居然不会读报纸上的故事,这太荒唐了,只是将书换成报纸,居然必须要修改Mother才能读。
假如以后需求换成杂志呢?换成网页呢?
还要不断地修改Mother,这显然不是好的设计。
原因就是Mother与Book之间的耦合性太高了,必须降低他们之间的耦合度才行。
我们引入一个抽象的接口IReader。
读物,只要是带字的都属于读物:12345interface
IReader{
public
String getContent();
}
Mother类与接口IReader发生依赖关系,而Book和Newspaper都属于读物的范畴,
他们各自都去实现IReader接口,这样就符合依赖倒置原则了,代码修改为:12345678910111213141516171819202122232425class
Newspaper : IReader {
public
String getContent(){
return
"林书豪17+9助尼克斯击败老鹰……"
;
}
}
class
Book : IReader{
public
String getContent(){
return
"很久很久以前有一个阿拉伯的故事……"
;
}
}
class
Mother{
public
void
narrate(IReader reader){
Debug.Log(
"妈妈开始讲故事"
);
Debug.Log(reader.getContent());
}
}
public
class
Client{
public
static
void
main(String[] args){
Mother mother =
new
Mother();
Debug.Log(mother.narrate(
new
Book()));
Debug.Log(mother.narrate(
new
Newspaper()));
}
}</pre>
运行结果:
妈妈开始讲故事
很久很久以前有一个阿拉伯的故事……
妈妈开始讲故事
林书豪17+9助尼克斯击败老鹰……
这样修改后,无论以后怎样扩展Client类,都不需要再修改Mother类了。
这只是一个简单的例子,实际情况中,代表高层模块的Mother类将负责完成主要的业务逻辑,一旦需要对它进行修改,引入错误的风险极大。
所以遵循依赖倒置原则可以降低类之间的耦合性,提高系统的稳定性,降低修改程序造成的风险。采用依赖倒置原则给多人并行开发带来了极大的便利,
比如上例中,原本Mother类与Book类直接耦合时,Mother类必须等Book类编码完成后才可以进行编码,因为Mother类依赖于Book类。
修改后的程序则可以同时开工,互不影响,因为Mother与Book类一点关系也没有。
参与协作开发的人越多、项目越庞大,采用依赖导致原则的意义就越重大。
现在很流行的TDD开发模式就是依赖倒置原则最成功的应用。
在实际编程中,我们一般需要做到如下3点:
1.低层模块尽量都要有抽象类或接口,或者两者都有。
2.变量的声明类型尽量是抽象类或接口。使用继承时遵循里氏替换原则。
3.依赖倒置原则的核心就是要我们面向接口编程,理解了面向接口编程,也就理解了依赖倒置。设计模式六大原则(4):接口隔离原则
定义:客户端不应该依赖它不需要的接口;一个类对另一个类的依赖应该建立在最小的接口上。
将臃肿的接口I拆分为独立的几个接口,类A和类C分别与他们需要的接口建立依赖关系。也就是采用接口隔离原则。
举例来说明接口隔离原则:(图1 未遵循接口隔离原则的设计)
这个图的意思是:类A依赖接口I中的方法1、方法2、方法3,类B是对类A依赖的实现。
类C依赖接口I中的方法1、方法4、方法5,类D是对类C依赖的实现。
对于类B和类D来说,虽然他们都存在着用不到的方法(也就是图中红色字体标记的方法),但由于实现了接口I,所以也必须要实现这些用不到的方法。
对类图不熟悉的可以参照程序代码来理解,代码如下:123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141interface
I {
public
void
method1();
public
void
method2();
public
void
method3();
public
void
method4();
public
void
method5();
}
class
A{
public
void
depend1(I i){
i.method1();
}
public
void
depend2(I i){
i.method2();
}
public
void
depend3(I i){
i.method3();
}
}
class
B : I{
public
void
method1() {
Debug.Log(
"类B实现接口I的方法1"
);
}
public
void
method2() {
Debug.Log(
"类B实现接口I的方法2"
);
}
public
void
method3() {
Debug.Log(
"类B实现接口I的方法3"
);
}
//对于类B来说,method4和method5不是必需的,但是由于接口A中有这两个方法,
//所以在实现过程中即使这两个方法的方法体为空,也要将这两个没有作用的方法进行实现。
public
void
method4() {}
public
void
method5() {}
}
class
C{
public
void
depend1(I i){
i.method1();
}
public
void
depend2(I i){
i.method4();
}
public
void
depend3(I i){
i.method5();
}
}
class
D : I{
public
void
method1() {
Debug.Log(
"类D实现接口I的方法1"
);
}
//对于类D来说,method2和method3不是必需的,但是由于接口A中有这两个方法,
//所以在实现过程中即使这两个方法的方法体为空,也要将这两个没有作用的方法进行实现。
public
void
method2() {}
public
void
method3() {}
public
void
method4() {
Debug.Log(
"类D实现接口I的方法4"
);
}
public
void
method5() {
Debug.Log(
"类D实现接口I的方法5"
);
}
}
public
class
Client{
void
Start(){
A a =
new
A();
Debug.Log(a.depend1(
new
B()));
Debug.Log(a.depend2(
new
B()));
Debug.Log(a.depend3(
new
B()));
C c =
new
C();
Debug.Log(c.depend1(
new
D()));
Debug.Log(c.depend2(
new
D()));
Debug.Log(c.depend3(
new
D()));
}
}
可以看到,如果接口过于臃肿,只要接口中出现的方法,不管对依赖于它的类有没有用处,实现类中都必须去实现这些方法,这显然不是好的设计。
如果将这个设计修改为符合接口隔离原则,就必须对接口I进行拆分。
在这里我们将原有的接口I拆分为三个接口,拆分后的设计如图2所示:(图2 遵循接口隔离原则的设计)
照例贴出程序的代码,供不熟悉类图的朋友参考:123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109interface
I1 {
public
void
method1();
}
interface
I2 {
public
void
method2();
public
void
method3();
}
interface
I3 {
public
void
method4();
public
void
method5();
}
class
A{
public
void
depend1(I1 i){
i.method1();
}
public
void
depend2(I2 i){
i.method2();
}
public
void
depend3(I2 i){
i.method3();
}
}
class
B : I1, I2{
public
void
method1() {
Debug.Log(
"类B实现接口I1的方法1"
);
}
public
void
method2() {
Debug.Log(
"类B实现接口I2的方法2"
);
}
public
void
method3() {
Debug.Log(
"类B实现接口I2的方法3"
);
}
}
class
C{
public
void
depend1(I1 i){
i.method1();
}
public
void
depend2(I3 i){
i.method4();
}
public
void
depend3(I3 i){
i.method5();
}
}
class
D : I1, I3{
public
void
method1() {
Debug.Log(