做 Laya 项目的朋友Socket 这块迟早要碰。不管是棋牌里的发牌、对战房间里的同步还是大厅里的聊天推送HTTP 那一套在服务端主动推数据的时候完全使不上劲。我最初用 Laya 做弱联网功能时也是一上来就 HTTP 轮询能用是能用但延迟和流量都很难看后来换成长连接之后才真正把实时性做出来。这篇文章就围绕 Laya Socket 这套组合讲一套能落地、能复用的方案从选型到组包分包、心跳重连再到真机排坑全部串起来。适合刚入门的 H5 游戏开发新手也适合想优化现有网络层的朋友参考。1. 项目概述与Socket选型思路1.1 什么场景下 Laya 项目需要长连接很多人第一次接触 Socket 是因为项目里要做实时对战。比如棋牌游戏的房间机制玩家出牌、抢地主、发牌结果都得在几秒内同步到所有人再比如 MOBA 或者 SLG 的同屏战斗每个单位的位置、血量、技能释放都依赖服务端把状态实时刷下来。此时 HTTP 的请求-响应模型就显得很笨重客户端要不停轮询服务端拿着数据也只能等客户端来取实时性差服务器压力还大。除了对战大厅场景同样离不开长连接。排行榜刷新、邮件送达、全服公告、活动推送这些都是服务端主动触发的消息。你用 HTTP 轮询也能实现但每 5 秒拉一次全量数据流量和耗电都扛不住。用长连接之后服务端有消息就往下推客户端不需要反复发请求网络开销小一个量级。我自己的经验判断标准很简单只要服务端需要主动找客户端或者双方需要高频互传就应该上 Socket。典型的适用场景包括实时聊天、战斗同步、全局广播、实时排行榜、在线状态上报。而登录鉴权、头像上传、商城购买这类低频且天然的请求-响应操作继续走 HTTP 反而更合适。二者不是互斥关系很多项目是 HTTP 做入口Socket 做实时通道。1.2 选型WebSocket 还是 TCPLaya 里的Laya.Socket类其实是一层封装它既能跑在 Web 环境下的 WebSocket 上也能在 LayaNative 等原生环境里走原生 TCP。这里必须先说清楚H5 游戏跑在浏览器里时Laya.Socket默认就是 WebSocket因为浏览器根本不给你开裸 TCP 的口子。只有到了 LayaNative 的原生壳里才有直连 TCP 的可能。所以选型不是纯技术口味问题而是由发布目标决定的。如果你的项目要发微信小游戏、H5 页面、抖音小游戏那就老老实实用 WebSocket如果你确定只做原生 App且服务器端协议已经定好了 TCP 私有协议那可以用Laya.Socket的 TCP 模式但要做好粘包分包、断线重连、前后台切换这些繁琐处理。对比项WebSocketTCPLayaNative浏览器支持原生支持不支持协议升级基于 HTTP 握手服务端好实现需要直接处理传输层数据边界数据帧自带长度字节流无边界必须处理粘包消息类型文本帧、二进制帧纯二进制流跨域限制有 Origin 限制但服务端可控无跨域概念常用场景H5、小游戏、App 内嵌 WebView纯原生游戏客户端我的建议是除非服务器已经强制要求 TCP 并且客户端确定只有原生端否则默认 WebSocket。WebSocket 在二进制帧上也做得很好我们同样可以传Uint8Array、ArrayBuffer性能足够支撑动作游戏的网络同步。后面讲的封装方案我会直接基于Laya.Socket的 WebSocket 模式展开TCP 模式在协议处理上再补一层粘包缓冲即可。2. 环境准备与基础概念2.1 项目环境与最简单的联通测试在 LayaAir 里新建一个 TypeScript 空项目就可以开始搞网络层不需要 UI 场景直接用脚本在启动时连接。为了快速联调我先在本地起一个最简单的 WebSocket 服务端。推荐用 Node.js 的ws库几条命令就能跑起来后续随意改协议逻辑也很方便。// server.js const { WebSocketServer } require(ws); const wss new WebSocketServer({ port: 8888 }); wss.on(connection, ws { console.log(client connected); ws.send(welcome); ws.on(message, data { console.log(recv:, data.toString()); ws.send(echo: data.toString()); }); });运行node server.js端口 8888 就起来了。接着在 Laya 侧写一个最原始的连接脚本目的是验证链路通不通别上来就上封装。export class SocketDemo { private socket: Laya.Socket; public connect(): void { this.socket new Laya.Socket(); this.socket.on(Laya.Event.OPEN, this, this.onOpen); this.socket.on(Laya.Event.MESSAGE, this, this.onMessage); this.socket.on(Laya.Event.CLOSE, this, this.onClose); this.socket.on(Laya.Event.ERROR, this, this.onError); // 默认第三个参数 isWebSocket 为 true this.socket.connect(127.0.0.1, 8888); } private onOpen(): void { console.log(连接打开); this.socket.send(hello server); } private onMessage(data: any): void { console.log(收到消息:, data); } private onClose(): void { console.log(连接关闭); } private onError(e: any): void { console.log(连接错误:, e); } }这里要注意Laya.Socket.connect的第一个参数是 host第二个是端口。如果是本机调试127.0.0.1没问题如果一会儿要拿到手机上测必须换成电脑的局域网 IP而且手机和电脑要连同一个 WiFi。连不上时先用telnet 127.0.0.1 8888在电脑上验证端口是否真的通了这一步能帮你避开一半的“连不上”问题。如果在原生端打算用 TCP服务端就得换成监听 TCP 的代码。C# 里比较常见的写法是用TcpListener配合BeginReceive这种异步回调去读流。下面是个很基础的骨架方便你对照理解TcpListener listener new TcpListener(IPAddress.Any, 8888); listener.Start(); TcpClient client await listener.AcceptTcpClientAsync(); NetworkStream stream client.GetStream(); byte[] buffer new byte[4096]; int length await stream.ReadAsync(buffer, 0, buffer.Length); Console.WriteLine(Encoding.UTF8.GetString(buffer, 0, length));但我要再强调一遍这个 C# 服务器只能给原生 TCP 客户端用WebSocket 客户端连不上它。如果服务端要同时兼容 H5 和原生更加推荐在服务端做一层网关对外统一暴露 WebSocket 协议内部再转 TCP 私有协议。2.2 协议模式与 Byte 数据的基本用法Laya.Socket的connect方法有一个容易被忽略的第四参数byteClass。默认情况下WebSocket 收到文本帧时回调里的数据可能是字符串收到二进制帧时可能是ArrayBuffer。如果你显式传入Laya.Byte那么消息回调拿到的就是一个封装好的Byte对象可以直接用getUint16、getInt32这些游标式方法读数据比手动折腾ArrayBuffer舒服得多。this.socket.connect(192.168.1.100, 8888, true, Laya.Byte);用Byte的时候建议统一大小端。Laya 的Byte默认是小端也支持通过构造参数创建大端实例。我的习惯是和服务端约定好网络字节序两端都用大端避免出现同一个uint16读出来正好反了的问题。代码上可以统一用new Laya.Byte(null, true)创建大端Byte所有组包解包都走这一种别混着用。Byte常用的方法就这么几个writeUint8、writeUint16、writeInt32、writeUTFBytes负责组包getUint8、getUint16、getInt32、getUTFBytes负责解包pos表示当前读取位置length表示总长度bytesAvailable表示还没读完的字节数。后三个属性在拆包时几乎是必用的后面协议解析那段会反复出现。3. 核心实现封装一个通用的Socket管理器3.1 管理器的整体设计联网功能一旦多起来每条消息都去操作裸 socket 会把自己逼疯。我在项目里习惯把网络层收敛成一个单例管理器负责连接状态、消息收发、心跳重连、协议分发。业务层只关心语义不碰字节流。设计上需要几个东西一个状态枚举表示当前连接处于什么阶段避免重复连接一个Laya.Socket实例作为底层通道一个粘包缓冲区用来攒数据心跳定时器和重连定时器各一个。事件则通过Laya.EventDispatcher派发出去业务模块监听NetworkEvent.Connected、NetworkEvent.Message、NetworkEvent.Disconnected这些自定义事件即可。为什么做单例因为网络连接是全局资源多个界面、多个系统都要访问。如果每开一个界面都建一个 Socket端口反复占用、状态互相冲突最后一定出问题。单例加上事件派发逻辑就清晰了。3.2 连接、发送、收信完整代码下面是我在实际项目中抽出来的简化版本关键逻辑都保留着可以直接抄到自己的 Laya 项目里改。export enum NetState { None, Connecting, Connected, Closed } export class NetworkManager extends Laya.EventDispatcher { private static _inst: NetworkManager; public static get instance(): NetworkManager { if (!this._inst) this._inst new NetworkManager(); return this._inst; } private socket: Laya.Socket; private state: NetState NetState.None; private host: string ; private port: number 0; private isForceClose: boolean false; private reconnectCount: number 0; private heartbeatTimer: number -1; private reconnectTimer: number -1; public connect(host: string, port: number): void { if (this.state NetState.Connecting || this.state NetState.Connected) return; this.host host; this.port port; this.isForceClose false; this.socket new Laya.Socket(); this.socket.on(Laya.Event.OPEN, this, this.onOpen); this.socket.on(Laya.Event.MESSAGE, this, this.onMessage); this.socket.on(Laya.Event.CLOSE, this, this.onClose); this.socket.on(Laya.Event.ERROR, this, this.onError); this.state NetState.Connecting; this.socket.connect(host, port, true, Laya.Byte); } private onOpen(): void { this.state NetState.Connected; this.reconnectCount 0; this.startHeartbeat(); this.event(NetEvent.Connected); } private onMessage(data: Laya.Byte): void { // 后续拆包解析统一在这里做 ProtocolDispatcher.dispatch(data); } private onClose(): void { this.state NetState.Closed; this.stopHeartbeat(); if (this.isForceClose) { this.event(NetEvent.ClosedByUser); return; } this.event(NetEvent.Disconnected); this.scheduleReconnect(); } private onError(e: any): void { console.error(Socket error:, e); this.event(NetEvent.Error); } public sendByte(bytes: Laya.Byte): boolean { if (this.state ! NetState.Connected) { console.warn(网络未连接发送失败); return false; } this.socket.send(bytes); return true; } public close(): void { this.isForceClose true; this.stopHeartbeat(); if (this.reconnectTimer ! -1) { Laya.timer.clear(this, this.reconnectTimer); this.reconnectTimer -1; } if (this.socket) { this.socket.close(); } } private scheduleReconnect(): void { if (this.reconnectTimer ! -1) return; const delay Math.min(1000 * Math.pow(2, this.reconnectCount), 30000); this.reconnectCount; this.reconnectTimer Laya.timer.once(delay, this, () { this.reconnectTimer -1; this.connect(this.host, this.port); }); } private startHeartbeat(): void { this.stopHeartbeat(); this.heartbeatTimer Laya.timer.loop(15000, this, this.sendHeartbeat); } private stopHeartbeat(): void { if (this.heartbeatTimer ! -1) { Laya.timer.clear(this, this.heartbeatTimer); this.heartbeatTimer -1; } } private sendHeartbeat(): void { const pkg ProtocolBuilder.buildHeartbeat(); this.sendByte(pkg); } }这段代码有几个值得说的地方。第一onClose里一定要区分是主动关闭还是意外掉线。主动关闭就别重连了否则用户都退出登录了连接还自己在那折腾既浪费流量又制造诡异日志。第二重连用指数退避第一次 1 秒第二次 2 秒依次翻倍最高 30 秒。这是防止服务器短暂宕机时客户端像轰炸一样疯狂重连。第三心跳用Laya.timer.loop而不是全局setInterval因为Laya.timer会随着游戏暂停或切后台统一调度行为更可控。3.3 心跳机制与断线重连心跳的作用是保活也是探测假死连接。很多情况下TCP 连接表面上还开着但网络中间设备已经把它断了你不发数据根本发现不了。心跳包就是定时往服务端发一个固定协议号的小数据包服务器收到后回一个心跳响应。如果连续几次心跳响应都没收到就主动断开重新走连接流程。心跳间隔我一般取 15 秒不要无脑 3 秒一个。心跳太频繁会白白占用带宽尤其移动网络下省电比什么都重要。服务端的超时判断要比心跳间隔宽松比如客户端每 15 秒发一次服务端 45 秒没收到任何数据就断开这个连接。这样能容忍一两次心跳在网络里丢包。重连状态还要处理一个经典问题重连成功后服务器上可能还留着旧连接。我建议客户端重连成功后立刻发送一条认证消息带上用户 ID 和一个自增的会话序号。服务端发现同一个用户 ID 有新连接上来就主动把旧连接踢掉防止出现同一账号两个连接同时收发消息的脏数据。我在真实项目里见过两次因为没做这个处理玩家断线重连后收到别人操作消息的情况排查起来非常痛苦。4. 协议层设计与粘包分包处理4.1 为什么必须自己定义消息边界严格来说WebSocket 的数据帧本身是有边界信息的一条消息就是一帧不会把两条消息拼在一起。但如果你在 LayaNative 端用了 TCP那就是纯字节流没有边界粘包分包是必须处理的。即使只用 WebSocket我也建议在应用层加一套自己的消息头原因有三个一是为了以后兼容原生 TCP 端二是为了加协议号和消息长度做路由与校验三是为了统一加密和压缩。粘包的直观现象是服务端连续发两条消息“hello”和“world”客户端一次性收到了“helloworld”。这是因为底层 TCP 为了提高效率把小包合并成一个段发过来了。分包则是反过来的一条大消息被拆成多个 TCP 段第一次收到“hel”第二次才收到“lo”。如果不按协议解析这两类问题都会让数据变得不可读。解决办法就是固定消息头。约定一个最小包头比如前 6 个字节固定含义然后解析器先读包头再根据包头里的长度字段决定要不要继续等数据。这就是我在 4.2 要展开讲的二进制消息格式。4.2 一套实用的二进制消息格式我自己常用的格式是前 2 字节是协议号cmduint16中间 4 字节是body长度uint32后面跟着真正的消息体。包头一共 6 字节客户端每次收到数据都往缓冲区里追加然后循环解析直到缓冲区剩下的字节不够一个完整包为止。组包的代码大概长这样function buildPacket(cmd: number, body: Laya.Byte): Laya.Byte { const pkg new Laya.Byte(null, true); pkg.writeUint16(cmd); pkg.writeUint32(body.length); pkg.writeArrayBuffer(body.buffer, body.pos, body.bytesAvailable); return pkg; }注意这里如果body是一个Uint8Array直接用body.buffer可能会把byteOffset之前的无效字节也带进去。最稳妥的做法是从body这个Byte对象里用body.buffer加body.pos加body.bytesAvailable三个字段去截取有效区间。你要是直接new Uint8Array(body.buffer)很有可能多出一些脏字节导致服务端解码失败。解包的循环逻辑是核心粘包半包都在这里处理function parsePackets(buffer: Laya.Byte): void { while (buffer.bytesAvailable 6) { const startPos buffer.pos; const cmd buffer.getUint16(); const bodyLen buffer.getUint32(); if (buffer.bytesAvailable bodyLen) { // 还差数据没到回退到包头开始位置等下一帧 buffer.pos startPos; return; } // 这里按 length 读取 body const bodyBytes buffer.readInt32(); // 以实际协议要求为准 // 实际应该读取 bodyLen 长度的字节再交给协议解析 // 解析成功后继续下一轮 } }每次解析前先判断剩余字节是否够一个 6 字节包头不够就直接退出等下一帧。够的话读出版本号和长度再判断 body 是否完整。如果 body 只到了一半就把buffer.pos回退到包头开始的位置等下一次MESSAGE回调继续追加数据。这个“回退指针”的技巧是拆包处理里最容易出错的地方。很多人写完解析器明明逻辑看着没问题但数据一多就乱大概率是忘了在残缺包处回退pos。4.3 文本协议与 JSON 的取舍二进制协议不是唯一选择实际上很多项目前期就是用 JSON 文本协议快速迭代。Laya 里直接socket.send(JSON.stringify(obj))服务端收到后JSON.parse调试时一眼就能看到数据内容非常方便。WebSocket 对文本帧的支持也很成熟不存在字节序、粘包这些烦恼。但 JSON 协议的短板也很明显。第一体积大一个字段名反复出现网络传输成本比二进制高不少第二解析性能不如二进制在高频的位置同步场景里每秒几十条 JSON 解析对低端手机是压力第三弱类型结构容易让前后端字段对不上改字段名就可能引发线上问题。我的习惯是混合使用开发期和低频管理协议登录、创建房间、购买道具走 JSON方便打日志排查高频战斗同步、实时位置、帧广播走二进制协议。如果你整个项目都决定用二进制那客户端要有一套清晰的协议登记表把每个cmd数字和消息类对应起来避免团队里两个人用了同一个协议号。我见过一次线上事故两个模块协议号撞了结果玩家操作一会卡死一会掉线查了很久才发现是三字节的协议号被用了两次。5. 常见问题与排查技巧实录5.1 端口占用与服务端起不来的坑网络联调第一道坎往往不是代码而是端口。Windows 上经常看到“通常每个套接字地址(协议/网络地址/端口)只允许使用一次”这句话翻译过来就是端口被占了或者上次连接还没完全释放。常见场景是服务端代码改了Ctrl C停掉进程后立即重启结果系统还在TIME_WAIT状态端口不能立刻绑定。解决办法是服务端监听代码里设置SO_REUSEADDR或者等几十秒再重启。还有一种报错是failed to create server shutdown socket on address [localhost] and port [802]虽然这段提示看起来像某个框架在初始化 shutdown 监听时失败但本质还是端口占用或权限不足。遇到这类问题第一件事不是翻代码而是打开命令行执行netstat -ano | findstr 端口号看看是谁占着端口再用tasklist | findstr PID找到对应进程杀掉。排查顺序一定是从环境到代码别反着来。开发阶段我习惯把端口配置抽到一个配置文件里比如中央配置里的net.port 8888不要散落在各个文件。因为联调时经常要临时换端口如果忘改一个地方又会出现服务端起来了、客户端却连不上的尴尬。5.2 连接失败跨域、localhost、真机H5 端连不上服务端除了服务器没启动最常见的就是跨域。WebSocket 也有跨域限制服务器握手时可以校验Origin。你自己写的 Node.jsws服务默认是放开的但 Java、C# 写的服务端可能默认拒绝跨域。表现就是浏览器控制台报错而telnet却又通非常迷惑。此时要在服务端允许来源域名或者开发期直接放开所有Origin。真机调试是另一个大坑。手机上访问的页面如果没有做特殊配置连127.0.0.1就是手机自己而不是电脑。正确做法是连同一个局域网把连接地址改成电脑的局域网 IP。同时服务端监听地址不要只绑127.0.0.1要绑0.0.0.0否则局域网其他设备根本访问不到。Windows 防火墙弹出拦截时记得允许 Node 或 Java 进程通过专用网络。另外如果你是在微信小游戏或者 App 的 WebView 里跑还要注意协议匹配。页面是httpsWebSocket 就要用wss否则浏览器会直接拦截。有些开发者本地测试用http ws没问题一上线切到https wss就忘了改结果线上连接一直失败。5.3 收不到消息和解析乱码代码全对但收不到消息优先检查事件名。Laya 的事件名就是Laya.Event.MESSAGE对应字符串message大小写不能错。如果连接时传了Laya.Byte回调参数拿到的就是Byte没传就可能是String或ArrayBuffer。有一种很常见的误操作是把回调参数当string去解析结果拿到的是一段二进制打印出来全是乱码。解析乱码还要看大小端。服务端用 C# 的BinaryWriter默认小端你用大端Byte去读所有多字节数字都会反过来。这个错非常隐蔽因为字符串还能正常读数字却全错了。排查方法很简单发一个uint16 1的测试消息看看客户端读出来是1还是256后者就是大小端反了。Buffer 的读取位置也是高频问题。在拆包循环里如果pos没有正确处理第二包开始就会解析错位。强烈建议在解析函数入口和出口都打印一下buffer.pos和buffer.length能快速定位是不是指针被弄乱了。5.4 内存泄漏与事件清理Socket 管理器的内存泄漏是慢性的平时很难察觉但游戏挂一天就明显卡顿。最常见的原因就是重连逻辑里反复new Laya.Socket()每一次都会给新实例绑定事件监听旧实例如果没有及时off还会继续留在引擎的事件表里回调导致内存不断积累。还有一种情况是心跳定时器没有清理。Laya.timer.loop在连接断开后不主动clear就会一直执行哪怕 socket 已经 close它还在那里发消息发一次失败一次失败一次刷一条日志。所以封装里必须有独立的stopHeartbeat在onClose和dispose里都要调用。问题现象解决办法端口被占用服务端启动报错netstat -ano查端口杀进程或换端口跨域被拒浏览器报连接失败服务端放开 Origin真机连不上手机一直超时用局域网 IP服务端绑 0.0.0.0API 事件名写错收不到任何回调用Laya.Event.MESSAGE大小端不一致数字读出来不对两端统一字节序没清理定时器内存持续上涨clear心跳和重连定时器6. 实操心得与避坑建议6.1 项目实战中养成的几个习惯做了几个 Laya 联网项目之后我总结出几条很朴实的规矩。第一所有网络请求都要有日志出口比如统一的LogUtil.net(cmd, body)把命令字、参数、耗时打出来。联调时没有日志就等于蒙着眼睛找 bug效率极低。第二连接的send方法里加状态判断未连接时直接拒绝并返回失败避免业务层在掉线瞬间还在疯狂发包导致数据错乱。第三协议号要集中管理。我会在项目里建一个NetProtocol常量类把所有命令字以枚举形式登记在一个文件里并写清注释。以前图省事协议号散落在各个模块后来查重的时候非常痛苦。第四客户端要做幂等。服务端可能因为重试机制把同一条消息推了两遍客户端收到重复消息时要能识别并忽略否则会出现重复扣费、重复跳转这种严重事故。6.2 网络层后续还能怎么扩展这套管理器只是一个基础骨架实际项目里还可以往上加很多能力。比如消息加密可以在buildPacket和parsePackets里加一层 AES 或者 XOR 混淆成本很低但能挡掉很多明文字段的抓包风险。再比如消息压缩对大型战斗日志和地图同步数据用 zlib 压一下流量能省不少。更进阶的做法是把消息路由抽象出来用装饰器或配置表把cmd绑定到具体处理函数这样业务层新增消息时不用改 NetworkManager只要注册一个函数就够了。我个人觉得最值得做的还是弱网模拟器在客户端本地模拟丢包、延迟、抖动。真机网络环境远比本机复杂很多问题只有在弱网下才会暴露提前用模拟器测一轮比上线后被玩家喷靠谱得多。最后分享一个我踩过很多次才改过来的习惯断线重连后一定要发一条身份认证消息让服务器把旧连接顶掉。不要觉得重连成功就万事大吉旧连接还挂着服务端数据就会分叉。客户端送一段包含用户 ID 和会话序号的认证包服务端校验通过后踢掉旧连接这套流程虽然多了两个协议但它能省掉后续大量的脏数据排查时间。
阅读完成 · 觉得有帮助?