协议:必选项,默认是 ws 协议,如果需要安全加密则使用 wss。
地址:必选项,可以是 ip 或域名,当然建议使用域名。
端口:可选项,在不指定的情况下,ws 的默认端口为 80,wss 的默认端口为 443。
资源:可选性,一般是跟在域名后某资源路径,我们基本不需要它。
0 CONNECTING 尚未建立连接。
1 OPEN WebSocket连接已建立,可以进行通信。
2 CLOSING 连接正在进行关闭握手,或者该close()方法已被调用。
3 CLOSED 连接已关闭。
如果要发送的数据是二进制,我们可以通过 websocket 对象的 binaryType 属性来指定二进制的类型,binaryType 只可以被设置为“blob”或“arraybuffer”,默认为“blob”。如果我们要传输的是文件这样较为固定的、用于写入到磁盘的数据,使用 blob。而你希望传输的对象在内存中进行处理则使用较为灵活的 arraybuffer。如果要从其他非 blob 对象和数据构造一个 blob,需要使用 blob 的构造函数。在发送数据时,官方有2个建议:
检测 websocket 对象的 readyState 是否为 OPEN,是才进行 send。
检测 websocket 对象的 bufferedAmount 是否为0,是才进行 send(为了避免消息堆积,该属性表示调用 send 后堆积在 websocket 缓冲区的还未真正发送出去的数据长度)。
onopen:连接成功后调用。
onmessage:有消息过来时调用:传入的对象有 data 属性,可能是字符串、blob 或 arraybuffer。
onerror:出现网络错误时调用:传入的对象有 data 属性,通常是错误描述的字符串。
onclose:连接关闭时调用:传入的对象有 code、reason、wasClean 等属性。
注意:当网络出错时,会先调用 onerror 再调用 onclose,无论何种原因的连接关闭,onclose 都会被调用。
默认的 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个,比如一个主连接负责处理大厅等业务消息,一个战斗连接直接连战斗服务器,或者连接聊天服务器。
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接口
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();
}
}
// 网络提示接口
export interface INetworkTips {
connectTips(isShow: boolean): void;
reconnectTips(isShow: boolean): void;
requestTips(isShow: boolean): void;
}
连接的建立与鉴权(是否鉴权、如何鉴权由用户的回调决定)
断线重连后的数据重发处理
心跳机制确保连接有效(心跳包间隔由配置,心跳包的内容由ProtocolHelper定义)
连接的关闭
支持断线重传,超时重传
支持唯一发送(避免同一时间重复发送)
支持持续监听
支持request-respone模式
可自定义网络延迟、短线重连等状态的表现
NetNode 自身的状态变量,如 ISocket 对象、当前状态、连接参数等等。
各种回调,包括连接、断开连接、协议处理、网络提示等回调。
各种定时器,如心跳、重连相关的定时器。
请求列表与监听列表,都是用于接收到的消息处理。
init 方法用于初始化 NetNode,主要是指定 Socket 与协议等处理对象。
connect 方法用于连接服务器。
initSocket 方法用于绑定 Socket 的回调到 NetNode 中。
updateNetTips 方法用于刷新网络提示。
这里确保没有重复之所以使用的是遍历 _requests,是因为我们不会积压大量的请求到 _requests中,超时或异常重发也不会导致 _requests 的积压,因为重发的逻辑是由 NetNode 控制的,而且在网络断开的情况下,我们理应屏蔽用户发起请求,此时一般会有一个全屏遮罩——网络出现波动之类的提示。我们有2种回调,一种是前面的 request 回调,这种回调是临时性的,一般随着请求-响应-执行而立即清理,_listener 回调则是常驻的,需要我们手动管理的,比如打开某界面时监听、离开是关闭,或者在游戏一开始就进行监听。适合处理服务器的主动推送消息。 最后是心跳与超时相关的定时器,我们每隔 _heartTime 会发送一个心跳包,每隔 _receiveTime 检测如果没有收到服务器返回的包,则判断网络断开。 完整代码,大家可以进入源码查看!
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);
}
}
该例子连接的是 websocket 官方的 echo.websocket.org 地址,这个服务器会将我们发送给它的所有消息都原样返回给我们。
// 不关键的代码省略
@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 的属性面板中:
大神驾到 | 盛大锦天大神,做客Creator星球
大神驾到 | 无限循环的ScrollView
大神驾到 | 天天酷跑视频教程,送资源与源码!
大神驾到 |「大掌教」Cocos3D组件详解