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