设计模式:以桥接模式和访问者模式为例,看设计模式在微信小游戏版本迭代中的应用

100 阅读17分钟

去年9月份,微信小游戏《羊了个羊》火爆全网,由于同时在线玩家过多,开发商服务器2天之内竟然出现了3次宕机。这在云开发时代是极少出现的,若不是火爆程度大大超出了预期,程序员怎么可能来不及扩容服务器呢?

微信小游戏开发整体来讲简单、独立、易上手,即使是一个人,也可以开发,不少程序员还是独立的微信小游戏开发者,仅靠游戏收入就远远超过了一般程序员的上班收入。《羊了个羊》小游戏的火爆,更加刺激了程序员,尤其是前端程序员向这个领域转行。

为什么要在游戏开发中使用设计模式呢?

对于游戏开发,一般人认为这是一个创意行业,不仅要有过硬的技术,更要有新奇的创意。这个认知没有错,但是,创意是不受法律保护的,任何一个创意火爆以后,马上就可能有N个开发商跟风抄袭。在游戏行业的开发史上,已经出现过多次,第一个想出创意的老大被后来居上的老二反超了。

怎么应对这种情况呢?

如果别人跑得快,就要想办法比别人跑得更快,跑得更久。游戏开发和其他所有软件产品的开发一样,并不是一锤子买卖,在第一个版本上线以后,后续根据玩家反馈和竞品功能的升级,需要不断研发和推出新版本。

在版本迭代的过程中,怎么样让新功能更快地开发出来,同时老功能还能更大范围地保持稳定,这是最考验游戏架构师能力的。架构师在项目启动的时候,就要为后续可能的变化预留方案,让后面游戏版本的迭代进行得又快、又稳。这涉及游戏架构师的一项核心能力:渐进式模块化重构与面向对象重构的能力。

软件开发是有成熟的套路的,前辈大牛经过实践总结的设计模式便是套路的结晶,有意识地在游戏开发中运用成熟的设计模式,不仅可以彰显程序员的内功水平,还能在一定程度上保证版本迭代的快速与稳定。

当前的小游戏项目分析

接下来作者分享的,是来自《微信小游戏开发》这本书中的一个小游戏实战案例,项目进行到第11章,基本功能已经开发完了,为了方便读者锤炼渐进式模块化重构与面向对象重构的能力,特意在这个阶段安排了设计模式实战。

在目前的项目中(以《微信小游戏开发》前端篇随书源码第11章/11.1/11.1.1的源码为基础),有两类碰撞检测:一类发生在球与挡板之间;另一类发生在球与屏幕边界之间。在游戏中,碰撞检测是非常常见一种功能,为了应对可能增加的碰撞检测需求,我们使用设计模式将两类碰撞的耦合性降低,方便后续加入新的碰撞与被碰撞对象。

具体从实现上来讲,接下来我们准备应用桥接模式,将发生碰撞的双方,分别定义为两个可以独立变化的抽象对象(HitObjectRectangle与HitedObjectRectangle),然后再让它们的具体实现部分独立变化,以此完成对桥接模式的应用。

目前球(Ball)与挡板(Panel)还没有基类,我们可以让它们继承于新创建的抽象基类,但这样并不是很合理,它们都属于可视化对象,如果要继承,更应该继承于Component基类。在JS中一个类的继承只能实现单继续,不能让一个类同时继承于多个基类,在这种情况下我们怎么实现桥接模式中的抽象部分呢?对象能力的扩展形式,除了继承,还有复合,我们可以将定义好的桥接模式中的具体实现部分,以类属性的方式放在球和挡板对象中。

什么是桥接模式?

在应用桥接模式之前,我们先从概念上简单了解一下什么是桥接模式。

桥接模式是一种结构型设计模式, 可将一系列紧密相关的类拆分为抽象和实现两个独立的层次结构, 从而能在开发时分别使用。

