Unity3d 一个优秀的程序必备的几种设计模式
原作者:随风去旅行 原文链接:http://taik.io/98
Unity 编程属于脚本化编程,脚本缺乏具体的概念和架构。在项目开发过程中,常常出现为实现某个功能就随意添加脚本的情况,导致代码管理混乱。甚至开发者自己在闲置一段时间后,再想查找某个功能的实现代码,都要在视图中翻找半天。由此可见,一个好的设计模式在 Unity 开发中至关重要。
然而,脚本架构的编写并没有固定的答案,因为每个人的开发习惯和每个团队的开发模式都不尽相同。本文主要对几种设计模式进行总结,参考书籍有《设计模式》《设计模式之禅》《大话设计模式》以及网上的一些零散文章,同时融入了作者本人的经验和感悟。希望通过本文,一方面能系统地整理相关知识,另一方面能与广大网友分享。如果大家有不同的理解,欢迎留言共同探讨。
设计模式六大原则(1):单一职责原则
很多人对单一职责原则不屑一顾,认为它过于简单。稍有经验的程序员即使未专门学习设计模式,在设计软件时也会自觉遵守这一原则,因为这是编程的基本常识。在软件编程中,谁都不希望修改一个功能导致其他功能出现故障,而遵循单一职责原则就能有效避免此类问题。
但即便如此,经验丰富的程序员编写的程序也可能存在违背该原则的代码。这是因为存在职责扩散的情况,即职责因某种原因被分化成更细的职责。
例如,用一个类描述动物呼吸的场景:
class 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,代码如下:
class 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 类虽然违背单一职责原则,但开销较小,代码如下:
class 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 方法,这可能会影响到调用“猪”“牛”“羊”等相关功能的代码,导致程序运行结果出错。
还有一种修改方式:
class 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):里氏替换原则
许多人初次看到里氏替换原则时,会对其名称感到疑惑。该原则由麻省理工学院的 Barbara Liskov 女士在 1988 年提出,简单来说,就是在使用继承时要遵循此原则。具体而言,当类 B 继承类 A 时,除添加新方法完成新增功能外,应尽量避免重写和重载父类 A 的方法。
继承在给程序设计带来便利的同时,也存在一些弊端。例如,使用继承会使程序具有侵入性,降低程序的可移植性,增加对象间的耦合性。当一个类被其他类继承时,修改该类时需考虑所有子类,且父类修改后,涉及子类的功能可能会出现故障。
以下是一个继承风险的示例:
class 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 继承类 A 后,代码如下:
class 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));
}
}
运行结果:
100 - 50 = 150
100 - 80 = 180
100 + 20 + 100 = 220
可以看到,原本正常的相减功能出现了错误。原因是类 B 重写了父类的方法,导致所有调用相减功能的代码都调用了类 B 重写后的方法。在实际编程中,重写父类方法虽能实现新功能,但会降低继承体系的可复用性,增加程序出错的几率。
若非要重写父类方法,较通用的做法是:让原来的父类和子类都继承一个更通用的基类,去掉原有的继承关系,采用依赖、聚合、组合等关系代替。
里氏替换原则通俗来讲就是:子类可以扩展父类的功能,但不能改变父类原有的功能,它包含以下 4 层含义:
- 子类可以实现父类的抽象方法,但不能覆盖父类的非抽象方法。
- 子类中可以增加自己特有的方法。
- 当子类的方法重载父类的方法时,方法的前置条件(即方法的形参)要比父类方法的输入参数更宽松。
- 当子类的方法实现父类的抽象方法时,方法的后置条件(即方法的返回值)要比父类更严格。
在实际编程中,我们常常会违反里氏替换原则,但程序仍能正常运行。然而,不遵循该原则会大大增加代码出错的几率。
设计模式六大原则(3):依赖倒置原则
依赖倒置原则的定义为:高层模块不应该依赖低层模块,二者都应该依赖其抽象;抽象不应该依赖细节;细节应该依赖抽象。
以抽象为基础搭建的架构比以细节为基础搭建的架构更稳定。抽象指的是接口或抽象类,细节是具体的实现类。使用接口或抽象类的目的是制定规范和契约,将具体操作交给实现类完成。
依赖倒置原则的核心思想是面向接口编程,下面通过一个例子说明其优势。场景是母亲给孩子讲故事,只要给她一本书,她就能照着书讲故事,代码如下:
class 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()));
}
}
运行结果:
妈妈开始讲故事
很久很久以前有一个阿拉伯的故事……
若需求变为让母亲读报纸上的故事,报纸类代码如下:
class Newspaper
{
public String getContent()
{
return "林书豪 38 + 7 领导尼克斯击败湖人……";
}
}
此时母亲类无法读报纸上的故事,因为 Mother 类与 Book 类的耦合性太高。为降低耦合度,引入抽象接口 IReader:
interface IReader
{
public String getContent();
}
让 Mother 类与 IReader 接口建立依赖关系,Book 和 Newspaper 类实现 IReader 接口,修改后的代码如下:
class 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()));
}
}
运行结果:
妈妈开始讲故事
很久很久以前有一个阿拉伯的故事……
妈妈开始讲故事
林书豪 17 + 9 助尼克斯击败老鹰……
修改后,无论如何扩展 Client 类,都无需修改 Mother 类。在实际情况中,代表高层模块的 Mother 类负责主要业务逻辑,修改它可能会引入错误。因此,遵循依赖倒置原则可以降低类之间的耦合性,提高系统的稳定性,降低修改程序的风险。
此外,依赖倒置原则为多人并行开发带来了便利。例如,原本 Mother 类与 Book 类直接耦合时,Mother 类需等 Book 类编码完成后才能编码;修改后,二者可同时开发,互不影响。参与协作开发的人越多、项目越庞大,采用依赖倒置原则的意义就越重大。现在流行的 TDD 开发模式就是该原则的成功应用。
在实际编程中,一般需做到以下 3 点:
- 低层模块尽量有抽象类或接口,或两者都有。
- 变量的声明类型尽量是抽象类或接口,使用继承时遵循里氏替换原则。
- 理解面向接口编程,就能理解依赖倒置原则。
设计模式六大原则(4):接口隔离原则
接口隔离原则的定义为:客户端不应该依赖它不需要的接口;一个类对另一个类的依赖应该建立在最小的接口上。
下面通过一个例子说明该原则。假设有一个臃肿的接口 I,类 A 依赖接口 I 中的方法 1、方法 2、方法 3,类 B 实现类 A 的依赖;类 C 依赖接口 I 中的方法 1、方法 4、方法 5,类 D 实现类 C 的依赖。代码如下:
interface 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");
}
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");
}
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 拆分为三个接口 I1、I2、I3,代码如下:
interface 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("类 D 实现接口 I1 的方法 1");
}
public void method4()
{
Debug.Log("类 D 实现接口 I3 的方法 4");
}
public void method5()
{
Debug.Log("类 D 实现接口 I3 的方法 5");
}
}
接口隔离原则的含义是:建立单一接口,避免建立庞大臃肿的接口,尽量细化接口,减少接口中的方法。也就是说,要为各个类建立专用接口,而非一个庞大的接口供所有依赖它的类调用。依赖几个专用接口比依赖一个综合接口更灵活,通过分散定义多个接口,可以预防外来变更的扩散,提高系统的灵活性和可维护性。
接口隔离原则与单一职责原则有所不同:
- 单一职责原则注重职责,而接口隔离原则注重对接口依赖的隔离。
- 单一职责原则主要约束类,其次是接口和方法,针对程序的实现和细节;接口隔离原则主要约束接口,针对抽象和程序整体框架的构建。
采用接口隔离原则对接口进行约束时,需注意以下几点:
- 接口尽量小,但要有限度。过度细化接口会导致接口数量过多,使设计复杂化,因此要适度。
- 为依赖接口的类定制服务,只暴露调用类需要的方法,隐藏不需要的方法,建立最小的依赖关系。
- 提高内聚,减少对外交互,用最少的方法完成最多的事情。
运用接口隔离原则时要适度,设计接口时需仔细思考和筹划,才能准确实践该原则。
设计模式六大原则(5):迪米特法则
迪米特法则的定义为:一个对象应该对其他对象保持最少的了解。类与类之间的关系越密切,耦合度越大,一个类的改变对另一个类的影响也越大,因此要尽量降低类与类之间的耦合。
软件编程的总原则是低耦合、高内聚,无论是面向过程编程还是面向对象编程,降低模块间的耦合能提高代码的复用率。迪米特法则就是为了实现低耦合而提出的,它又叫最少知道原则,由美国 Northeastern University 的 Ian Holland 在 1987 年提出。
通俗来讲,一个类对其依赖的类知道得越少越好。被依赖的类应将逻辑封装在内部,除提供 public 方法外,不对外泄漏信息。迪米特法则还有一个更简单的定义:只与直接的朋友通信。直接的朋友是指出现成员变量、方法参数、方法返回值中的类,而出现在局部变量中的类不是直接的朋友,陌生的类最好不要作为局部变量出现在类的内部。
下面通过一个例子说明。有一个集团公司,下属有分公司和直属部门,要求打印出所有下属单位的员工 ID。违反迪米特法则的设计代码如下:
// 总公司员工
class Employee
{
private String id;
public void setId(String id)
{
this.id = id;
}
public String getId()
{
return id;
}
}
// 分公司员工
class SubEmployee
{
private String id;
public void setId(String id)
{
this.id = id;
}
public String getId()
{
return id;
}
}
class SubCompanyManager
{
public List<SubEmployee> getAllEmployee()
{
List<SubEmployee> list = new List<SubEmployee>();
for (int i = 0; i < 100; i++)
{
SubEmployee emp = new SubEmployee();
emp.setId("分公司" + i);
list.Add(emp);
}
return list;
}
}
class CompanyManager
{
public List<Employee> getAllEmployee()
{
List<Employee> list = new List<Employee>();
for (int i = 0; i < 30; i++)
{
Employee emp = new Employee();
emp.setId("总公司" + i);
list.Add(emp);
}
return list;
}
public void printAllEmployee(SubCompanyManager sub)
{
List<SubEmployee> list1 = sub.getAllEmployee();
foreach (SubEmployee e in list1)
{
Debug.Log(e.getId());
}
List<Employee> list2 = this.getAllEmployee();
foreach (Employee e in list2)
{
Debug.Log(e.getId());
}
}
}
public class Client
{
void Start()
{
CompanyManager e = new CompanyManager();
Debug.Log(e.printAllEmployee(new SubCompanyManager()));
}
}
该设计的问题在于 CompanyManager 类与 SubEmployee 类不是直接朋友,增加了不必要的耦合。按照迪米特法则修改后的代码如下:
class SubCompanyManager
{
public List<SubEmployee> getAllEmployee()
{
List<SubEmployee> list = new List<SubEmployee>();
for (int i = 0; i < 100; i++)
{
SubEmployee emp = new SubEmployee();
emp.setId("分公司" + i);
list.Add(emp);
}
return list;
}
public void printEmployee()
{
List<SubEmployee> list = this.getAllEmployee();
foreach (SubEmployee e in list)
{
Debug.Log(e.getId());
}
}
}
class CompanyManager
{
public List<Employee> getAllEmployee()
{
List<Employee> list = new List<Employee>();
for (int i = 0; i < 30; i++)
{
Employee emp = new Employee();
emp.setId("总公司" + i);
list.Add(emp);
}
return list;
}
public void printAllEmployee(SubCompanyManager sub)
{
sub.printEmployee();
List<Employee> list2 = this.getAllEmployee();
foreach (Employee e in list2)
{
Debug.Log(e.getId());
}
}
}
修改后,为分公司增加了打印人员 ID 的方法,总公司直接调用该方法,避免了与分公司员工的耦合。
迪米特法则的初衷是降低类之间的耦合,但过度使用会产生大量中介和传递类,增加系统复杂度。因此,在采用该法则时要权衡利弊,做到结构清晰、高内聚低耦合。
设计模式六大原则(6):开闭原则
开闭原则的定义为:一个软件实体(如类、模块和函数)应该对扩展开放,对修改关闭。
在软件的生命周期中,因变化、升级和维护等原因修改原有代码时,可能会引入错误,甚至需要重构整个功能并重新测试原有代码。因此,软件需要变化时,应尽量通过扩展软件实体的行为来实现,而非修改已有代码。
开闭原则是面向对象设计中最基础的原则,它指导我们构建稳定灵活的系统。但该原则的定义较为模糊,只告诉我们要对扩展开放、对修改关闭,却未明确说明如何实现。
实际上,遵循前面 5 大原则以及使用 23 种设计模式的目的就是遵循开闭原则。开闭原则更像是前面五项原则遵守程度的“平均得分”,前面五项原则遵守得好,软件设计就更符合开闭原则。
开闭原则的核心是用抽象构建框架,用实现扩展细节。抽象具有良好的灵活性和适应性,合理的抽象能保持软件架构的稳定。软件中易变的细节可通过从抽象派生的实现类进行扩展,当软件需要变化时,只需派生一个新的实现类即可。
在遵守这六个原则时,并非是绝对的遵守,而是有程度之分。设计模式的六个原则需要根据实际情况灵活运用,只要遵守程度在合理范围内,就是良好的设计。如果大家对这六项原则有不同的理解,欢迎指正。