天天看點

Cocos Creator 通用架構設計 —— 網絡

在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.html 。

預設的url字首是wss,由于wss抽風,使用ws才可以連接配接上,如果ws也抽風,可以試試連這個位址ws://121.40.165.18:8800,這是國内的一個免費測試websocket的網址。
<!DOCTYPE html>
  <meta charset="utf-8" />
  <title>WebSocket Test</title>
  <script language="javascript" type="text/javascript">

  var wsUri = "ws://echo.websocket.org/";
  var output;

  function init() {
    output = document.getElementById("output");
    testWebSocket();
  }

  function testWebSocket() {
    // 初始化websocket,綁定回調
    websocket = new WebSocket(wsUri);
    websocket.onopen = onOpen;
    websocket.onclose = onClose;
    websocket.onmessage = onMessage;
    websocket.onerror = onError;
  }

  function onOpen(evt) {
    writeToScreen("CONNECTED");
    doSend("WebSocket rocks");
  }

  function onClose(evt) {
    writeToScreen("DISCONNECTED");
  }

  function onMessage(evt) {
    writeToScreen('<span style="color: blue;">RESPONSE: ' + evt.data+'</span>');
    websocket.close();
  }

  function onError(evt) {
    writeToScreen('<span style="color: red;">ERROR:</span> ' + evt.data);
  }

  function doSend(message) {
    writeToScreen("SENT: " + message);
    websocket.send(message);
  }

  function writeToScreen(message) {
    var pre = document.createElement("p");
    pre.style.wordWrap = "break-word";
    pre.innerHTML = message;
    output.appendChild(pre);
  }

  // 加載時調用init方法,初始化websocket
  window.addEventListener("load", init, false);
  </script>
  
  <h2>WebSocket Test</h2>
  <div id="output"></div>
           

參考

  • https://www.w3.org/TR/websockets/
  • https://developer.mozilla.org/en-US/docs/Web/API/Blob
  • http://www.websocket.org/echo.html
  • http://www.websocket-test.com/

設計架構

一個通用的網絡架構,在通用的前提下還需要能夠支援各種項目的差異需求,根據經驗,常見的需求差異如下:

  • 使用者協定差異,遊戲可能傳輸json、protobuf、flatbuffer或者自定義的二進制協定
  • 底層協定差異,我們可能使用websocket、或者微信小遊戲的wx.websocket、甚至在原生平台我們希望使用tcp/udp/kcp等協定
  • 登陸認證流程,在使用長連接配接之前我們理應進行登陸認證,而不同遊戲登陸認證的方式不同
  • 網絡異常處理,比如逾時時間是多久,逾時後的表現是怎樣的,請求時是否應該屏蔽UI等待伺服器響應,網絡斷開後表現如何,自動重連還是由玩家點選重連按鈕進行重連,重連之後是否重發斷網期間的消息?等等這些。
  • 多連接配接的處理,某些遊戲可能需要支援多個不同的連接配接,一般不會超過2個,比如一個主連接配接負責處理大廳等業務消息,一個戰鬥連接配接直接連戰鬥伺服器,或者連接配接聊天伺服器。

根據上面的這些需求,我們對功能子產品進行拆分,盡量保證子產品的高内聚,低耦合。

Cocos Creator 通用架構設計 —— 網絡
  • 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模式
  • 界面展示
    • 可自定義網絡延遲、短線重連等狀态的表現

以下是NetNode的完整代碼:

export enum NetTipsType {
    Connecting,
    ReConnecting,
    Requesting,
}

export enum NetNodeState {
    Closed,                     // 已關閉
    Connecting,                 // 連接配接中
    Checking,                   // 驗證中
    Working,                    // 可傳輸資料
}

export interface NetConnectOptions {
    host?: string,              // 位址
    port?: number,              // 端口
    url?: string,               // url,與位址+端口二選一
    autoReconnect?: number,     // -1 永久重連,0不自動重連,其他正整數為自動重試次數
}

export class NetNode {
    protected _connectOptions: NetConnectOptions = null;
    protected _autoReconnect: number = 0;
    protected _isSocketInit: boolean = false;                               // Socket是否初始化過
    protected _isSocketOpen: boolean = false;                               // Socket是否連接配接成功過
    protected _state: NetNodeState = NetNodeState.Closed;                   // 節點目前狀态
    protected _socket: ISocket = null;                                      // Socket對象(可能是原生socket、websocket、wx.socket...)