换一个说法,桥接模式将对象的抽象部分与它的具体实现部分分离,使它们都可以独立的变化。在桥接模式中,一般包括两个抽象部分和两个具体实现的部分,一个抽象部分和一个具体实现部分为一组,一共有两组,两组通过中间的抽象部分进行桥接,从而让两组的具体实现部分可以相对独立自由的变化。

为了更好地理解这个模式,我们通过一张图看一个应用示例,如图11-1所示:

image.png 图11-1桥接模式示例示意图

在这张图中,中间是一个跨平台开发框架,它为开发者抽离出一套通用接口(抽象部分B),这些接口是通用的、系统无关的,借此开发框架实现了跨平台特性。在开发框架中,具体到每个系统(Mac、Windows和Linux),每个接口及UI有不同的实现(具体实现部分B1、B2、B3)。左边,在应用程序中,开发者在软件中定义了一套抽象部分A,在每个系统上有不同的具体实现(具体实现部分A1、A2、A3)。应用程序面向抽象部分B编程,不必关心开发框架在每个系统下的具体实现;应用程序的具体实现部分A1、A2、A3是基于抽象部分A编程的,它们也不需要知道抽象部分B。抽象部分A与抽象部分B之间仿佛有一个桥连接了起来,这两套抽象部分与其具体实现部分呈现的模式便是桥接模式。

试想一下,如果我们不使用桥接模式,没有中间这一层跨平台开发框架,没有抽象部分B和抽象部分A,这时候我们想实现具体实现部分A1、A2、A3,怎么做呢?直接在各个系统的基础类库上实现呢?让A1与B1耦合、A2与B2耦合、A3与B3耦合吗?每次在应用程序中添加一个新功能,都要在三个地方分别实现。而有了桥接模式之后,B1、B2、B3都不需要关心了,只需要知道抽象部分B就可以了;添加新功能时,只需要在抽象部分A中定义并基于抽象部分B实现核心功能就可以了,在具体实现部分A1、A2、A3中只是UI和交互方式不同而已。这是使用桥接模式的价值。

桥接模式的具体实现

接下来开始实践,我们先定义桥接模式当中的抽象部分,一个是主动撞击对象的抽象部分(HitObjectRectangle),一个是被动撞击对象的抽象部分(HitedObjectRectangle)。由于两个部分的抽象部分具有相似性,我们可以先定义一个抽象部分的基类Rectangle,如代码清单11-7所示:

代码清单11-7矩形基类

1.// JS:src\views\hitTest\rectangle.js
2./** 对象的矩形描述,默认将注册点放在左上角 */
3.class Rectangle {
4.  constructor(x, y, width, height) {
5.    this.x = x
6.    this.y = y
7.    this.width = width
8.    this.height = height
9.  }
10.
11.  /** X坐标 */
12.  x = 0
13.  /** Y坐标 */
14.  y = 0
15.  /** X轴方向上所占区域 */
16.  width = 0
17.  /** Y轴方向上所占区域 */
18.  height = 0
19.
20.  /** 顶部边界 */
21.  get top() {
22.    return this.y
23.  }
24.  /** 底部边界 */
25.  get bottom() {
26.    return this.y + this.height
27.  }
28.  /** 左边界 */
29.  get left() {
30.    return this.x
31.  }
32.  /** 右边界 */
33.  get right() {
34.    return this.x + this.width
35.  }
36.}
37.
38.export default Rectangle

上面代码做了什么事?

❑ 第12行至第18行,这是4个属性,x、y决定注册点,width、height决定尺寸。

❑ 第21行至第35行,这是4个getter访问器,分别代表对象在4个方向上的边界值。这4个属性不是实际存在的,而是通过注册点与尺寸计算出来的。根据注册点位置的不同,这4个getter的值也不同。默认注册点,即(0,0)坐标点在左上角,这时候top等于y;如果注册点在左下角,这时候top则等于y减去height。

Rectangle描述了一个对象的距形范围,关于4个边界属性top、bottom、left、right与注册点的关系,可以参见图11-2:

image.png

图11-2注册点与边界值的关系

接下来我们开始定义两个抽象部分:一个是撞击对象的,另一个是受撞击对象的。先看受撞击对象的,它比较简单:

1.// JS:src\views\hitTest\hited_object_rectangle.js
2.import Rectangle from "rectangle.js"
3.
4./** 被碰撞对象的抽象部分,屏幕及左右挡板的注册点默认在左上角 */
5.class HitedObjectRectangle extends Rectangle{
6.  constructor(x, y, width, height){
7.    super(x, y, width, height)
8.  }
9.}
10.
11.export default HitedObjectRectangle

HitedObjectRectangle类它没有新增属性或方法,所有特征都是从基类继承的。它的主要作用是被继承,稍后有3个子类继承它。

再看一下撞击对象的定义,如代码清单11-8所示:

代码清单11-8创建撞击对象基类

1.// JS:src\views\hitTest\hit_object_rectangle.js
2.import Rectangle from "rectangle.js"
3.import LeftPanelRectangle from "left_panel_rectangle.js"
4.import RightPanelRectangle from "right_panel_rectangle.js"
5.import ScreenRectangle from "screen_rectangle.js"
6.
7./** 碰撞对象的抽象部分,球与方块的注册点在中心,不在左上角 */
8.class HitObjectRectangle extends Rectangle {
9.  constructor(width, height) {
10.    super(GameGlobal.CANVAS_WIDTH / 2, GameGlobal.CANVAS_HEIGHT / 2, width, height)
11.  }
12.
13.  get top() {
14.    return this.y - this.height / 2
15.  }
16.  get bottom() {
17.    return this.y + this.height / 2
18.  }
19.  get left() {
20.    return this.x - this.width / 2
21.  }
22.  get right() {
23.    return this.x + this.width / 2
24.  }
25.
26.  /** 与被撞对象的碰撞检测 */
27.  hitTest(hitedObject) {
28.    let res = 0
29.    if (hitedObject instanceof LeftPanelRectangle) { // 碰撞到左挡板返回1
30.      if (this.left < hitedObject.right && this.top > hitedObject.top && this.bottom < hitedObject.bottom) {
31.        res = 1 << 0
32.      }
33.    } else if (hitedObject instanceof RightPanelRectangle) { // 碰撞到右挡板返回2
34.      if (this.right > hitedObject.left && this.top > hitedObject.top && this.bottom < hitedObject.bottom) {
35.        res = 1 << 1
36.      }
37.    } else if (hitedObject instanceof ScreenRectangle) {
38.      if (this.right > hitedObject.right) { // 触达右边界返回4
39.        res = 1 << 2
40.      } else if (this.left < hitedObject.left) { // 触达左边界返回8
41.        res = 1 << 3
42.      }
43.      if (this.top < hitedObject.top) { // 触达上边界返回16
44.        res = 1 << 4
45.      } else if (this.bottom > hitedObject.bottom) { // 触达下边界返回32
46.        res = 1 << 5
47.      }
48.    }
49.    return res
50.  }
51.}
52.
53.export default HitObjectRectangle

在上面代码中:

❑ HitObjectRectangle也是作为基类存在的,稍后有一个子类继承它。在这个基类中,第13行至第24行,我们通过重写getter访问器属性,将注册点由左上角移到了中心。

❑ 第10行,在构造器函数中我们看到,默认的起始x、y是屏幕中心的坐标。

❑ 第27行至第50行,hitTest方法的实现是核心代码,碰撞到左挡板与碰撞到右挡板返回的数字与之前定义的一样,碰撞四周墙壁返回的数字是4个新增的数字。

❑ 第35行,这行出现的1<<0代表数值的二进制向左移0个位置。移0个位置没有意义,这样书写是为了与下面的第35行、第39行、第41行等保持格式一致。1<<0等于1,1<<1等于2,1<<2等于4,1<<3等于8,这些数值是按2的N次幂递增的。

接下来我们定义ScreenRectangle,它是被撞击部分的具体实现部分:

1.// JS:src\views\hitTest\screen_rectangle.js
2.import HitedObjectRectangle from "hited_object_rectangle.js"
3.
4./** 被碰撞对象屏幕的大小数据 */
5.class ScreenRectangle extends HitedObjectRectangle {
6.  constructor() {
7.    super(0, 0, GameGlobal.CANVAS_WIDTH, GameGlobal.CANVAS_HEIGHT)
8.  }
9.}
10.
export default ScreenRectangle

ScreenRectangle是屏幕的大小、位置数据对象,是一个继承于HitedObjectRectangle的具体实现。ScreenRectangle类作为一个具体的实现类,却没有添加额外的属性或方法,那我们为什么要定义它呢?它存在的意义,是由它本身作为一个对象成立的,参见HitObjectRectangle类中的hitTest方法。

接下来我们再看左挡板的大小、位置数据对象:

1.// JS:src\views\hitTest\left_panel_rectangle.js
2.import HitedObjectRectangle from "hited_object_rectangle.js"
3.
4./** 被碰撞对象左挡板的大小数据 */
5.class LeftPanelRectangle extends HitedObjectRectangle {
6.  constructor() {
7.    super(0, (GameGlobal.CANVAS_HEIGHT - GameGlobal.PANEL_HEIGHT) / 2, GameGlobal.PANEL_WIDTH, GameGlobal.PANEL_HEIGHT)
8.  }
9.}
10.
11.export default LeftPanelRectangle

LeftPanelRectangle与ScreenRectangle一样,是继承于HitedObjectRectangle的一个具体实现,仍然没有新增属性或方法,所有信息,包括大小和位置,都已经通过构造器参数传递进去了。

再看一下右挡板的大小、位置数据对象:

1.// JS:src\views\hitTest\right_panel_rectangle.js
2.import HitedObjectRectangle from "hited_object_rectangle.js"
3.
4./** 被碰撞对象右挡板的大小数据 */
5.class RightPanelRectangle extends HitedObjectRectangle {
6.  constructor() {
7.    super(GameGlobal.CANVAS_WIDTH - GameGlobal.PANEL_WIDTH, (GameGlobal.CANVAS_HEIGHT - GameGlobal.PANEL_HEIGHT) / 2, GameGlobal.PANEL_WIDTH, GameGlobal.PANEL_HEIGHT)
8.  }
9.}
10.
11.export default RightPanelRectangle

RightPanelRectangle也是继承于HitedObjectRectangle的一个具体实现,与LeftPanelRectangle不同的只是坐标位置。

接下来我们再看撞击对象这边的具体实现部分,只有一个BallRectangle类:

1.// JS:src\views\hitTest\ball_rectangle.js
2.import HitObjectRectangle from "hit_object_rectangle.js"
3.
4./** 碰撞对象的具体实现部分,球的大小及运动数据对象 */
5.class BallRectangle extends HitObjectRectangle {
6.  constructor() {
7.    super(GameGlobal.RADIUS * 2, GameGlobal.RADIUS * 2)
8.  }
9.}
10.
11.export default BallRectangle

BallRectangle是描述球的位置、大小的,所有信息在基类中都具备了,所以它不需要添加任何属性或方法了。

以上就是我们为应用桥接模式定义的所有类了,为了进一步明确它们之间的关系,看一张示意图,如图11-3所示:

image.png

图11-3桥接模式示例类关系图

第二层的HitObjectRectangle和HitedObjectRectangle是桥接模式中的抽象部分,第三层是具体实现部分。事实上如果我们需要的话,我们在HitObjectRectangle和HitedObjectRectangle两条支线上,还可以定义更多的具体实现类。

在项目中消费桥接模式

接下来看如何使用,先改造原来的Ball类,如代码清单11-9所示:

代码清单11-9改造Ball类

