CocosCreator通用框架設計之網絡
前言
在 Cocos Creator 中發起一個 http 請求是比較簡單的,但很多遊戲希望能夠和服務器之間保持長連接,以便服務端能夠主動向客戶端推送消息,而非總是由客戶端發起請求,對於實時性要求較高的遊戲更是如此。這裡我們會設計一個通用的網絡框架,可以方便地應用於我們的項目中。
使用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 的網址。
設計框架
一個通用的網絡框架,在通用的前提下還需要能夠支持各種項目的差異需求,根據經驗,常見的需求差異如下:
- 用戶協議差異,遊戲可能傳輸 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中。接著是連接、發送和關閉幾個接口的實現:
// 不關鍵的代碼省略 @ccclassexport 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)?
另外需要對我們的使用場景及需求本身的理解,對需求的理解越透徹,越能做出好的設計。哪些需求是項目相關的,哪些需求是通用的?通用的需求是必須的還是可選的?不同的變化我們應該封裝成類或接口,使用多態的方式來實現呢?還是提供配置?回調綁定?事件通知?
我們需要設計出一個好的框架,來適用於下一個項目,並且在一個一個的項目中優化迭代,這樣才能建立深厚的沉淀、提高效率。
以上就是CocosCreator通用框架設計之網絡的詳細內容,更多關於CocosCreator框架設計之網絡的資料請關註WalkonNet其它相關文章!
推薦閱讀:
- None Found