    protected _networkTips: INetworkTips = null;                            // 網絡提示ui對象(請求提示、斷線重連提示等)
    protected _protocolHelper: IProtocolHelper = null;                      // 包解析對象
    protected _connectedCallback: CheckFunc = null;                         // 連接配接完成回調
    protected _disconnectCallback: BoolFunc = null;                         // 斷線回調
    protected _callbackExecuter: ExecuterFunc = null;                       // 回調執行

    protected _keepAliveTimer: any = null;                                  // 心跳定時器
    protected _receiveMsgTimer: any = null;                                 // 接收資料定時器
    protected _reconnectTimer: any = null;                                  // 重連定時器
    protected _heartTime: number = 10000;                                   // 心跳間隔
    protected _receiveTime: number = 6000000;                               // 多久沒收到資料斷開
    protected _reconnetTimeOut: number = 8000000;                           // 重連間隔
    protected _requests: RequestObject[] = Array<RequestObject>();          // 請求清單
    protected _listener: { [key: number]: CallbackObject[] } = {}           // 監聽者清單

    /********************** 網絡相關處理 *********************/
    public init(socket: ISocket, protocol: IProtocolHelper, networkTips: any = null, execFunc : ExecuterFunc = null) {
        console.log(`NetNode init socket`);
        this._socket = socket;
        this._protocolHelper = protocol;
        this._networkTips = networkTips;
        this._callbackExecuter = execFunc ? execFunc : (callback: CallbackObject, buffer: NetData) => {
            callback.callback.call(callback.target, 0, buffer);
        }
    }

    public connect(options: NetConnectOptions): boolean {
        if (this._socket && this._state == NetNodeState.Closed) {
            if (!this._isSocketInit) {
                this.initSocket();
            }
            this._state = NetNodeState.Connecting;
            if (!this._socket.connect(options)) {
                this.updateNetTips(NetTipsType.Connecting, false);
                return false;
            }

            if (this._connectOptions == null) {
                options.autoReconnect = options.autoReconnect;
            }
            this._connectOptions = options;
            this.updateNetTips(NetTipsType.Connecting, true);
            return true;
        }
        return false;
    }

    protected initSocket() {
        this._socket.onConnected = (event) => { this.onConnected(event) };
        this._socket.onMessage = (msg) => { this.onMessage(msg) };
        this._socket.onError = (event) => { this.onError(event) };
        this._socket.onClosed = (event) => { this.onClosed(event) };
        this._isSocketInit = true;
    }

    protected updateNetTips(tipsType: NetTipsType, isShow: boolean) {
        if (this._networkTips) {
            if (tipsType == NetTipsType.Requesting) {
                this._networkTips.requestTips(isShow);
            } else if (tipsType == NetTipsType.Connecting) {
                this._networkTips.connectTips(isShow);
            } else if (tipsType == NetTipsType.ReConnecting) {
                this._networkTips.reconnectTips(isShow);
            }
        }
    }

    // 網絡連接配接成功
    protected onConnected(event) {
        console.log("NetNode onConnected!")
        this._isSocketOpen = true;
        // 如果設定了鑒權回調,在連接配接完成後進入鑒權階段,等待鑒權結束
        if (this._connectedCallback !== null) {
            this._state = NetNodeState.Checking;
            this._connectedCallback(() => { this.onChecked() });
        } else {
            this.onChecked();
        }
        console.log("NetNode onConnected! state =" + this._state);
    }

    // 連接配接驗證成功,進入工作狀态
    protected onChecked() {
        console.log("NetNode onChecked!")
        this._state = NetNodeState.Working;
        // 關閉連接配接或重連中的狀态顯示
        this.updateNetTips(NetTipsType.Connecting, false);
        this.updateNetTips(NetTipsType.ReConnecting, false);

        // 重發待發送資訊
        console.log(`NetNode flush ${this._requests.length} request`)
        if (this._requests.length > 0) {
            for (var i = 0; i < this._requests.length;) {
                let req = this._requests[i];
                this._socket.send(req.buffer);
                if (req.rspObject == null || req.rspCmd <= 0) {
                    this._requests.splice(i, 1);
                } else {
                    ++i;
                }
            }
            // 如果還有等待傳回的請求,啟動網絡請求層
            this.updateNetTips(NetTipsType.Requesting, this.request.length > 0);
        }
    }

