ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Unity中基于C#的Socket多人网络通信系统实现指南

Unity中基于C#的Socket多人网络通信系统实现指南 简介基于Unity3D与C#的Socket多人网络通信课程设计资源包面向游戏开发学习者与需要完成网络编程实践的学生。资源涵盖了服务器端Socket实例创建、客户端连接、数据传输及多线程处理等关键环节清晰展示了如何将游戏状态序列化为字节流并完成交互同时包含错误处理与扩展优化思路适合作为课程设计或毕业设计的参考工程。资源包共收录2000个文件约29.76MB以bin、meta、cs、prefab、shader为主要类型。其中231个C#脚本实现了网络核心逻辑40个prefab与25个unity场景可直接运行体验dll、txt、xml等文件用于支持项目配置与说明整体目录结构清晰便于检索。资源已被368人学习下载能够帮助开发者快速搭建可运行的多人在线通信Demo理解Socket编程在Unity中的落地方法并通过现成脚本节省从零构建系统的时间。1. Unity 多人联机的第一道坎为什么用 C# 写 Socket 而不直接上插件本地单机跑得再顺联机测试一开Unity 项目就会露出最粗糙的一面两台机器上同一个玩家的位置各走各的消息时通时断异常输出刷得飞快。这个标题「在 Unity 中运用 C# 实现基于 Socket 的多人网络通信系统」听起来像是一个现成的工程压缩包但它背后要解决的是一个绕不开的问题——在 Unity 里用 C# 自己搭一套可靠的网络层把连接管理、消息帧、异步收发和主线程桥接都握在自己手里。它适合房间制、10 人以内的小规模联机场景也适合不想把网络层当黑匣子外包出去的团队。下面按从协议到落地的顺序把这套系统的每一步拆开讲。2. 先定协议再写代码消息帧格式与序列化方案拿到「多人网络通信系统」这类压缩包新手最容易犯的毛病是直奔连接代码结果连上了也互相看不懂——因为两端对「一条消息长什么样」没有共识。网络通信本质上是两台机器之间的字节流转定义清楚字节的排列规则就是定义协议。协议不定后面所有收发逻辑都是空中楼阁。2.1 为什么选 Socket 而不是现成插件Unity 官方早年提供的 UNET 已经停止维护第三方方案如 Mirror、Photon 虽然省事但引入了一个额外的依赖层出了问题你只能去翻别人的源码遇到自定义同步逻辑还得绕开框架的约束。自己用 Socket 写核心收益是三条第一协议完全自控消息精简到极致带宽开销自己说了算第二服务端可以用同一个 C# 代码跑在 Windows 或 Linux 控制台上不依赖云厂商的机房第三整个链路从 TCP 握手到掉线重连都是透明的出了问题能顺着堆栈查到底。传输层选型上我一般首选 TCP。多人联机里位置同步允许偶尔丢一帧但加入房间、玩家属性、战斗结算这类消息绝不允许丢TCP 的可靠传输和有序到达正好兜住这两种诉求。有些项目为了极致手感改用 UDP 自研可靠层或者套 KCP那是另一个量级的工程不在这个标题的讨论范围内。Socket 编程的入门门槛其实很低一个 Socket、一个端口、一组收发回调剩下的复杂度全在「如何组织消息」上。2.2 消息帧格式解决半包与粘包的最小设计TCP 是流协议它不保证一次 Read 拿到的正好是一条完整消息。发送方调用两次 Send接收方可能一次就收到两段数据粘包发送方调用一次 Send 一个大消息接收方可能分三次才读完半包。解决这个问题的标准手段是给每条消息套一个固定结构的帧头接收端根据帧头里的长度字段切分消息。我常用的帧格式如下总帧头固定 10 字节字段类型字节数说明帧头标记uint4固定值 0xAA55AA55用于快速校验和对齐命令字ushort2消息业务类型如 1加入房间2位置同步负载长度uint4后面负载的字节数不含帧头本身序列号uint4每条消息自增用于去重与响应配对负载byte[]变长业务数据的二进制序列化结果帧头标记的作用不只是校验更重要的是让接收端在数据流错位时能重新对齐。序列号平时容易忽略但做重连补偿和延迟统计时它就是命根子客户端发出消息带序号服务端回包带回同一个序号往返延迟立刻可算。负载长度必须设上限我一般限制在 64KB超过直接断开连接——这既防止内存被异常包撑爆也是最基本的防攻击手段。2.3 二进制序列化JSON 为什么在带宽敏感时不划算负载部分的序列化有两种思路字符串格式JSON和二进制格式。JSON 用 Unity 自带的 JsonUtility 序列化一个 Vector3得到类似{x:1.23,y:4.56,z:7.89}的字符串一个坐标就吃掉 30 字节以上而且带小数点的浮点转字符串本身就有精度损耗和解析开销。二进制方案把 x、y、z 各转成 4 字节 float一共 12 字节省掉三分之二的带宽。代价是二进制几乎不可读、不可调试这也是很多团队不愿意上手的原因。我的折中做法是开发阶段用 JSON 跑通逻辑版本发布前把高频消息位置、输入指令切换成二进制低频消息保留 JSON 格式。这样既保住调试体验又不牺牲线上带宽。另一个要注意的是大小端问题C# 的BitConverter在 x86/ARM 上默认小端如果服务端将来要跟 C 或 Java 互通必须在封包时统一转成网络序大端否则跨语言联调时会出现整型错读的怪现象。2.4 封包与解包的 C# 实现下面是这套帧格式的核心实现封包负责把消息变成字节流解包负责从字节流里切出完整消息。代码是纯 C#不依赖 Unity API可以同时用在客户端和服务端。using System; using System.Collections.Generic; public static class NetPacket { public const uint HeaderMark 0xAA55AA55; public const int HeaderSize 14; // 4244 public const int MaxBodySize 64 * 1024; // 封包命令字 负载字节数组返回完整帧 public static byte[] Encode(ushort cmd, byte[] body, uint seq) { if (body null) body new byte[0]; if (body.Length MaxBodySize) throw new ArgumentException(body too large); byte[] frame new byte[HeaderSize body.Length]; WriteUInt32(frame, 0, HeaderMark); WriteUInt16(frame, 4, cmd); WriteUInt32(frame, 6, (uint)body.Length); WriteUInt32(frame, 10, seq); Buffer.BlockCopy(body, 0, frame, HeaderSize, body.Length); return frame; } // 解包从缓存里尽可能解析出所有完整消息 // 返回 true 表示至少解析出一条buffer 中剩余数据自动保留 public static bool TryDecode(ref byte[] buffer, out Listbyte[] frames) { frames new Listbyte[](); int offset 0; while (buffer.Length - offset HeaderSize) { if (ReadUInt32(buffer, offset) ! HeaderMark) { // 帧头不对跳过 1 字节尝试重对齐 offset; continue; } uint bodyLen ReadUInt32(buffer, offset 6); if (bodyLen MaxBodySize) throw new Exception(frame too large, connection closed); int totalLen HeaderSize (int)bodyLen; if (buffer.Length - offset totalLen) break; // 半包等更多数据 byte[] frame new byte[totalLen]; Buffer.BlockCopy(buffer, offset, frame, 0, totalLen); frames.Add(frame); offset totalLen; } if (offset 0) { byte[] remains new byte[buffer.Length - offset]; Buffer.BlockCopy(buffer, offset, remains, 0, remains.Length); buffer remains; } return frames.Count 0; } private static void WriteUInt32(byte[] dst, int offset, uint val) { dst[offset] (byte)(val 0xFF); dst[offset1] (byte)((val 8) 0xFF); dst[offset2] (byte)((val 16) 0xFF); dst[offset3] (byte)((val 24) 0xFF); } private static void WriteUInt16(byte[] dst, int offset, ushort val) { dst[offset] (byte)(val 0xFF); dst[offset1] (byte)((val 8) 0xFF); } private static uint ReadUInt32(byte[] src, int offset) { return (uint)(src[offset] | (src[offset1] 8) | (src[offset2] 16) | (src[offset3] 24)); } private static ushort ReadUInt16(byte[] src, int offset) { return (ushort)(src[offset] | (src[offset1] 8)); } }逻辑说明TryDecode的核心是循环解析每轮先检查剩余数据是否够一个帧头然后验证帧头标记读负载长度判断数据是否完整。数据不完整就break退出等下一次收到新数据再继续解析完的字节从原缓冲区里切掉剩下的半包数据保留给下一次调用。这样无论粘包还是半包接收端都只需要一个持久化的缓存字节数组不需要为每条消息单独处理边界。参数说明HeaderSize必须和封包时完全一致任何一端改了字段顺序或大小另一边就会报帧头错乱。MaxBodySize设成 64KB 是针对位置同步少量事件消息的常规值如果你的游戏有头像、缩略图这类大字段传输可以单独开一条大消息通道而不是调高这个上限——否则恶意客户端可以发一个大包把服务器内存拖垮。这段代码在 Windows 和 Linux 下的行为一致Unity 里用之前确认 Scripting Runtime Version 设置为 .NET 4.x Equivalent 即可。3. 异步连接与收发客户端核心类的落地写法协议定了接下来是通信本体。Socket 编程里最经典的坑是把收发写成同步阻塞式主线程一Receive整个程序就卡住等网络而网络是最不可控的输入源延迟从几毫秒到几百毫秒都是常态。Unity 里更严重主线程卡顿直接表现为画面掉帧、触控失灵。所以这一章的核心是异步收发模型。3.1 同步阻塞为什么在 Unity 里必死同步 Socket 的Receive在收到数据之前不会返回如果服务端迟迟不响应Unity 主线程就卡死在那一行表现是游戏进入假死状态。业内不是没有用独立线程做同步收发的做法但线程一旦涉及共享状态就要处理锁竞争而 Unity 的对象模型又对线程安全极度不友好绕一圈还是回到异步回调。异步模型的好处是收发操作交给操作系统和线程池回调触发时网络数据已经就绪不需要在任何业务线程上干等。3.2 接收回调BeginReceive 是一次性的用BeginReceive发起一次异步接收后回调只在数据到达时触发一次。收到一次不代表一条消息完整——TCP 可能分片所以每次回调里要把数据追加到缓存再用上一章的TryDecode切分消息。切出的消息先在网络线程里解析成可识别的 C# 对象然后丢进一个线程安全队列等 Unity 主线程来取。这个衔接点是整个联机系统最容易翻车的地方后面第 4 章专门讲。还有一个高频问题是接收缓冲区的尺寸。缓冲区太小会导致一次回调收到的数据被截断剩下的数据要等下一次回调虽然协议层能处理但会增加解析次数缓冲区太大又浪费内存。我一般设 16KB 起步按协议最大帧调整到HeaderSize MaxBodySize也就是 64KB 14 字节。注意BeginReceive回调线程来自 .NET 线程池不是 Unity 主线程回调里访问任何UnityEngine.Object都会触发 InvalidOperationException而且要小心你的 Socket 对象被其他线程同时关闭。3.3 客户端核心类连接、发送、接收与心跳下面是一个最小可用的客户端封装覆盖连接、异步接收、发送队列和心跳using System; using System.Net.Sockets; using System.Collections.Concurrent; public class SocketClient { private Socket socket; private byte[] recvBuffer new byte[64 * 1024]; private byte[] packetBuffer new byte[0]; private ConcurrentQueuebyte[] incoming new ConcurrentQueuebyte[](); private volatile bool connected; private DateTime lastReceiveTime; public event Actionbyte[] OnPacket; public bool IsConnected connected; public void Connect(string ip, int port) { socket new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); socket.NoDelay true; // 关闭 Nagle 算法降低小包延迟 socket.SendTimeout 3000; socket.ReceiveTimeout 3000; socket.BeginConnect(ip, port, OnConnect, null); } private void OnConnect(IAsyncResult ar) { try { socket.EndConnect(ar); connected true; lastReceiveTime DateTime.UtcNow; socket.BeginReceive(recvBuffer, 0, recvBuffer.Length, SocketFlags.None, OnReceive, null); Console.WriteLine(connected); } catch (SocketException e) { Console.WriteLine(connect failed: e.SocketErrorCode); connected false; } } private void OnReceive(IAsyncResult ar) { try { int len socket.EndReceive(ar); if (len 0) { OnDisconnected(); return; } lastReceiveTime DateTime.UtcNow; // 把新收到的数据追加到缓存 byte[] merged new byte[packetBuffer.Length len]; Buffer.BlockCopy(packetBuffer, 0, merged, 0, packetBuffer.Length); Buffer.BlockCopy(recvBuffer, 0, merged, packetBuffer.Length, len); packetBuffer merged; // 切分完整消息 var frames new System.Collections.Generic.Listbyte[](); if (NetPacket.TryDecode(ref packetBuffer, out frames)) { foreach (var frame in frames) OnPacket?.Invoke(frame); } // 继续接收必须再次调用 socket.BeginReceive(recvBuffer, 0, recvBuffer.Length, SocketFlags.None, OnReceive, null); } catch (ObjectDisposedException) { // 主动关闭时 EndReceive 会抛这个直接忽略 } catch (SocketException e) { Console.WriteLine(recv error: e.SocketErrorCode); OnDisconnected(); } } public void Send(byte[] frame) { if (socket null || !connected) return; try { socket.BeginSend(frame, 0, frame.Length, SocketFlags.None, OnSend, frame); } catch (SocketException e) { Console.WriteLine(send error: e.SocketErrorCode); OnDisconnected(); } } private void OnSend(IAsyncResult ar) { try { socket.EndSend(ar); } catch (SocketException e) { Console.WriteLine(send callback error: e.SocketErrorCode); OnDisconnected(); } } private void OnDisconnected() { if (!connected) return; connected false; Console.WriteLine(disconnected); try { socket.Shutdown(SocketShutdown.Both); } catch {} try { socket.Close(); } catch {} } public void Close() { OnDisconnected(); } }逻辑说明连接建立后立即调用一次BeginReceive之后每次回调解析完数据必须再调用一次否则收不到后续消息。接收缓存packetBuffer是跨回调复用的它保存的是还没凑成完整帧的残留数据。OnPacket事件在 .NET 线程池线程上触发订阅方不能直接操作 Unity 对象这一点在接入 Unity 时必须处理。参数说明NoDelay true关闭 Nagle 算法位置同步这类小包能少等几十毫秒代价是网络包数量增加适合游戏场景不适合大文件传输。SendTimeout和ReceiveTimeout设为 3000 毫秒是让同步 API 不卡死异步模式下主要起辅助作用。真正判断连接是否活着靠lastReceiveTime配合一个定时器超过 10 秒没收到任何数据就主动断开这是应用层心跳的兜底。3.4 服务端最小实现监听、Accept 与消息分发多人系统里服务端可以是一台独立的云主机也可以是开发时本机开一个控制台程序。服务端核心逻辑比客户端简单一个监听 Socket每 Accept 一个客户端就新建一个 Socket 和对应的接收循环收到的消息根据命令字路由到不同的业务处理器。注意 Accept 回调也是异步的而且每个客户端的消息都在独立线程上执行涉及共享的玩家列表时必须加锁。using System; using System.Net; using System.Net.Sockets; using System.Collections.Generic; public class SocketServer { private Socket listener; private ListSocket clients new ListSocket(); private readonly object lockObj new object(); public void Start(int port) { listener new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); listener.Bind(new IPEndPoint(IPAddress.Any, port)); listener.Listen(20); listener.BeginAccept(OnAccept, null); Console.WriteLine(server listening on port); } private void OnAccept(IAsyncResult ar) { Socket client listener.EndAccept(ar); lock (lockObj) { clients.Add(client); } byte[] buffer new byte[64 * 1024]; client.BeginReceive(buffer, 0, buffer.Length, SocketFlags.None, OnReceive, new ClientState { Socket client, Buffer buffer }); listener.BeginAccept(OnAccept, null); } private void OnReceive(IAsyncResult ar) { var state (ClientState)ar.AsyncState; try { int len state.Socket.EndReceive(ar); if (len 0) { lock (lockObj) { clients.Remove(state.Socket); } state.Socket.Close(); return; } // 这里同样要做粘包/半包处理逻辑和客户端一致 // 按命令字分发到不同处理器 state.Socket.BeginReceive(state.Buffer, 0, state.Buffer.Length, SocketFlags.None, OnReceive, state); } catch (SocketException) { lock (lockObj) { clients.Remove(state.Socket); } state.Socket.Close(); } } private class ClientState { public Socket Socket; public byte[] Buffer; } }逻辑说明每个客户端连接由一个ClientState对象携带自己的 Socket 和缓冲区回调触发时通过AsyncState取回对应的状态。clients列表被接受线程、接收线程和未来的逻辑线程同时访问所以所有读写都包在lockObj锁里。这里没有写业务分发实际做法是按命令字拆包后把消息投递到一个独立的逻辑队列让游戏逻辑在单线程里顺序处理避免多线程逻辑竞争。多线程确实能提高吞吐但联机游戏的逻辑状态玩家位置、血量一旦竞争bug 就很难复现单线程逻辑队列是最稳妥的起点。4. Unity 主线程桥接网络数据怎么安全地变成场景表现网络层跑通了消息也收到了接下来是 Unity 项目特有的一道工序把网络线程上的数据搬回主线程。很多人第一次联机就在这一步翻车——回调里写了transform.position ...运行时报「get_transform can only be called from the main thread」然后开始怀疑人生。这不是玄学是 Unity 对象系统的硬性约束。4.1 Unity API 的线程限制为什么不能在回调里改 TransformUnity 的场景对象、组件、物理系统都不是线程安全的。网络回调线程来自 .NET 线程池在这个线程上访问任何场景对象都属于未定义行为多数情况直接抛异常少数情况不报错但数据错乱。正确处理方式不是在回调里小心翼翼而是彻底隔离网络线程只负责把原始消息变成 C# 事件对象放进一个线程安全队列Unity 主线程在每一帧的去尾部把队列掏空再执行实际的场景操作。4.2 用并发队列把网络事件搬回主线程C# 的ConcurrentQueueT加一个 MonoBehaviour 就能完成这个桥接。网络层不依赖任何 MonoBehaviour纯 C# 类在场景加载前就可以初始化主线程上的分发器负责每帧消费队列。using System; using System.Collections.Concurrent; using UnityEngine; public class NetworkDispatcher : MonoBehaviour { public static NetworkDispatcher Instance { get; private set; } private ConcurrentQueueAction actionQueue new ConcurrentQueueAction(); private void Awake() { Instance this; } // 网络线程调用投递一个要在主线程执行的委托 public void Post(Action action) { actionQueue.Enqueue(action); } private void Update() { // 每帧最多执行 100 个避免积压太多导致卡顿 int count 0; while (count 100 actionQueue.TryDequeue(out Action action)) { try { action(); } catch (Exception e) { Debug.LogError(dispatch error: e); } count; } } }逻辑说明Post方法是网络线程安全入口任何回调里只要写NetworkDispatcher.Instance.Post(() { ... })括号里的代码就会被搬回主线程执行。Update里每帧最多消费 100 条这个上限是性能保护如果网络消息爆炸式积压强行一次清空会让主线程卡顿明显而网络消息本身就有时效性旧的位置同步帧丢了比晚处理一帧更合理。参数说明消费上限 100 是根据单帧逻辑耗时调的如果游戏逻辑简单可以提到 200如果一帧里已经做了复杂计算就降到 50。Action委托捕获的是闭包闭包里引用的对象必须在主线程上仍然存活——最常见的崩法是网络层持有已销毁的玩家对象再通过队列回调访问它。这里建议所有主线程操作前都做一次 null判断。4.3 命令字分发从收到消息到调用游戏逻辑消息到达主线程后下一步是找到对应的处理函数。我把所有命令字的处理函数统一注册到一个字典里键是ushort命令字值是一个Actionbyte[]。收到完整帧后先按帧格式取出命令字和负载查字典命中则调用未命中则记录日志。这种注册表模式的好处是新增一种消息只需加一个注册不需要改分发器本身。using System.Collections.Generic; using UnityEngine; public class NetMessageRouter { private Dictionaryushort, System.Actionbyte[] handlers new Dictionaryushort, System.Actionbyte[](); public void Register(ushort cmd, System.Actionbyte[] handler) { handlers[cmd] handler; } public void Unregister(ushort cmd) { handlers.Remove(cmd); } public void HandleFrame(byte[] frame) { ushort cmd (ushort)(frame[4] | (frame[5] 8)); byte[] body new byte[frame.Length - NetPacket.HeaderSize]; System.Buffer.BlockCopy(frame, NetPacket.HeaderSize, body, 0, body.Length); if (handlers.TryGetValue(cmd, out var handler)) handler(body); else Debug.LogWarning(unhandled cmd: cmd); } }逻辑说明frame数组里下标 4 和 5 对应封包时写入的命令字用小端方式组合成ushort。负载从HeaderSize14开始拷贝。查询不到处理函数说明协议错位或者两端命令字表不一致打印 warning 而不是静默吞掉联调阶段这类日志能省很多查错时间。4.4 位置同步与插值把跳变变成平滑移动位置同步是联机游戏最典型的表现层难点。如果直接把网络包里的坐标赋值给transform.position丢包或延迟抖动会让人物瞬移。业界常用做法是快照插值客户端只保留目标位置每帧按剩余距离做平滑逼近。我家网络层解析完位置帧后不直接改坐标而是把目标点存起来由表现层在Update里逐帧推进。using UnityEngine; public class PositionInterpolator : MonoBehaviour { private Vector3 target; private float smoothTime 0.1f; private Vector3 velocity Vector3.zero; public void SetTarget(Vector3 newTarget) { target newTarget; } private void Update() { // SmoothDamp 是 Unity 内置的平滑插值自带阻尼效果 transform.position Vector3.SmoothDamp( transform.position, target, ref velocity, smoothTime); } }逻辑说明收到位置同步消息时调用SetTarget剩下的交给SmoothDamp。它和简单的Lerp不同的是内部维护了一个速度状态接近目标时会自然减速不会出现匀速移动导致的生硬感。smoothTime是达到目标的大致时间值越大越平滑但跟随越迟钝。参数说明smoothTime取 0.1 秒适合移动速度中等的人物如果你的角色有冲刺、闪现这类高速位移要临时把 smoothTime 调小否则会感觉角色「飘」。位置同步的发送频率一般控制在 10~15 帧每秒比渲染帧率低很多配合插值视觉上依然流畅。不要每帧都发位置带宽撑不住而且服务端转发压力会线性上涨。5. 联机避坑清单端口占用、粘包、心跳与线程竞争Socket 联机调试到后期遇到的问题翻来覆去就那么几类。这块写几个我踩过、也帮别人排查过的高频雷区每条按现象、原因、解决三步说清楚。这些坑在开发阶段几乎必然遇到提前知道能省好几个晚上的查错时间。5.1 端口占用为什么杀掉进程还是报「地址已被使用」现象服务端启动时报错Windows 上最常见的一句话是「通常每个套接字地址(协议/网络地址/端口)只允许使用一次」。更诡异的是你明明已经把前一个服务端进程关掉了重启还是报同样的错。原因操作系统在 TCP 连接关闭后端口会进入 TIME_WAIT 状态持续约 2 到 4 个 MSL报文最大生存时间Windows 默认 120 秒。这个状态下端口还被内核占用如果服务端在监听 Socket 上没有设置地址复用立刻重启就会撞上这堵墙。Unity 里还有一种情况是编辑器反复 Play-Stop上一次的连接没有被正确 CloseSocket 对象被 GC 回收但底层句柄还在。解决创建监听 Socket 时设置SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true)让服务端能立刻复用 TIME_WAIT 中的地址。客户端连接时不要硬编码本地端口让操作系统自动分配避免多开联调时互相抢端口。如果异常排查时怀疑端口被占用netstat -ano | findstr port查 PID再对照任务管理器确认是谁占着的。5.2 粘包与半包收到的消息为什么是乱码或解析错位现象客户端发来一条长度 200 字节的消息服务端第一次回调只收到 120 字节第二次又收到 300 字节——后 180 字节其实是下一条消息的前半段。这时候如果用Encoding.UTF8.GetString直接转字符串会看到乱码或者字段错位。原因TCP 不保证消息边界一次 Send 的数据可能在网络层被拆成多段多次 Send 的数据也可能合并成一段到达。很多新手只知道把收到的数据直接处理不知道需要有一个跨回调的缓存区。解决用第 2 章的帧格式统一处理。核心约定是两块第一接收缓冲区必须有持久化状态不能在每次回调里重建第二解析必须按「帧头校验 → 读长度 → 等完整 → 切分」的状态机推进。封包、解包代码在客户端和服务端之间完全复用不要各写一套各写一套早晚会在一端漏掉帧头字段。5.3 心跳与掉线误判TCP 静默断开为什么查不出来现象服务端日志显示玩家已经掉线但客户端进程还活着界面也没有任何异常或者反过来客户端以为自己还连着服务端早就把这条连接关闭了要等下一次发送才报错。原因TCP 连接在没有数据传输时双方无法感知对方是否存活。路由器 NAT 表、Wi-Fi 切换、运营商中间设备都可能把空闲连接静默清理掉而两端都不知道。靠 TCP 自身的 KeepAlive 默认要两个小时才触发一次游戏根本等不起。解决应用层自己做心跳。客户端每 5 秒发一条心跳包服务端记录每个连接的最后活跃时间超过 15 秒没有收到任何数据就判定掉线主动关闭并清理玩家数据。客户端同样维护最后收到数据的时间超过 15 秒没收到任何回包就主动重连。心跳包只啄帧头命令字单独设一个负载为空整条消息 14 字节带宽开销可以忽略。注意心跳判定要覆盖所有消息不只心跳包本身——只要有任何数据到达最后活跃时间就要刷新。5.4 线程竞争关闭 Socket 时接收回调还在跑现象玩家退出房间客户端调用Close()关闭 Socket然后随机出现ObjectDisposedException或者SocketException而且不是每次都能复现。Unity 编辑器里更隐蔽异常发生在线程池里主线程的 Debug 面板有时候根本不显示。原因BeginReceive发起后操作系统可能在任意时刻触发回调即使你已经调用了Close()。回调执行EndReceive时Socket 句柄已经释放直接抛异常。这是异步模型里最常见的竞态条件不是代码写错而是没有做状态同步。解决在Close()之前先置一个volatile bool closing标志OnReceive回调开头检查这个标志为 true 就直接返回不做EndReceive。Close()本身要包 try/catch因为即使做了标志位操作系统仍可能在某个极小的时间窗口里触发回调。另外所有回调里的 Socket 操作都用 try/catch 包住ObjectDisposedException这是预期内的异常而不是崩溃打印一行 Info 日志即可。服务端在移除玩家时也要先移除客户端列表里的引用再关闭 Socket顺序反了会出现往已关闭连接上写数据的竞态。6. 从跑通到扛得住压测方法与上线前的三个验证系统能跑通只是开始多人联机的真正考验是并发。自己不压测就上线上线后翻车是必然的只是时间早晚。压测不需要昂贵的外部工具自己写一个控制台模拟客户端就够用。6.1 自写压测客户端跑出延迟与丢包统计模拟客户端的逻辑和真实客户端共用同一套封包解包类区别是不跑 Unity 渲染只循环发位置同步消息并统计响应延迟。下面是一个简化版的模拟客户端using System; using System.Diagnostics; using System.Net.Sockets; using System.Threading; class StressClient { private Socket socket; private int received; private long totalLatency; public void Run(string ip, int port) { socket new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); socket.NoDelay true; socket.Connect(ip, port); byte[] body new byte[12]; // 模拟一个 Vector3 位置 Stopwatch sw Stopwatch.StartNew(); for (int i 0; i 1000; i) { byte[] frame NetPacket.Encode(2, body, (uint)i); socket.Send(frame); Thread.Sleep(16); // 模拟 60 FPS 的发送节奏 if (socket.Available 0) { byte[] recv new byte[socket.Available]; socket.Receive(recv); // 这里只统计回包数量实际项目按序列号匹配延迟 received; totalLatency sw.ElapsedMilliseconds; } } Console.WriteLine($received {received}/1000, avg latency {totalLatency / 1000} ms); socket.Close(); } }参数说明用同步Send/Receive写压测脚本没问题因为它的职责是制造压力而不是承载游戏。Thread.Sleep(16)模拟的是真实客户端 60 FPS 的发包节奏压测时可以把间隔缩短到 5 毫秒来测试服务端上限。计数逻辑越简单越好压测脚本里引入复杂业务逻辑反而会干扰被测系统。6.2 上线前必须过的三个验证第一延迟与抖动验证客户端与服务端在局域网环境下往返延迟应稳定在 5 毫秒以内波动不超过 3 毫秒公网环境下以 100 次往返统计平均延迟 80 毫秒以内、丢包率 0 才算过关。第二断线重连验证服务端手动断开客户端连接观察客户端能否在 5 秒内自动重连并恢复状态重连后位置同步是否还连续。第三长时间稳定性验证模拟 20 个客户端连续跑 2 小时观察内存是否稳定、心跳超时误判次数是否为 0、消息积压是否持续增长。6.3 最后一道习惯日志分级与关键埋点联机系统的日志是排障的命根子但日志不能乱打。连接建立打 Info消息解析失败打 Error断线打 Warning位置同步这类高频消息默认不打。调试联机问题时我会先在服务端开一条DEBUG级别开关把某个玩家 ID 的消息流完整打印出来用序列号核对收发顺序。这一步看起来不起眼却能在半小时内定位到是协议错位、逻辑丢包还是网络抖动。这些年做联机项目的血泪经验浓缩成一句话网络层最贵的不是写代码是查问题查问题最快的手段不是猜是能复现、有日志、有序列号。协议层把帧格式定死收发层把线程边界守好表现层把插值做扎实这套系统就稳了一大半。希望帮到你。本文还有配套的精品资源点击获取
返回列表