微信小遊戲爆發式增長,如何保證小遊戲的版本迭代又快又穩?

騰訊雲開發者發表於2023-03-16
導語 | 以《羊了個羊》為代表的微信小遊戲在去年多次刷屏,引爆全網。近期又有幾款微信小遊戲成為熱門,一度讓“微信小遊戲”熱度指數上漲 20% 以上。微信小遊戲市場一直都充滿著希望與競爭,開發者如何在爆品爭霸中脫穎而出呢?在小遊戲開發中有哪些傳統開發經驗可以借鑑與學習呢?我們特邀騰訊雲 TVP、計算機作家/講師 李藝老師,在他新書《微信小遊戲開發》的基礎上帶我們看看在微信小遊戲專案開發中,從架構師角度如何應用物件導向和軟體設計思想和設計模式。

作者簡介

image.png

李藝,騰訊雲 TVP、日行一課聯合創始人兼 CTO,極客時間影片專欄《微信小程式全棧開發實戰》講師,一汽大眾等知名企業內訓培訓講師。具有近 20 年網際網路軟體研發經驗,參與研發的音影片直播產品曾在騰訊 QQ 上線,為數千萬人使用。是國內早期閃客之一,曾自定義課件標準並完成全平臺教育課件產品研發,官方評定為 Adobe 中國十五位社群管理員之一。同時,還是中國人工智慧學會會員,在北京協同創新研究院負責過人工智慧專案的研發。業餘喜歡寫作,在微信公眾號/影片號“藝述論”分享技術經驗,著有《微信小遊戲開發》、《小程式從 0 到 1:微信全棧工程師一本通》等計算機圖書。

引言

去年 9 月,微信小遊戲《羊了個羊》火爆全網,使用者訪問量驟增時甚至出現過多次當機,其火爆程度遠超預期。其實,微信小遊戲開發整體而言簡單、獨立、易上手,即使單人也可以完成開發,不少程式設計師都是獨立的微信小遊戲開發者。《羊了個羊》微信小遊戲的火熱,吸引了很多前端開發者向這個領域轉行。

為什麼要在遊戲開發中使用設計模式呢?

一般而言,遊戲開發作為創意行業,不僅要有過硬的技術,更要有新奇的想法。尤其當任何一個創意火爆後,馬上就會引發眾多開發廠商快速跟進。這在遊戲行業的開發史上,已經出現過多次後來者居上的案例了。

那麼我們該怎麼應對這種情況呢?如果別人跑得快,就要想辦法比別人跑得更快,跑得更久。遊戲開發和其他所有軟體產品的開發一樣,並不是一錘子買賣,在第一個版本上線以後,後續根據玩家反饋和競品功能的升級,需要不斷研發和推出新版本。

在版本迭代的過程中,怎麼樣讓新功能更快地開發出來,同時老功能還能更大範圍地保持穩定,這是最考驗遊戲架構師能力的。架構師在專案啟動的時候,就要為後續可能的變化預留方案,讓後面遊戲版本的迭代進行得又快、又穩。這涉及遊戲架構師的一項核心能力:漸進式模組化重構與物件導向重構的能力。

軟體開發是有成熟的套路的,前輩大牛經過實踐總結的設計模式便是套路的結晶,有意識地在遊戲開發中運用成熟的設計模式,不僅可以彰顯程式設計師的內功水平,還能在一定程度上保證版本迭代的快速與穩定。

小遊戲實戰專案介紹

接下來分享的,是來自《微信小遊戲開發》這本書中的一個小遊戲實戰案例,專案在基本功能開發完後,為了方便讀者錘鍊漸進式模組化重構與物件導向重構的能力,特意在這個階段安排了設計模式實戰。

在目前的專案中,有兩類碰撞檢測:一類發生在球與擋板之間;另一類發生在球與螢幕邊界之間。在遊戲中,碰撞檢測是非常常見一種功能,為了應對可能增加的碰撞檢測需求,我們使用設計模式將兩類碰撞的耦合性降低,方便後續加入的碰撞與被碰撞物件。

具體從實現上來講,我們準備應用橋接模式,將發生碰撞的雙方,分別定義為兩個可以獨立變化的抽象物件(HitObjectRectangle與HitedObjectRectangle),然後再讓它們的具體實現部分獨立變化,以此完成對橋接模式的應用。

