1. 程式人生 > >Cocos Creator 通用框架設計 —— 網路

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個,比如一個主連線負責處理大廳等業務訊息,一個戰鬥連線直接連戰鬥伺服器,或者連線聊天伺服器。

根據上面的這些需求,我們對功能模組進行拆分,儘量保證模組的高內聚,低耦合。

  • 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地址,這個伺服器會將我們傳送給它的所有訊息都原樣返回給我們。

接下來,實現一個簡單的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