    // 接收到一個完整的消息包
    protected onMessage(msg): void {
        // console.log(`NetNode onMessage status = ` + this._state);
        // 進行頭部的校驗(實際包長與頭部長度是否比對)
        if (!this._protocolHelper.check P a c ka ge(msg)) {
            console.error(`NetNode checkHead Error`);
            return;
        }
        // 接受到資料,重新定時收資料計時器
        this.resetReceiveMsgTimer();
        // 重置心跳包發送器
        this.resetHearbeatTimer();
        // 觸發消息執行
        let rspCmd = this._protocolHelper.getPackageId(msg);
        console.log(`NetNode onMessage rspCmd = ` + rspCmd);
        // 優先觸發request隊列
        if (this._requests.length > 0) {
            for (let reqIdx in this._requests) {
                let req = this._requests[reqIdx];
                if (req.rspCmd == rspCmd) {
                    console.log(`NetNode execute request rspcmd ${rspCmd}`);
                    this._callbackExecuter(req.rspObject, msg);
                    this._requests.splice(parseInt(reqIdx), 1);
                    break;
                }
            }
            console.log(`NetNode still has ${this._requests.length} request watting`);
            if (this._requests.length == 0) {
                this.updateNetTips(NetTipsType.Requesting, false);
            }
        }

        let listeners = this._listener[rspCmd];
        if (null != listeners) {
            for (const rsp of listeners) {
                console.log(`NetNode execute listener cmd ${rspCmd}`);
                this._callbackExecuter(rsp, msg);
            }
        }
    }

    protected onError(event) {
        console.error(event);
    }

    protected onClosed(event) {
        this.clearTimer();

        // 執行斷線回調,傳回false表示不進行重連
        if (this._disconnectCallback && !this._disconnectCallback()) {
            console.log(`disconnect return!`)
            return;
        }

        // 自動重連
        if (this.isAutoReconnect()) {
            this.updateNetTips(NetTipsType.ReConnecting, true);
            this._reconnectTimer = setTimeout(() => {
                this._socket.close();
                this._state = NetNodeState.Closed;
                this.connect(this._connectOptions);
                if (this._autoReconnect > 0) {
                    this._autoReconnect -= 1;
                }
            }, this._reconnetTimeOut);
        } else {
            this._state = NetNodeState.Closed;
        }
    }

    public close(code?: number, reason?: string) {
        this.clearTimer();
        this._listener = {};
        this._requests.length = 0;
        if (this._networkTips) {
            this._networkTips.connectTips(false);
            this._networkTips.reconnectTips(false);
            this._networkTips.requestTips(false);
        }
        if (this._socket) {
            this._socket.close(code, reason);
        } else {
            this._state = NetNodeState.Closed;
        }
    }

    // 隻是關閉Socket套接字(仍然重用緩存與目前狀态)
    public closeSocket(code?: number, reason?: string) {
        if (this._socket) {
            this._socket.close(code, reason);
        }
    }

    // 發起請求,如果目前處于重連中,進入緩存清單等待重連完成後發送
    public send(buf: NetData, force: boolean = false): boolean {
        if (this._state == NetNodeState.Working || force) {
            console.log(`socket send ...`);
            return this._socket.send(buf);
        } else if (this._state == NetNodeState.Checking ||
            this._state == NetNodeState.Connecting) {
            this._requests.push({
                buffer: buf,
                rspCmd: 0,
                rspObject: null
            });
            console.log("NetNode socket is busy, push to send buffer, current state is " + this._state);
            return true;
        } else {
            console.error("NetNode request error! current state is " + this._state);
            return false;
        }
    }

    // 發起請求,并進入緩存清單
    public request(buf: NetData, rspCmd: number, rspObject: CallbackObject, showTips: boolean = true, force: boolean = false) {
        if (this._state == NetNodeState.Working || force) {
            this._socket.send(buf);
        }
        console.log(`NetNode request with timeout for ${rspCmd}`);
        // 進入發送緩存清單
        this._requests.push({
            buffer: buf, rspCmd, rspObject
        });
        // 啟動網絡請求層
        if (showTips) {
            this.updateNetTips(NetTipsType.Requesting, true);
        }
    }

    // 唯一request,確定沒有同一響應的請求(避免一個請求重複發送,netTips界面的屏蔽也是一個好的方法)
    public requestUnique(buf: NetData, rspCmd: number, rspObject: CallbackObject, showTips: boolean = true, force: boolean = false): boolean {
        for (let i = 0; i < this._requests.length; ++i) {
            if (this._requests[i].rspCmd == rspCmd) {
                console.log(`NetNode requestUnique faile for ${rspCmd}`);
                return false;
            }
        }
        this.request(buf, rspCmd, rspObject, showTips, force);
        return true;
    }

