大神駕到 | 騰訊光子大牛的 Cocos Creator 網路通用框架(強勢圍觀)
- 2019 年 11 月 8 日
- 筆記
編者按
作者,寶爺。寶爺是光子工作室的開發工程師,謙稱自己為一枚碼農,是一個熱愛遊戲、熱愛開發、熱愛學習並堅持沉澱知識的開發者,曾寫過《精通 Cocos2d-x 遊戲開發》基礎卷與進階卷,感謝寶爺為社區所做的貢獻!
在 Cocos Creator 中發起一個 http 請求是比較簡單的,但很多遊戲希望能夠和伺服器之間保持長連接,以便服務端能夠主動向客戶端推送消息,而非總是由客戶端發起請求,對於實時性要求較高的遊戲更是如此。這裡我們會設計一個通用的網路框架,可以方便地應用於我們的項目中。
項目源碼
https://github.com/wyb10a10/cocos_creator_framework
本項目在不斷完善中,包含 bug修改和程式碼更新,下文所展示的程式碼請以源碼為準。
使用websocket
在實現這個網路框架之前,我們先了解一下 websocket。websocket 是一種基於 tcp 的全雙工網路協議,可以讓網頁創建持久性的連接,進行雙向的通訊。在 Cocos Creator 中使用 websocket 既可以用於 H5 網頁遊戲上,同樣支援原生平台 Android 和 iOS。
構造 websocket 對象
在使用 websocket 時,第一步應該創建一個 websocket 對象。websocket 對象的構造函數可以傳入2個參數,第一個是 url 字元串,第二個是協議字元串或字元串數組,指定了可接受的子協議,服務端需要選擇其中的一個返回,才會建立連接,但我們一般用不到。
url 參數非常重要,主要分為4部分:協議、地址、埠、資源。
比如 ws://echo.websocket.org:
- 協議:必選項,默認是 ws 協議,如果需要安全加密則使用 wss。
- 地址:必選項,可以是 ip 或域名,當然建議使用域名。
- 埠:可選項,在不指定的情況下,ws 的默認埠為 80,wss 的默認埠為 443。
- 資源:可選性,一般是跟在域名後某資源路徑,我們基本不需要它。
websocket 的狀態
websocket 有4個狀態,可以通過 readyState 屬性查詢:
- 0 CONNECTING 尚未建立連接。
- 1 OPEN WebSocket連接已建立,可以進行通訊。
- 2 CLOSING 連接正在進行關閉握手,或者該close()方法已被調用。
- 3 CLOSED 連接已關閉。
websocket 的 API
websocket 只有2個 API,void send( data ) 發送數據和 void close( code, reason ) 關閉連接。
send 方法只接收一個參數——即要發送的數據,類型可以是以下4個類型的任意一種:string | ArrayBufferLike | Blob | ArrayBufferView。
如果要發送的數據是二進位,我們可以通過 websocket 對象的 binaryType 屬性來指定二進位的類型,binaryType 只可以被設置為「blob」或「arraybuffer」,默認為「blob」。如果我們要傳輸的是文件這樣較為固定的、用於寫入到磁碟的數據,使用 blob。而你希望傳輸的對象在記憶體中進行處理則使用較為靈活的 arraybuffer。如果要從其他非 blob 對象和數據構造一個 blob,需要使用 blob 的構造函數。
在發送數據時,官方有2個建議:
- 檢測 websocket 對象的 readyState 是否為 OPEN,是才進行 send。
- 檢測 websocket 對象的 bufferedAmount 是否為0,是才進行 send(為了避免消息堆積,該屬性表示調用 send 後堆積在 websocket 緩衝區的還未真正發送出去的數據長度)。
close 方法接收2個可選的參數,code 表示錯誤碼,我們應該傳入 1000 或 3000~4999 之間的整數,reason 可以用於表示關閉的原因,長度不可超過 123 位元組。
websocket 的回調
websocket 提供了4個回調函數供我們綁定:
- onopen:連接成功後調用。
- onmessage:有消息過來時調用:傳入的對象有 data 屬性,可能是字元串、blob 或 arraybuffer。
- onerror:出現網路錯誤時調用:傳入的對象有 data 屬性,通常是錯誤描述的字元串。
- onclose:連接關閉時調用:傳入的對象有 code、reason、wasClean 等屬性。
注意:當網路出錯時,會先調用 onerror 再調用 onclose,無論何種原因的連接關閉,onclose 都會被調用。
Echo 實例
下面 websocket 官網的 echo demo 的程式碼,可以將其寫入一個 html 文件中並用瀏覽器打開,打開後會自動創建 websocket 連接,在連接上時主動發送了一條消息「WebSocket rocks」,伺服器會將該消息返回,觸發 onMessage,將資訊列印到螢幕上,然後關閉連接。具體可以參考:http://www.websocket.org/echo.html17
默認的 url 前綴是wss,由於 wss 抽風,使用 ws 才可以連接上,如果 ws 也抽風,可以試試連這個地址ws://121.40.165.18:8800,這是中國的一個免費測試 websocket 的網址。
參考鏈接
- https://www.w3.org/TR/websockets/9
- https://developer.mozilla.org/en-US/docs/Web/API/Blob6
- http://www.websocket.org/echo.html17
- http://www.websocket-test.com/2
設計框架
一個通用的網路框架,在通用的前提下還需要能夠支援各種項目的差異需求,根據經驗,常見的需求差異如下:
- 用戶協議差異,遊戲可能傳輸 json、protobuf、flatbuffer 或者自定義的二進位協議。
- 底層協議差異,我們可能使用 websocket、或者微信小遊戲的 wx.websocket、甚至在原生平台我們希望使用 tcp/udp/kcp 等協議。
- 登陸認證流程,在使用長連接之前我們理應進行登陸認證,而不同遊戲登陸認證的方式不同。
- 網路異常處理,比如超時時間是多久,超時後的表現是怎樣的,請求時是否應該屏蔽 UI 等待伺服器響應,網路斷開後表現如何,自動重連還是由玩家點擊重連按鈕進行重連,重連之後是否重發斷網期間的消息?等等這些。
- 多連接的處理,某些遊戲可能需要支援多個不同的連接,一般不會超過2個,比如一個主連接負責處理大廳等業務消息,一個戰鬥連接直接連戰鬥伺服器,或者連接聊天伺服器。
根據上面的這些需求,我們對功能模組進行拆分,盡量保證模組的高內聚,低耦合。