1.// JS:src/views/ball.js
2.import BallRectangle from "hitTest/ball_rectangle.js"
3.
4./** 小球 */
5.class Ball {
6.  ...
7.
8.  constructor() { }
9.
10.  get x() {
11.    // return this.#pos.x
12.    return this.rectangle.x
13.  }
14.  get y() {
15.    // return this.#pos.y
16.    return this.rectangle.y
17.  }
18.  /** 小于碰撞检测对象 */
19.  rectangle = new BallRectangle()
20.  // #pos // 球的起始位置
21.  #speedX = 4 // X方向分速度
22.  #speedY = 2 // Y方向分速度
23.
24.  /** 初始化 */
25.  init(options) {
26.    // this.#pos = options?.ballPos ?? { x: GameGlobal.CANVAS_WIDTH / 2, y: GameGlobal.CANVAS_HEIGHT / 2 } 
27.    // const defaultPos = { x: this.#pos.x, y: this.#pos.y }
28.    // this.reset = () => {
29.    //   this.#pos.x = defaultPos.x
30.    //   this.#pos.y = defaultPos.y
31.    // }
32.    this.rectangle.x = options?.x ?? GameGlobal.CANVAS_WIDTH / 2
33.    this.rectangle.y = options?.y ?? GameGlobal.CANVAS_HEIGHT / 2
34.    this.#speedX = options?.speedX ?? 4
35.    this.#speedY = options?.speedY ?? 2
36.    const defaultArgs = Object.assign({}, this.rectangle)
37.    this.reset = () => {
38.      this.rectangle.x = defaultArgs.x
39.      this.rectangle.y = defaultArgs.y
40.      this.#speedX = 4
41.      this.#speedY = 2
42.    }
43.  }
44.
45.  /** 重设 */
46.  reset() { }
47.
48.  /** 渲染 */
49.  render(context) {
50.    ...
51.  }
52.
53.  /** 运行 */
54.  run() {
55.    // 小球运动数据计算
56.    // this.#pos.x += this.#speedX
57.    // this.#pos.y += this.#speedY
58.    this.rectangle.x += this.#speedX
59.    this.rectangle.y += this.#speedY
60.  }
61.
62.  /** 小球与墙壁的四周碰撞检查 */
63.  // testHitWall() {
64.  //   if (this.#pos.x > GameGlobal.CANVAS_WIDTH - GameGlobal.RADIUS) { // 触达右边界
65.  //     this.#speedX = -this.#speedX
66.  //   } else if (this.#pos.x < GameGlobal.RADIUS) { // 触达左边界
67.  //     this.#speedX = -this.#speedX
68.  //   }
69.  //   if (this.#pos.y > GameGlobal.CANVAS_HEIGHT - GameGlobal.RADIUS) { // 触达右边界
70.  //     this.#speedY = -this.#speedY
71.  //   } else if (this.#pos.y < GameGlobal.RADIUS) { // 触达左边界
72.  //     this.#speedY = -this.#speedY
73.  //   }
74.  // }
75.  testHitWall(hitedObject) {
76.    const res = this.rectangle.hitTest(hitedObject)
77.    if (res === 4 || res === 8) {
78.      this.#speedX = -this.#speedX
79.    } else if (res === 16 || res === 32) {
80.      this.#speedY = -this.#speedY
81.    }
82.  }
83.
84.  ...
85.}
86.
87.export default Ball.getInstance()

在Ball类中发生了什么变化?

❑ 第19行,我们添加了新的类属性rectangle,它是BallRectangle的实例。所有关于球的位置、大小等信息都移到了rectangle中,所以原来的类属性#pos(第20行)不再需要了,同时原来调用它的代码(例如第58行、第59行)都需要使用rectangle改写。

❑ 第32行至第42行,这是初始化代码,原来#pos是一个坐标,包括x、y两个值,现在将这两个值分别以rectangle中的x、y代替。

❑ 方法testHitWall用于屏幕边缘碰撞检测的,第63行至第74行的是旧代码,第75行至第82行是新代码。hitedObject是新增的参数,它是HitedObjectRectangle子类的实例。

小球属于撞击对象,它的rectangle是一个HitObjectRectangle的子类实例(BallRectangle)。

看一下对Panel类的改造,它是LeftPanel和RightPanel的基类,如代码清单11-10所示:

代码清单11-10改造Panel类