目前球(Ball)與擋板(Panel)還沒有基類,我們可以讓它們繼承於新建立的抽象基類,但這樣並不是很合理,它們都屬於視覺化物件,如果要繼承,更應該繼承於 Component 基類。在 JS 中一個類的繼承只能實現單繼承,不能讓一個類同時繼承於多個基類,在這種情況下我們怎麼實現橋接模式中的抽象部分呢?物件能力的擴充套件形式,除了繼承,還有複合,我們可以將定義好的橋接模式中的具體實現部分,以類屬性的方式放在球和擋板物件中。

模式應用之橋接模式

在應用橋接模式之前,我們首先需要把握它的概念,從定義入手。其實,橋接模式是一種結構型設計模式,可將一系列緊密相關的類拆分為抽象和實現兩個獨立的層次結構,從而能在開發時分別使用。

換言之,橋接模式將物件的抽象部分與它的具體實現部分分離,使它們都可以獨立的變化。在橋接模式中,一般包括兩個抽象部分和兩個具體實現的部分,一個抽象部分和一個具體實現部分為一組,一共有兩組,兩組透過中間的抽象部分進行橋接,從而讓兩組的具體實現部分可以相對獨立自由的變化。

為了更好地理解這個模式,我們透過一張圖看一個應用示例,如圖 1 所示:

image.png

圖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:


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 與註冊點的關係,可以參見圖 2:

image.png

圖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 個子類繼承它。

再看一下撞擊物件的定義:

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.  
11.  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 是描述球的位置、大小的,所有資訊在基類中都具備了,所以它不需要新增任何屬性或方法了。

以上就是我們為應用橋接模式定義的所有類了,為了進一步明確它們之間的關係,看一張示意圖,如圖 3 所示:

image.png

圖3,橋接模式示例類關係圖

第二層的 HitObjectRectangle 和 HitedObjectRectangle 是橋接模式中的抽象部分,第三層是具體實現部分。事實上如果我們需要的話,我們在 HitObjectRectangle 和 HitedObjectRectangle 兩條支線上,還可以定義更多的具體實現類。

在專案中消費橋接模式

接下來看如何使用,先改造原來的 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 的基類:


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 類的改造:


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 類的改寫:


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,它是我們應用橋接模式的最後一站了:


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.  
25.  export default GameIndexPage

在 GameIndexPage 類中,只有以下兩處修改:

第 9 行,新增了一個私有屬性 #rectangle,它是一個碰撞檢測資料物件,是 HitedObjectRectangle 的子類例項。
第 18 行,在呼叫小球的 testHitWall 方法,將 #rectangle 作為引數傳遞了進去。
現在程式碼修改完了,重新編譯測試,執行效果與之前一致,如下所示:

image.png

圖4,執行效果圖

使用橋接模式的意義

我們思考一下,我們在碰撞檢測這一塊應用橋接模式,建立了許多新類,除了把專案變複雜了,到底有什麼積極作用?我們將碰撞測試元素拆分為兩個抽象物件(HitObjectRectangle 和 HitedObjectRectangle)的意義在哪裡?

看一張結構圖,如圖 5 所示:

image.png

圖5,待擴充套件的橋接模式示意圖

HitObjectRectangle 代表碰撞物件的碰撞檢測資料物件,HitedObjectRectangle 代表被碰撞物件的碰撞檢測資料物件,後者有三個具體實現的子類:ScreenRectangle、LeftPanelRectangle 和 RightPanelRectangle,這三個子類代表三類被撞擊的型別。

如果遊戲中出現一個四周需要被碰撞檢測的物件,它的檢測資料物件可以繼承於 ScreenRectangle;如果出現一個右側需要碰撞檢測的物件,它的檢測資料物件可以繼承於 RightPanelRectangle,以此類推左側出現的,它的資料物件可以繼承於 LeftPanelRectangle。而如果出現一個撞擊物件,它的檢測資料物件可以繼承於 BallRectangle。

目前我們這個小遊戲專案太過簡單,不足夠顯示橋接模式的作用。接下來我們做一個人為擴充,新增一個紅色立方體代替小球:


1.  // JS:src\views\cube.js
2.  import { Ball } from "ball.js"
3.  import CubeRectangle from "hitTest/cube_rectangle.js"
4.  
5.  /** 紅色立方塊 */
6.  class Cube extends Ball {
7.    constructor() {
8.      super()
9.      this.rectangle = new CubeRectangle()
10.    }
11.  
12.    /** 渲染 */
13.    render(context) {
14.      context.fillStyle = "red"
15.      context.beginPath()
16.      context.rect(this.rectangle.left, this.rectangle.top, this.rectangle.width, this.rectangle.height)
17.      context.fill()
18.    }
19.  }
20.  
21.  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.  
7.  export default CubeRectangle

CubeRectangle 是立方塊的檢測資料物件。CubeRectangle 可以繼承於HitObjectRectangle 實現,但因為立方體與小球特徵很像,所以讓它繼承於 BallRectangle 更容易實現。事實上它像一個“富二代”,只需要繼承(第 5 行),什麼也不用做。

接下來開始使用立方塊。為了使測試程式碼簡單,我們將 game.js 檔案中的頁面建立程式碼修改一下,如下所示:


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 行引入地址變了,其他不會改變。程式碼擴充套件完了,重新編譯測試,遊戲的執行效果如圖 6 所示:

image.png

圖6,小球變成了紅色方塊

改動後,白色的小球變成了紅色的方塊。此處,專案的可擴充套件性非常好,在應用了橋接模式以後,當我們把小球擴充套件為方塊時,只需要少量的變動就可以做到了。現在,將 CubeRectangle 納入結構圖,如圖 7所示:

image.png

圖7,擴充套件後的橋接模式示意圖

第四層新增了一個 CubeRectangle,我們的 HitObjectRectangle 修改了嗎?沒有。雖然在 HitObjectRectangle 的 hitTest 方法中,我們使用 instanceof 進行了型別判斷,如下所示:


1.  /** 與被撞物件的碰撞檢測 */
2.  hitTest(hitedObject) {
3.    let res = 0
4.    if (hitedObject instanceof LeftPanelRectangle) { 
5.      ...
6.    } else if (hitedObject instanceof RightPanelRectangle) { 
7.      ...
8.    } else if (hitedObject instanceof ScreenRectangle) {
9.      ...
10.    }
11.    return res
12.  }

但判斷的是基本型別,在第四層新增子型別不會影響程式碼的執行。我們新增的CubeRectangle 繼承於 BallRectangle,屬於 HitObjectRectangle 一支,如果新增一個新類繼承於 HitedObjectRectangle 的子類(即 ScreenRectangle、LeftPanelRectangle 和 RightPanelRectangle),結果是一樣的,程式碼不用修改仍然有效。HitObjectRectangle 和 HitedObjectRectangle 作為抽象部分,是我們實現的橋接模式中的重要組成部分,它們幫助具體實現部分遮蔽了變化的複雜性。

注意:如果我們新增了新的碰撞檢測型別,不同於 ScreenRectangle、LeftPanelRectangle 和 RightPanelRectangle 中的任何一個,程式碼應該如何擴充?這時候就需要修改 HitObjectRectangle 類的 hitTest 方法啦,需要新增 else if 分支。

橋接模式用法總結

綜上所述,在橋接模式中,是有兩部分物件分別實現抽象部分與具體部分,然後這兩部分物件相對獨立自由的變化。在本小節示例中,我們主要應用橋接模式實現了碰撞檢測。小球和立方塊是撞擊物件,左右擋板及螢幕是被撞擊物件,透過相同的方式定義它們的大小、位置資料,然後以一種相對優雅的方式實現了碰撞檢測。

對比重構前後的程式碼,我們不難發現,在應用橋接模式之前,我們的碰撞檢測程式碼是與 GameIndexPage、Ball、LeftPanel 和 RightPanel 耦合在一起的,並且不方便進行新的碰撞物件擴充套件;在重構以後,我們碰撞檢測的程式碼變成了只有 top、bottom、left 和 right 屬性數值的對比,變得非常清晰。

所有物件導向重構中使用的設計模式,橋接模式是最複雜的,在大型跨平臺 GUI 軟體中,橋接模式基本也是必出現的。

模式應用之訪問者模式

在應用了橋接模式以後,相信大家對設計模式的作用會有更深的瞭解,也有意識地運用設計模式,它可以幫助我們更大限度地應對需求變化的複雜性,從而保證版本迭代的穩定與快捷。

訪問者模式則是微信小遊戲開發中另一應用設計,以下內容屬於《微信小遊戲開發》前端篇內容,我們嘗試在原始碼基礎之上,嘗試應用訪問者模式,目的仍然是有針對性地錘鍊學習者漸進性模組化重構和麵向物件重構思維的能力。

