使用桥接模式的意义在哪里?
现在我们思考一下,我们在碰撞检测这一块应用桥接模式,创建了许多新类,除了把项目变复杂了,到底有什么积极作用?我们将碰撞测试元素拆分为两个抽象对象(HitObjectRectangle和HitedObjectRectangle)的意义在哪里?
看一张结构图,如图11-4所示:
图11-4待扩展的桥接模式示意图
HitObjectRectangle代表碰撞对象的碰撞检测数据对象,HitedObjectRectangle代表被碰撞对象的碰撞检测数据对象,后者有三个具体实现的子类:ScreenRectangle、LeftPanelRectangle和RightPanelRectangle,这三个子类代表三类被撞击的类型。如果游戏中出现一个四周需要被碰撞检测的对象,它的检测数据对象可以继承于ScreenRectangle;如果出现一个右侧需要碰撞检测的对象,它的检测数据对象可以继承于RightPanelRectangle,以此类推左侧出现的,它的数据对象可以继承于LeftPanelRectangle。而如果出现一个撞击对象,它的检测数据对象可以继承于BallRectangle。
目前我们这个小游戏项目太过简单,不足够显示桥接模式的作用。接下来我们做一个人为拓展,新增一个红色立方体代替小球,代码如代码清单11-14所示:
代码清单11-14创建立方体模块
1. *// JS* *:* *src\views\cube.js* 1. import { Ball } from "ball.js" 2. import CubeRectangle from "hitTest/cube_rectangle.js" 3. 4. */** 红色立方块 */* 5. **class** Cube **extends** Ball { 6. constructor() { 7. **super**() 8. **this**.rectangle = **new** CubeRectangle() 9. } 10. 11. */** 渲染 */* 12. render(context) { 13. context.fillStyle = "red" 14. context.beginPath() 15. context.rect(**this**.rectangle.left, **this**.rectangle.top, **this**.rectangle.width, **this**.rectangle.height) 16. context.fill() 17. } 18. } 19. 20. export default **new** Cube()
Cube类的代码与Ball是类似的,只有render代码略有不同,让它继承于Ball是最简单的实现方法。第9行,rectangle设置为CubeRectangle的实例,这个类尚不存在,稍后我们创建,它是BallRectangle的子类。
在cube.js文件中引入的Ball(第2行)现在还没有导出,我们需要修改一下ball.js文件,如下所示:
1.// JS:src/views/ball.js 2.... 3. 4./** 小球 */ 5.// class Ball { 6.export class Ball { 7. ... 8.} 9....
第6行,使用export关键字添加了常规导出,其它不会修改。
现在看一下新增的CubeRectangle类,如下所示:
1.// JS:src\views\hitTest\ball_rectangle.js 2.import BallRectangle from "ball_rectangle.js" 3. 4./** 碰撞对象的具体实现部分,立方体的大小及运动数据对象 */ 5.class CubeRectangle extends BallRectangle { } 6. export default CubeRectangle
CubeRectangle是立方块的检测数据对象。CubeRectangle可以继承于HitObjectRectangle实现,但因为立方体与小球特征很像,所以让它继承于BallRectangle更容易实现。事实上它像一个“富二代”,只需要继承(第5行),什么也不用做。
接下来开始使用立方块。为了使测试代码简单,我们将game.js文件中的页面创建代码修改一下,如代码清单11-15所示:
代码清单11-15使用页面工厂创建页面对象
1.// JS:disc\第11章\11.1\11.1.2\game.js 2.... 3.// import PageBuildDirector from "src/views/page_build_director.js" // 引入页面建造指挥者 4.import PageFactory from "src/views/page_factory.js" // 引入页面工厂 5. 6./** 游戏对象 */ 7.class Game extends EventDispatcher { 8. ... 9. 10. /** 游戏换页 */ 11. turnToPage(pageName) { 12. ... 13. // this.#currentPage = PageBuildDirector.buildPage(pageName, { game: this, context: this.#context }) 14. this.#currentPage = PageFactory.createPage(pageName, this, this.#context) 15. ... 16. } 17. 18. ... 19.} 20....
只有两处改动,第4行和第14行,继承使用PageBuildDirector不利于代码测试,使用PageFactory代码会更简单。这一步改动与本小节的桥接模式没有直接关系。
最后修改game_index_page.js文件,使用立方块,代码如下:
1.// JS:src\views\game_index_page.js 2.... 3.// import ball from "ball.js" // 引入小球单例 4.import ball from "cube.js" // 引入立方块实例 5....
只有第4行引入地址变了,其他不会改变。
代码扩展完了,重新编译测试,游戏的运行效果如图11-5所示:
图11-5小球变成了红色方块
改动后,白色的小球变成了红色的方块。看到了吗?项目的可扩展性非常好,在应用了桥接模式以后,当我们把小球扩展为方块时,只需要少量的变动就可以做到了。
现在,将CubeRectangle纳入结构图,如图11-6所示:
图11-6扩展后的桥接模式示意图
第四层添加了一个CubeRectangle,我们的HitObjectRectangle修改了吗?没有。虽然在HitObjectRectangle的hitTest方法中,我们使用instanceof进行了类型判断,如下所示:
1. */*** *与被撞对象的碰撞检测* **/* 1. hitTest(hitedObject) { 2. **let** res = 0 3. **if** (hitedObject **instanceof** LeftPanelRectangle) { 4. ... 5. } **else** **if** (hitedObject **instanceof** RightPanelRectangle) { 6. ... 7. } **else** **if** (hitedObject **instanceof** ScreenRectangle) { 8. ... 9. } 10. **return** res 11. } 复制代码
但判断的是基本类型,在第四层添加子类型不会影响代码的执行。我们添加的CubeRectangle继承于BallRectangle,属于HitObjectRectangle一支,如果添加一个新类继承于HitedObjectRectangle的子类(即ScreenRectangle、LeftPanelRectangle和RightPanelRectangle),结果是一样的,代码不用修改仍然有效。HitObjectRectangle和HitedObjectRectangle作为抽象部分,是我们实现的桥接模式中的重要组成部分,它们帮助具体实现部分屏蔽了变化的复杂性。
注意:如果我们添加了新的碰撞检测类型,不同于ScreenRectangle、LeftPanelRectangle和RightPanelRectangle中的任何一个,代码应该如何拓展?这时候就需要修改HitObjectRectangle类的hitTest方法啦,需要添加else if分支。
注:桥接模式完成后的最终源码目录见:disc/第11章/11.1/11.1.2,读者可以自行下载随书源码以对照查看。
总结一下桥接模式的用法
最后总结一下,在桥接模式中,是两部分对象分别实现抽象部分与具体部分,然后这两部分对象相对独立自由的变化。在本小节示例中,我们主要应用桥接模式实现了碰撞检测。小球和立方块是撞击对象,左右挡板及屏幕是被撞击对象,通过相同的方式定义它们的大小、位置数据,然后以一种相对优雅的方式实现了碰撞检测。
对比重构前后的代码,我们不难发现,在应用桥接模式之前,我们的碰撞检测代码是与GameIndexPage、Ball、LeftPanel和RightPanel耦合在一起的,并且不方便进行新的碰撞对象扩展;在重构以后,我们碰撞检测的代码变成了只有top、bottom、left和right属性数值的对比,变得非常清晰了。
第11章所有面向对象重构中使用的设计模式,桥接模式是最复杂的一个,它所用的笔墨最多。在大型跨平台GUI软件中,桥接模式基本是必出现的。
另一个访问者模式的应用
在应用了桥接模式以后,你是不是对设计模式的作用已经了然于胸了呢?有意识地运用设计模式,是不是可以更大限度地应对需求变化的复杂性,从而保证版本迭代的稳定与快捷?
下面我们再看一个访问者模式的应用。以下内容属于《微信小游戏开发》前端篇第11章第32课,我们尝试在随书源码第11章/11.1/11.1.3的基础之上,尝试应用访问者模式,目的仍然是有针对性地锤炼学习者渐进性模块化重构和面向对象重构思维的能力。
当前的小游戏项目分析
目前我们在实现碰撞检测功能的时候,在HitObjectRectangle类中有一个很重要的方法,如代码清单11-20所示:
代码清单11-20撞击对象的hitTest方法
1.// JS:src\views\hitTest\hit_object_rectangle.js 2.... 3. 4./** 碰撞对象的抽象部分,球与方块的注册点在中心,不在左上角 */ 5.class HitObjectRectangle extends Rectangle { 6. ... 7. 8. /** 与被撞对象的碰撞检测 */ 9. hitTest(hitedObject) { 10. let res = 0 11. if (hitedObject instanceof LeftPanelRectangle) { // 碰撞到左挡板返回1 12. ... 13. } else if (hitedObject instanceof RightPanelRectangle) { // 碰撞到右挡板返回2 14. ... 15. } else if (hitedObject instanceof ScreenRectangle) { 16. ... 17. } 18. return res 19. } 20.} 21. 22.export default HitObjectRectangle 复制代码
正是hitTest这个方法实现了碰撞检测,它根据不同的被撞击的对象,分别做了不同的边界检测。
但是这个方法它有坏味,它内部有if else,并且这个if else是会随着被检测对象的类型增长而增加的。
怎么优化它呢?有没有优化办法?
我们可以使用访问者模式重构。在访问者模式中,可以根据不同的对象分别作不同的处理,这里多个被撞击的对象,恰好是定义中所说的不同的对象。
什么是访问者模式?
在应用访问者模式之前,老规矩,先来简单了解一下它的概念吧。
访问者模式是一种行为设计模式, 它能将算法与算法所作用的对象隔离开来。
换言之,访问者模式根据访问者不同,展示不同的行为或做不同的处理。使用访问者模式,一般意味着调用反转,本来是A调用B,结果该调用最终反赤来是通过B调用A完成的。
在这个模式中一般有两个方面,我们可以拿软件外包市场中的甲方乙方类比一下,甲方是发包方,乙方是接包方,本来需要甲方到乙方公司系统阐明需求,由乙方根据不同需求安排不同的项目进行开发;现在反过来了,甲方不动窝了,由乙方分别派不同的开发小组,到甲方公司内部,现场与甲方进行对接。
访问者模式的实现与应用
下面开始访问者模式的实践。我们先给LeftPanelRectangle、RightPanelRectangle和ScreenRectangle都添加一个相同的方法accept,第一个LeftPanelRectangle的改动是这样的:
1. *// JS* *:* *src\views\hitTest\left_panel_rectangle.js* 1. ... 2. 3. */** 被碰撞对象左挡板的大小数据 */* 4. **class** LeftPanelRectangle **extends** HitedObjectRectangle { 5. ... 6. 7. visit(hitObject) { 8. **if** (hitObject.left < **this**.right && hitObject.top > **this**.top && hitObject.bottom < **this**.bottom) { 9. **return** 1 << 0 10. } 11. **return** 0 12. } 13. } 14. 15. export default LeftPanelRectangle
第8行至第13行,在这个新增的visit方法中,代码是从原来HitObjectRectangle类中摘取一段并稍加修改完成的,这里碰撞检测只涉及两个对象的边界,没有if else,逻辑上简洁清晰多了。
第二个RightPanelRectangle类的改动是这样的:
1. *// JS* *:* *src\views\hitTest\right_panel_rectangle.js* 1. ... 2. 3. */** 被碰撞对象右挡板的大小数据 */* 4. **class** RightPanelRectangle **extends** HitedObjectRectangle { 5. ... 6. 7. visit(hitObject) { 8. **if** (hitObject.right > **this**.left && hitObject.top > **this**.top && hitObject.bottom < **this**.bottom) { 9. **return** 1 << 1 10. } 11. **return** 0 12. } 13. } 14. export default RightPanelRectangle
第8行至第13行,这个visit方法的实现,与LeftPanelRectangle中visit方法的实现如出一辙。
第3个是ScreenRectangle类的改动如代码清单11-21所示:
代码清单11-21屏幕的被碰撞对象
1.// JS:src\views\hitTest\screen_rectangle.js 2.... 3. 4./** 被碰撞对象屏幕的大小数据 */ 5.class ScreenRectangle extends HitedObjectRectangle { 6. ... 7. 8. visit(hitObject) { 9. let res = 0 10. if (hitObject.right > this.right) { // 触达右边界返回4 11. res = 1 << 2 12. } else if (hitObject.left < this.left) { // 触达左边界返回8 13. res = 1 << 3 14. } 15. if (hitObject.top < this.top) { // 触达上边界返回16 16. res = 1 << 4 17. } else if (hitObject.bottom > this.bottom) { // 触达下边界返回32 18. res = 1 << 5 19. } 20. return res 21. } 22.} 23. 24.export default ScreenRectangle
第8行至第21行,是新增的visit方法。所有返回值,与原来均是一样的,代码的逻辑结构也是一样的,只是从哪个对象上取值进行比较作了变化。
上面这3个类都是HitedObjectRectangle的子类,为了让基类的定义更加完整,我们也修改一下hited_object_rectangle.js文件,如下所示:
1.// JS:src\views\hitTest\hited_object_rectangle.js 2.... 3. 4./** 被碰撞对象的抽象部分,屏幕及左右挡板的注册点默认在左上角 */ 5.class HitedObjectRectangle extends Rectangle { 6. ... 7. 8. visit(hitObject) { } 9.} 10. 11.export default HitedObjectRectangle
仅是第8行添加了一个空方法visite,这个改动可以让所有HitedObjectRectangle对象都有一个默认的visite方法,在某些情况下可以避免代码出错。
最后我们再看一下HitObjectRectangle类的改动,这也是访问者模式中的核心部分,如代码清单11-22所示:
代码清单11-22在撞击对象中应用访问者模式
1.// JS:src\views\hitTest\hit_object_rectangle.js 2.... 3. 4./** 碰撞对象的抽象部分,球与方块的注册点在中心,不在左上角 */ 5.class HitObjectRectangle extends Rectangle { 6. ... 7. 8. /** 与被撞对象的碰撞检测 */ 9. hitTest(hitedObject) { 10. // let res = 0 11. // if (hitedObject instanceof LeftPanelRectangle) { // 碰撞到左挡板返回1 12. // if (this.left < hitedObject.right && this.top > hitedObject.top && this.bottom < hitedObject.bottom) { 13. // res = 1 << 0 14. // } 15. // } else if (hitedObject instanceof RightPanelRectangle) { // 碰撞到右挡板返回2 16. // if (this.right > hitedObject.left && this.top > hitedObject.top && this.bottom < hitedObject.bottom) { 17. // res = 1 << 1 18. // } 19. // } else if (hitedObject instanceof ScreenRectangle) { 20. // if (this.right > hitedObject.right) { // 触达右边界返回4 21. // res = 1 << 2 22. // } else if (this.left < hitedObject.left) { // 触达左边界返回8 23. // res = 1 << 3 24. // } 25. // if (this.top < hitedObject.top) { // 触达上边界返回16 26. // res = 1 << 4 27. // } else if (this.bottom > hitedObject.bottom) { // 触达下边界返回32 28. // res = 1 << 5 29. // } 30. // } 31. // return res 32. return hitedObject.visit(this) 33. } 34.} 35. export default HitObjectRectangle
第10行至第31行,是hitTest方法中被注释掉的旧代码,看到了吗, 原来复杂的if else逻辑没有了,只留下简短的一句话(第32行) , 简单吧?这就是设计模式的力量,不仅现在简单,稍后如果我们要添加其他碰撞对象与被碰撞对象,这里也不需要变动,代码的可扩展性非常好。
我们在增加新的碰撞检测对象时,只需要创建新类就可以了,没有if else逻辑需要添加,也不会影响旧代码。第9行,这里的hitTest方法,相当于一般访问者模式中的accept方法。
当我们将访问者模式和桥接模式完成结合应用时,代码变得异常简洁清晰了,这才是好的面向对象设计该有的样子。
小游戏的运行效果与之前是一致的,如下所示。
注:访问者模式实践完成后,源码目录见:disc/第11章/11.2/11.2.1,读者可以自行下载随书源码与之对照。
总结一下访问者模式的用法
最后总结一下,访问者模式特别擅长将拥有多个if else逻辑或switch分支逻辑的代码,以一种反向调用的方式,转化为两类对象之间一对一的逻辑关系进行处理。这是一个应用十分普遍的设计模式,当遇到复杂的if else代码时,可以考虑使用该模式重构。
小结
以上就是桥接模式与访问者模式的应用举例了,这些设计模式是通用的,与业务无关,学完以后不可以应用于小游戏开发中,在其他前端项目中也可以用到,甚至在其他编程语言中也可以用到。设计模式本质上是一种组织软件功能、架构代码模块的面向对象思想,这种思想貌似让我们在开始写代码的时候多干了一些活,但干这些活的精力是值得投入的,它让我们可以把其他的活干得更快、更稳、更好。
只有走得稳,才可以走得更远、更快。不知道你看到这里,有没有理解设计模式在项目开发中的作用?有人可能会反驳说,项目着急上线根本不给我们仔细分析需求与架构的时间,怎么应用设计模式?
快速上线是没有问题的,时间就是产品的生命;但在第一版本上线之后,趁运营兄弟忙碌的时候,程序员要马上进行渐进式重构,你注意看一下,我在前面提到重构时,前面都加了“渐进式”三个字。在书中作者也是这样强调的,重构并不发生在项目之初,对设计模式的应用也是在基本功能尘埃落定之后进行的。
一个大都市,为了盖几座新楼、修几条新路,难道要把所有人都赶出城去吗?不会的,肯定是在尽量不影响居民生产生活的前提下同步进行的,这便是渐进式,这个道理在软件开发中同样适用。
只有走得稳,才可以走得更远、更快,而设计模式与渐进式面向对象重构思想可以帮助我们做到这一点。