1.// JS:src/views/panel.js
2./** 挡板基类 */
3.class Panel {
4.  constructor() { }
5.
6.  // x // 挡板的起点X坐标
7.  // y // 挡板的起点Y坐标
8.  get x() {
9.    return this.rectangle.x
10.  }
11.  set x(val) {
12.    this.rectangle.x = val
13.  }
14.  get y() {
15.    return this.rectangle.y
16.  }
17.  set y(val) {
18.    this.rectangle.y = val
19.  }
20.  /** 挡板碰撞检测对象 */
21.  rectangle
22.  ...
23.}
24.
25.export default Panel

这个基类发生了什么变化?

❑ 第21行,rectangle是新增的HitedObjectRectangle的子类实例,具体是哪个实现,要在子类中决定。

❑ 第6行、第7行将x、y去掉,代之以第8行至第19行的getter访问器和setter设置器,对x、y属性的访问和设置,将转变为对rectangle中x、y的访问和设置。

为什么要在Panel基类中新增一个rectangle属性?因为要在它的子类LeftPanel、RightPanel中新增这个属性,挡板是被撞击对象,rectangle是HitedObjectRectangle的子类实例。与其在子类中分别设置,不如在基类中一个地方统一设置;另外,基类中render方法渲染挡板时要使用x、y属性,x、y属性需要重写,这也要求rectangle必须定义在基类中定义。

看一下对LeftPanel类的改造,如代码清单11-11所示:

代码清单11-11改造LeftPanel类

1.// JS:src/views/left_panel.js
2....
3.import LeftPanelRectangle from "hitTest/left_panel_rectangle.js"
4.
5./** 左挡板 */
6.class LeftPanel extends Panel {
7.  constructor() {
8.    super()
9.    this.rectangle = new LeftPanelRectangle()
10.  }
11.
12.  ...
13.
14.  /** 小球碰撞到左挡板返回1 */
15.  testHitBall(ball) {
16.    return ball.rectangle.hitTest(this.rectangle)
17.    // if (ball.x < GameGlobal.RADIUS + GameGlobal.PANEL_WIDTH) { // 触达左挡板
18.    //   if (ball.y > this.y && ball.y < (this.y + GameGlobal.PANEL_HEIGHT)) {
19.    //     return 1
20.    //   }
21.    // }
22.    // return 0
23.  }
24.}
25.
26.export default new LeftPanel()

上面发生了什么?只有两处改动:

❑ 第9行,这里决定了基类中的rectangle是LeftPanelRectangle实例。LeftPanelRectangle是HitedObjectRectangle的子类。

❑ 第16行,碰撞检测代码修改为:由小球的rectangle与当前对象的rectangle做碰撞测试。

接下来是对RightPanel类的改写,如代码清单11-12所示:

代码清单11-12改造RightPanel类

1.// JS:src/views/right_panel.js
2....
3.import RightPanelRectangle from "hitTest/right_panel_rectangle.js"
4.
5./** 右挡板 */
6.class RightPanel extends Panel {
7.  constructor() {
8.    super()
9.    this.rectangle = new RightPanelRectangle()
10.  }
11.
12.  ...
13.
14.  /** 小球碰撞到左挡板返回2 */
15.  testHitBall(ball) {
16.    return ball.rectangle.hitTest(this.rectangle)
17.    // if (ball.x > (GameGlobal.CANVAS_WIDTH - GameGlobal.RADIUS - GameGlobal.PANEL_WIDTH)) { // 碰撞右挡板
18.    //   if (ball.y > this.y && ball.y < (this.y + GameGlobal.PANEL_HEIGHT)) {
19.    //     return 2
20.    //   }
21.    // }
22.    // return 0
23.  }
24.}
25.
26.export default new RightPanel()

与LeftPanel类似,在这个RightPanel类中也只有两处修改,见第9行与第16行。

最后,我们开始改造GameIndexPage,它是我们应用桥接模式的最后一站了,如代码清单11-13所示:

代码清单11-13改造游戏主页对象