應用模式之前的專案狀態

目前我們在實現碰撞檢測功能的時候,在 HitObjectRectangle 類中有一個很重要的方法:


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
2.  ...
3.  
4.  /** 被碰撞物件左擋板的大小資料 */
5.  class LeftPanelRectangle extends HitedObjectRectangle {
6.    ...
7.  
8.    visit(hitObject) {
9.      if (hitObject.left < this.right && hitObject.top > this.top && hitObject.bottom < this.bottom) {
10.        return 1 << 0
11.      }
12.      return 0
13.    }
14.  }
15.  
16.  export default LeftPanelRectangle

第 8 行至第 13 行,在這個新增的 visit 方法中,程式碼是從原來 HitObjectRectangle 類中摘取一段並稍加修改完成的,這裡碰撞檢測只涉及兩個物件的邊界,沒有 if else,邏輯上便會更加簡潔清晰。

第二個 RightPanelRectangle 類的改動是這樣的:


1.  // JS:src\views\hitTest\right_panel_rectangle.js
2.  ...
3.  
4.  /** 被碰撞物件右擋板的大小資料 */
5.  class RightPanelRectangle extends HitedObjectRectangle {
6.    ...
7.  
8.    visit(hitObject) {
9.      if (hitObject.right > this.left && hitObject.top > this.top && hitObject.bottom < this.bottom) {
10.        return 1 << 1
11.      }
12.      return 0
13.    }
14.  }
15.  
16.  export default RightPanelRectangle

第 8 行至第 13 行,這個 visit 方法的實現,與 LeftPanelRectangle 中 visit 方法的實現如出一轍。

第 3 個是 ScreenRectangle 類的改動:


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 類的改動,這也是訪問者模式中的核心部分:


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.  
36.  export default HitObjectRectangle

第 10 行至第 31 行,是 hitTest 方法中被註釋掉的舊程式碼,原來複雜的 if else 邏輯沒有了,只留下簡短的一句話(第 32 行)。這就是設計模式的力量,不僅現在簡單,後續如果我們要新增其他碰撞物件與被碰撞物件,這裡也不需要變動,足以證明程式碼的可擴充套件性。

這樣我們在增加新的碰撞檢測物件時,只需要建立新類,沒有 if else 邏輯需要新增,也不影響舊程式碼。第 9 行,這裡的 hitTest 方法,相當於一般訪問者模式中的 accept 方法。

當我們將訪問者模式和橋接模式完成結合應用時,程式碼便變得異常簡潔清晰。小遊戲的執行效果與之前是一致的,如下所示:

image.png

圖7,執行效果示意圖

訪問者模式用法總結

綜上,訪問者模式特別擅長將擁有多個 if else 邏輯或 switch 分支邏輯的程式碼,以一種反向呼叫的方式,轉化為兩類物件之間一對一的邏輯關係進行處理。這是一個應用十分普遍的設計模式,當遇到複雜的 if else 程式碼時,可以考慮使用該模式重構。

總結

橋接模式與訪問者模式是通用的,不僅可以應用於小遊戲開發中,而且可以用在其他前端專案中,甚至在其他程式語言中也可以發揮作用。設計模式本質上是一種組織軟體功能、架構程式碼模組的物件導向思想,這種思想貌似讓我們在開始寫程式碼的時候多幹了一些活,但幹這些活的精力是值得投入的,它讓我們可以把其他的活幹得更快、更穩、更好。

只有走得穩,才可以走得更遠、更快。設計模式在專案開發中的作用一目瞭然,但也有一些反駁的聲音認為,專案著急上線時根本沒有仔細分析需求與架構的時間,如何應用設計模式?

其實,快速上線是沒有問題的,時間就是產品的生命;但在第一版本上線之後,程式設計師可以進行漸進式重構,重構並不發生在專案之初,對設計模式的應用也是在基本功能塵埃落定之後進行的。

只有走得穩,才可以走得更遠、更快,而設計模式與漸進式物件導向重構思想便可以幫助我們實現。

image.png

本篇內容摘自騰訊雲 TVP 李藝著、機械工業出版社出版的《微信小遊戲開發》,該書已在京東上架,想要進一步深入瞭解微信小遊戲開發的朋友們可以自行前往購買,文中涉及的所有設計模式原始碼在隨書原始碼中都可以找到。

相關文章