ProtocolHelper 協議處理模組——當我們拿到一塊 buffer時,我們可能需要知道這個 buffer 對應的協議或者 id 是多少,比如我們在請求的時候就傳入了響應的處理回調,那麼常用的做法可能會用一個自增的 id 來區別每一個請求,或者是用協議號來區分不同的請求,這些是開發者需要實現的。我們還需要從 buffer 中獲取包的長度是多少?包長的合理範圍是多少?心跳包長什麼樣子等等。
Socket 模組——實現最基礎的通訊功能,首先定義 Socket 的介面類 ISocket,定義如連接、關閉、數據接收與發送等介面,然後子類繼承並實現這些介面。
NetworkTips 網路顯示模組——實現如連接中、重連中、載入中、網路斷開等狀態的顯示,以及 UI 的屏蔽。
NetNode 網路節點——所謂網路節點,其實主要的職責是將上面的功能串聯起來,為用戶提供一個易用的介面。
NetManager 管理網路節點的單例——我們可能有多個網路節點(多條連接),所以這裡使用單例來進行管理,使用單例來操作網路節點也會更加方便。
ProtocolHelper
在這裡定義了一個 IProtocolHelper 的簡單介面,如下所示:
export type NetData = (string | ArrayBufferLike | Blob | ArrayBufferView); // 協議輔助介面 export interface IProtocolHelper { getHeadlen(): number; // 返回包頭長度 getHearbeat(): NetData; // 返回一個心跳包 getPackageLen(msg: NetData): number; // 返回整個包的長度 checkPackage(msg: NetData): boolean; // 檢查包數據是否合法 getPackageId(msg: NetData): number; // 返回包的id或協議類型 }
Socket
在這裡定義了一個 ISocket 的簡單介面,如下所示:
// Socket介面 export interface ISocket { onConnected: (event) => void; // 連接回調 onMessage: (msg: NetData) => void; // 消息回調 onError: (event) => void; // 錯誤回調 onClosed: (event) => void; // 關閉回調 connect(ip: string, port: number); // 連接介面 send(buffer: NetData); // 數據發送介面 close(code?: number, reason?: string); // 關閉介面 }
接下來我們實現一個 WebSock,繼承於 ISocket,我們只需要實現 connect、send 和 close 介面即可。send 和 close 都是對 websocket 對簡單封裝,connect 則需要根據傳入的 ip、埠等參數構造一個 url 來創建 websocket,並綁定 websocket 的回調。
export class WebSock implements ISocket { private _ws: WebSocket = null; // websocket對象 onConnected: (event) => void = null; onMessage: (msg) => void = null; onError: (event) => void = null; onClosed: (event) => void = null; connect(options: any) { if (this._ws) { if (this._ws.readyState === WebSocket.CONNECTING) { console.log("websocket connecting, wait for a moment...") return false; } } let url = null; if(options.url) { url = options.url; } else { let ip = options.ip; let port = options.port; let protocol = options.protocol; url = `${protocol}://${ip}:${port}`; } this._ws = new WebSocket(url); this._ws.binaryType = options.binaryType ? options.binaryType : "arraybuffer"; this._ws.onmessage = (event) => { this.onMessage(event.data); }; this._ws.onopen = this.onConnected; this._ws.onerror = this.onError; this._ws.onclose = this.onClosed; return true; } send(buffer: NetData) { if (this._ws.readyState == WebSocket.OPEN) { this._ws.send(buffer); return true; } return false; } close(code?: number, reason?: string) { this._ws.close(); } }
NetworkTips
INetworkTips 提供了非常的介面,重連和請求的開關,框架會在合適的時機調用它們,我們可以繼承 INetworkTips 並訂製我們的網路相關提示資訊,需要注意的是這些介面可能會被**多次調用**。
// 網路提示介面 export interface INetworkTips { connectTips(isShow: boolean): void; reconnectTips(isShow: boolean): void; requestTips(isShow: boolean): void; }
NetNode
NetNode 是整個網路框架中最為關鍵的部分,一個 NetNode 實例表示一個完整的連接對象,基於 NetNode 我們可以方便地進行擴展,它的主要職責有:
連接維護
- 連接的建立與鑒權(是否鑒權、如何鑒權由用戶的回調決定)
- 斷線重連後的數據重發處理
- 心跳機制確保連接有效(心跳包間隔由配置,心跳包的內容由ProtocolHelper定義)
- 連接的關閉
數據發送
- 支援斷線重傳,超時重傳
- 支援唯一發送(避免同一時間重複發送)
數據接收
- 支援持續監聽
- 支援request-respone模式
介面展示
- 可自定義網路延遲、短線重連等狀態的表現
首先我們定義了 NetTipsType、NetNodeState 兩個枚舉,以及 NetConnectOptions 結構供 NetNode 使用。
接下來是 NetNode 的成員變數,NetNode 的變數可以分為以下幾類:
- NetNode 自身的狀態變數,如 ISocket 對象、當前狀態、連接參數等等。
- 各種回調,包括連接、斷開連接、協議處理、網路提示等回調。
- 各種定時器,如心跳、重連相關的定時器。
- 請求列表與監聽列表,都是用於接收到的消息處理。
接下來介紹網路相關的成員函數,首先看初始化與:
- init 方法用於初始化 NetNode,主要是指定 Socket 與協議等處理對象。
- connect 方法用於連接伺服器。
- initSocket 方法用於綁定 Socket 的回調到 NetNode 中。
- updateNetTips 方法用於刷新網路提示。
onConnected 方法在網路連接成功後調用,自動進入鑒權流程(如果設置了_connectedCallback),在鑒權完成後需要調用 onChecked 方法使 NetNode 進入可通訊的狀態,在未鑒權的情況,我們不應該發送任何業務請求,但登錄驗證這類請求應該發送給伺服器,這類請求可以通過帶force參數強制發送給伺服器。
接收到任何消息都會觸發 onMessage,首先會對數據包進行校驗,校驗的規則可以在自己的 ProtocolHelper 中實現,如果是一個合法的數據包,我們會將心跳和超時計時器進行更新——重新計時,最後在 _requests 和 _listener 中找到該消息的處理函數,這裡是通過 rspCmd 進行查找的,rspCmd 是從 ProtocolHelper 的 getPackageId 取出的,我們可以將協議的命令或者序號返回,由我們自己來決定請求和響應如何對應。
onError 和 onClosed 是網路出錯和關閉時調用的,無論是否出錯,最終都會調用 onClosed,在這裡我們執行斷線回調,以及做自動重連的處理。當然也可以調用 close來關閉套接字。close 與 closeSocket 的區別在於 closeSocket 只是關閉套接字——我仍然要使用當前的 NetNode,可能通過下一次 connect 恢復網路。而 close則是清除所有的狀態。
發起網路請求有3種方式:
send 方法,純粹地發送數據,如果當前斷網或者驗證中會進入 _request 隊列。
request 方法,在請求的時候即以閉包的方式傳入回調,在該請求的響應回到時會執行回調,如果同時有多個相同的請求,那麼這 N 個請求的響應會依次回到客戶端,響應回調也會依次執行(每次只會執行一個回調)。
requestUnique 方法,如果我們不希望有多個相同的請求,可以使用 requestUnique 來確保每一種請求同時只會有一個。
這裡確保沒有重複之所以使用的是遍歷 _requests,是因為我們不會積壓大量的請求到 _requests中,超時或異常重發也不會導致 _requests 的積壓,因為重發的邏輯是由 NetNode 控制的,而且在網路斷開的情況下,我們理應屏蔽用戶發起請求,此時一般會有一個全螢幕遮罩——網路出現波動之類的提示。
我們有2種回調,一種是前面的 request 回調,這種回調是臨時性的,一般隨著請求-響應-執行而立即清理,_listener 回調則是常駐的,需要我們手動管理的,比如打開某介面時監聽、離開是關閉,或者在遊戲一開始就進行監聽。適合處理伺服器的主動推送消息。
最後是心跳與超時相關的定時器,我們每隔 _heartTime 會發送一個心跳包,每隔 _receiveTime 檢測如果沒有收到伺服器返回的包,則判斷網路斷開。
完整程式碼,大家可以進入源碼查看!
NetManager
NetManager 用於管理 NetNode,這是由於我們可能需要支援多個不同的連接對象,所以需要一個 NetManager 專門來管理 NetNode,同時,NetManager 作為一個單例,也可以方便我們調用網路。
export class NetManager { private static _instance: NetManager = null; protected _channels: { [key: number]: NetNode } = {}; public static getInstance(): NetManager { if (this._instance == null) { this._instance = new NetManager(); } return this._instance; } // 添加Node,返回ChannelID public setNetNode(newNode: NetNode, channelId: number = 0) { this._channels[channelId] = newNode; } // 移除Node public removeNetNode(channelId: number) { delete this._channels[channelId]; } // 調用Node連接 public connect(options: NetConnectOptions, channelId: number = 0): boolean { if (this._channels[channelId]) { return this._channels[channelId].connect(options); } return false; } // 調用Node發送 public send(buf: NetData, force: boolean = false, channelId: number = 0): boolean { let node = this._channels[channelId]; if(node) { return node.send(buf, force); } return false; } // 發起請求,並在在結果返回時調用指定好的回調函數 public request(buf: NetData, rspCmd: number, rspObject: CallbackObject, showTips: boolean = true, force: boolean = false, channelId: number = 0) { let node = this._channels[channelId]; if(node) { node.request(buf, rspCmd, rspObject, showTips, force); } } // 同request,但在request之前會先判斷隊列中是否已有rspCmd,如有重複的則直接返回 public requestUnique(buf: NetData, rspCmd: number, rspObject: CallbackObject, showTips: boolean = true, force: boolean = false, channelId: number = 0): boolean { let node = this._channels[channelId]; if(node) { return node.requestUnique(buf, rspCmd, rspObject, showTips, force); } return false; } // 調用Node關閉 public close(code?: number, reason?: string, channelId: number = 0) { if (this._channels[channelId]) { return this._channels[channelId].closeSocket(code, reason); } }
測試例子
接下來我們用一個簡單的例子來演示一下網路框架的基本使用,首先我們需要拼一個簡單的介面用於展示,3個按鈕(連接、發送、關閉),2個輸入框(輸入 url、輸入要發送的內容),一個文本框(顯示從伺服器接收到的數據),如下圖所示。
該例子連接的是 websocket 官方的 echo.websocket.org 地址,這個伺服器會將我們發送給它的所有消息都原樣返回給我們。

接下來,實現一個簡單的 Component,這裡新建了一個 NetExample.ts 文件,做的事情非常簡單,在初始化的時候創建 NetNode、綁定默認接收回調,在接收回調中將伺服器返回的文本顯示到 msgLabel中。接著是連接、發送和關閉幾個介面的實現:
// 不關鍵的程式碼省略 @ccclass export default class NetExample extends cc.Component { @property(cc.Label) textLabel: cc.Label = null; @property(cc.Label) urlLabel: cc.Label = null; @property(cc.RichText) msgLabel: cc.RichText = null; private lineCount: number = 0; onLoad() { let Node = new NetNode(); Node.init(new WebSock(), new DefStringProtocol()); Node.setResponeHandler(0, (cmd: number, data: NetData) => { if (this.lineCount > 5) { let idx = this.msgLabel.string.search("n"); this.msgLabel.string = this.msgLabel.string.substr(idx + 1); } this.msgLabel.string += `${data}n`; ++this.lineCount; }); NetManager.getInstance().setNetNode(Node); } onConnectClick() { NetManager.getInstance().connect({ url: this.urlLabel.string }); } onSendClick() { NetManager.getInstance().send(this.textLabel.string); } onDisconnectClick() { NetManager.getInstance().close(); } }
程式碼完成後,將其掛載到場景的 Canvas 節點下(其他節點也可以),然後將場景中的 Label 和 RichText 拖拽到我們的 NetExample 的屬性面板中:

運行效果如下所示:

小結
可以看到,Websocket 的使用很簡單,我們在開發的過程中會碰到各種各樣的需求和問題,要實現一個好的設計,快速地解決問題。
我們一方面需要對我們使用的技術本身有深入的理解,websocket 的底層協議傳輸是如何實現的?與 tcp、http 的區別在哪裡?基於 websocket 能否使用 udp 進行傳輸呢?使用 websocket 發送數據是否需要自己對數據流進行分包(websocket 協議保證了包的完整)?數據的發送是否出現了發送快取的堆積(查看 bufferedAmount)?
另外需要對我們的使用場景及需求本身的理解,對需求的理解越透徹,越能做出好的設計。哪些需求是項目相關的,哪些需求是通用的?通用的需求是必須的還是可選的?不同的變化我們應該封裝成類或介面,使用多態的方式來實現呢?還是提供配置?回調綁定?事件通知?
我們需要設計出一個好的框架,來適用於下一個項目,並且在一個一個的項目中優化迭代,這樣才能建立深厚的沉澱、提高效率。
接下來的一段時間,我會將之前的一些經驗整理為一個開源易用的 Cocos Creator 框架。
源碼鏈接:
https://github.com/wyb10a10/cocos_creator_framework
———— / END / ————
再次向作者寶爺的分享致以謝意!