1.// JS:src\views\game_index_page.js
2....
3.import ScreenRectangle from "hitTest/screen_rectangle.js"
4.
5./** 游戏主页页面 */
6.class GameIndexPage extends Page {
7.  ...
8.  /** 墙壁碰撞检测对象 */
9.  #rectangle = new ScreenRectangle()
10.
11.  ...
12.
13.  /** 运行 */
14.  run() {
15.    ...
16.    // 小球碰撞检测
17.    // ball.testHitWall()
18.    ball.testHitWall(this.#rectangle)
19.    ...
20.  }
21.
22.  ...
23.}
24.
export default GameIndexPage

在GameIndexPage类中,只有两处修改:

❑ 第9行,添加了一个私有属性#rectangle,它是一个碰撞检测数据对象,是HitedObjectRectangle的子类实例。

❑ 第18行,在调用小球的testHitWall方法,将#rectangle作为参数传递了进去。

现在代码修改完了,重新编译测试,运行效果与之前一致,如下所示。

image.png

使用桥接模式的意义在哪里?

现在我们思考一下,我们在碰撞检测这一块应用桥接模式,创建了许多新类,除了把项目变复杂了,到底有什么积极作用?我们将碰撞测试元素拆分为两个抽象对象(HitObjectRectangle和HitedObjectRectangle)的意义在哪里?

看一张结构图,如图11-4所示:

image.png

图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所示:

image.png

图11-5小球变成了红色方块

改动后,白色的小球变成了红色的方块。看到了吗?项目的可扩展性非常好,在应用了桥接模式以后,当我们把小球扩展为方块时,只需要少量的变动就可以做到了。

现在,将CubeRectangle纳入结构图,如图11-6所示:

image.png

图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方法。

当我们将访问者模式和桥接模式完成结合应用时,代码变得异常简洁清晰了,这才是好的面向对象设计该有的样子。

小游戏的运行效果与之前是一致的,如下所示。

image.png

注:访问者模式实践完成后,源码目录见:disc/第11章/11.2/11.2.1,读者可以自行下载随书源码与之对照。

总结一下访问者模式的用法

最后总结一下,访问者模式特别擅长将拥有多个if else逻辑或switch分支逻辑的代码,以一种反向调用的方式,转化为两类对象之间一对一的逻辑关系进行处理。这是一个应用十分普遍的设计模式,当遇到复杂的if else代码时,可以考虑使用该模式重构。

小结

以上就是桥接模式与访问者模式的应用举例了,这些设计模式是通用的,与业务无关,学完以后不可以应用于小游戏开发中,在其他前端项目中也可以用到,甚至在其他编程语言中也可以用到。设计模式本质上是一种组织软件功能、架构代码模块的面向对象思想,这种思想貌似让我们在开始写代码的时候多干了一些活,但干这些活的精力是值得投入的,它让我们可以把其他的活干得更快、更稳、更好。

只有走得稳,才可以走得更远、更快。不知道你看到这里,有没有理解设计模式在项目开发中的作用?有人可能会反驳说,项目着急上线根本不给我们仔细分析需求与架构的时间,怎么应用设计模式?

快速上线是没有问题的,时间就是产品的生命;但在第一版本上线之后,趁运营兄弟忙碌的时候,程序员要马上进行渐进式重构,你注意看一下,我在前面提到重构时,前面都加了“渐进式”三个字。在书中作者也是这样强调的,重构并不发生在项目之初,对设计模式的应用也是在基本功能尘埃落定之后进行的。

一个大都市,为了盖几座新楼、修几条新路,难道要把所有人都赶出城去吗?不会的,肯定是在尽量不影响居民生产生活的前提下同步进行的,这便是渐进式,这个道理在软件开发中同样适用。

只有走得稳,才可以走得更远、更快,而设计模式与渐进式面向对象重构思想可以帮助我们做到这一点。

有问题欢迎在评论区留言讨论。以上内容摘自机工出版的《微信小游戏开发》,李艺著,该书已在京东上架,内容为适合网络发表有少量修改,文中涉及的源码在随书源码中都可以找到。

WechatIMG1601.jpeg