    /********************** 回調相關處理 *********************/
    public setResponeHandler(cmd: number, callback: NetCallFunc, target?: any): boolean {
        if (callback == null) {
            console.error(`NetNode setResponeHandler error ${cmd}`);
            return false;
        }
        this._listener[cmd] = [{ target, callback }];
        return true;
    }

    public addResponeHandler(cmd: number, callback: NetCallFunc, target?: any): boolean {
        if (callback == null) {
            console.error(`NetNode addResponeHandler error ${cmd}`);
            return false;
        }
        let rspObject = { target, callback };
        if (null == this._listener[cmd]) {
            this._listener[cmd] = [rspObject];
        } else {
            let index = this.getNetListenersIndex(cmd, rspObject);
            if (-1 == index) {
                this._listener[cmd].push(rspObject);
            }
        }
        return true;
    }

    public removeResponeHandler(cmd: number, callback: NetCallFunc, target?: any) {
        if (null != this._listener[cmd] && callback != null) {
            let index = this.getNetListenersIndex(cmd, { target, callback });
            if (-1 != index) {
                this._listener[cmd].splice(index, 1);
            }
        }
    }

    public cleanListeners(cmd: number = -1) {
        if (cmd == -1) {
            this._listener = {}
        } else {
            this._listener[cmd] = null;
        }
    }

    protected getNetListenersIndex(cmd: number, rspObject: CallbackObject): number {
        let index = -1;
        for (let i = 0; i < this._listener[cmd].length; i++) {
            let iterator = this._listener[cmd][i];
            if (iterator.callback == rspObject.callback
                && iterator.target == rspObject.target) {
                index = i;
                break;
            }
        }
        return index;
    }

    /********************** 心跳、逾時相關處理 *********************/
    protected resetReceiveMsgTimer() {
        if (this._receiveMsgTimer !== null) {
            clearTimeout(this._receiveMsgTimer);
        }

        this._receiveMsgTimer = setTimeout(() => {
            console.warn("NetNode recvieMsgTimer close socket!");
            this._socket.close();
        }, this._receiveTime);
    }

    protected resetHearbeatTimer() {
        if (this._keepAliveTimer !== null) {
            clearTimeout(this._keepAliveTimer);
        }

        this._keepAliveTimer = setTimeout(() => {
            console.log("NetNode keepAliveTimer send Hearbeat")
            this.send(this._protocolHelper.getHearbeat());
        }, this._heartTime);
    }

    protected clearTimer() {
        if (this._receiveMsgTimer !== null) {
            clearTimeout(this._receiveMsgTimer);
        }
        if (this._keepAliveTimer !== null) {
            clearTimeout(this._keepAliveTimer);
        }
        if (this._reconnectTimer !== null) {
            clearTimeout(this._reconnectTimer);
        }
    }

    public isAutoReconnect() {
        return this._autoReconnect != 0;
    }

    public rejectReconnect() {
        this._autoReconnect = 0;
        this.clearTimer();
    }
}
           

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位址,這個伺服器會将我們發送給它的所有消息都原樣傳回給我們。
Cocos Creator 通用架構設計 —— 網絡

接下來,實作一個簡單的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的屬性面闆中:

Cocos Creator 通用架構設計 —— 網絡

運作效果如下所示:

Cocos Creator 通用架構設計 —— 網絡

小結

可以看到,Websocket的使用很簡單,我們在開發的過程中會碰到各種各樣的需求和問題,要實作一個好的設計,快速地解決問題。

我們一方面需要對我們使用的技術本身有深入的了解,websocket的底層協定傳輸是如何實作的?與tcp、http的差別在哪裡?基于websocket能否使用udp進行傳輸呢?使用websocket發送資料是否需要自己對資料流進行分包(websocket協定保證了包的完整)?資料的發送是否出現了發送緩存的堆積(檢視bufferedAmount)?

另外需要對我們的使用場景及需求本身的了解,對需求的了解越透徹,越能做出好的設計。哪些需求是項目相關的,哪些需求是通用的?通用的需求是必須的還是可選的?不同的變化我們應該封裝成類或接口,使用多态的方式來實作呢?還是提供配置?回調綁定?事件通知?

我們需要設計出一個好的架構,來适用于下一個項目,并且在一個一個的項目中優化疊代,這樣才能建立深厚的沉澱、提高效率。

接下來的一段時間會将之前的一些經驗整理為一個開源易用的cocos creator架構:https://github.com/wyb10a10/cocos_creator_framework

繼續閱讀