简介这是一个基于Unity3D与C#的多人网络通信系统课程设计资源面向游戏开发方向的学生及希望学习Unity Socket编程的开发者。资源包共2000个文件包含231个C#脚本、40个预制体、31个着色器、27个动态库以及大量二进制数据文件压缩包整体约29.76MB呈现完整Unity工程结构可直接打开查看。内容从服务器端Socket创建、绑定IP与端口、监听客户端连接到客户端Connect发起会话再到游戏状态与玩家操作的序列化传输、多线程收发数据、错误与断线重连以及数据压缩、心跳机制等优化扩展覆盖Unity多人联网完整链路并附有unitynetworkresearch-master项目参考。已有367人学习下载适合作为课程设计、毕业设计或自学项目参考可帮助理解Socket通信在Unity中的落地方式。1. 从零搭一套 Unity 多人联机为什么我建议你先碰 Socket 而不是现成框架做 Unity 联机需求时很多人第一反应是 Photon、Mirror 或者 UNET 遗留方案。这些框架确实能让你半天跑起 Demo但一旦遇到自定义协议、帧同步、服务端逻辑要独立部署、或者需要精确控制每个字节的收发时机框架反而成了黑匣子。我见过不少项目在 Photon 上做到中期发现带宽超限、无法自定义心跳、或者服务端逻辑写不进云端最后被迫回炉重写。如果你正好在看这个标题说明你想要的不是一个拖拽即用的高层 API而是“自己握住网络层”的能力。基于 Socket 的多人通信本质是你自己当传输层的主人C# 的Socket类直接操作 TCP/UDPUnity 主线程负责收发包的分发逻辑层只关心消息 ID 和数据结构。这套方案适合三类人想做帧同步或状态同步原型的独立开发者、需要把服务端托管到 Linux 或云函数的团队、以及被现成框架的定价或限制卡住、想彻底掌控链路的工程师。标题里提到的这个压缩包大概率就是一套带客户端和服务端的可运行工程但真正值钱的是它背后那套“Socket 连接管理 消息分包 Unity 线程模型”的落地思路。下面我从最底层的连接开始逐步把整条链路拆给你看。2. 先搞定同步阻塞还是异步C# Socket 在 Unity 里的两种活法2.1 为什么不能在 Unity 主线程直接Accept和ReceiveUnity 的主线程跑的是渲染循环和Update任何阻塞调用都会让游戏画面卡死。你如果在Start里直接写socket.Accept()服务端会卡在那里等客户端连入编辑器界面直接转圈这是新手最容易踩的第一个坑。常见的做法是给 Socket 套上异步模型C# 里有两套异步风格基于BeginXXX/EndXXX的 APM和基于async/await的 TAP。在 Unity 2020 以上版本我建议直接用 TAP配合ConfigureAwait(false)避免同步上下文死锁但在回调里更新 Unity 对象时必须丢回主线程。我一般会建立一个NetworkThread类来单独跑 Socket 接收循环收到数据后放进ConcurrentQueuebyte[]Unity 主线程在Update里取队列并做逻辑分发。这样做的好处是网络层和游戏逻辑层彻底解耦即使某个包处理耗时也不会阻塞底层收包。using System.Collections.Concurrent; using System.Net.Sockets; using System.Threading.Tasks; using UnityEngine; public class TcpClientBehaviour : MonoBehaviour { private Socket _socket; private ConcurrentQueuebyte[] _recvQueue new ConcurrentQueuebyte[](); private byte[] _buffer new byte[4096]; public bool IsConnected { get; private set; } public async Task ConnectAsync(string ip, int port) { _socket new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); await _socket.ConnectAsync(ip, port); // 异步连接不会卡主线程 IsConnected true; _ Task.Run(ReceiveLoop); // 后台线程持续收包 } private void ReceiveLoop() { while (IsConnected) { int len _socket.Receive(_buffer); if (len 0) { IsConnected false; break; } byte[] data new byte[len]; System.Array.Copy(_buffer, data, len); _recvQueue.Enqueue(data); // 入队交给主线程处理 } } private void Update() { while (_recvQueue.TryDequeue(out byte[] packet)) { Debug.Log($收到 {packet.Length} 字节); } } }这里Task.Run起了一个后台线程跑ReceiveLoopConcurrentQueue保证跨线程写入安全。有个细节很多人忽略Socket.Receive不一定能一次收到完整的业务包它只保证“把当前可读的字节读出来”。所以_buffer数组大小和业务包大小之间没有必然关系后面讲分包黏包时再展开。2.2 服务端同样要异步用SocketAsyncEventArgs还是async/await服务端如果只需要同时处理几个客户端用类似客户端的async/await方式就够了。但如果是 20 人以上的在线场景我建议改用SocketAsyncEventArgsSAEA它是 .NET 里专门为高并发 Socket 设计的异步模型通过对象复用减少 GC 压力。SAEA 的关键在于一个SocketAsyncEventArgs实例可以重复利用一次操作完成后重置它的AcceptSocket、Buffer、UserToken然后再次调用AcceptAsync。很多网上教程只演示了一个客户端的接收没讲 socket 连接关闭后的事件顺序导致第二个客户端连进来时直接崩溃。我这里给一个精简版服务端收发骨架用async/await写方便你理解整体流程生产环境再换成 SAEA 也不迟using System; using System.Net; using System.Net.Sockets; using System.Text; using System.Threading.Tasks; public class TcpServer { private Socket _listener; private bool _running; public async Task StartAsync(int port) { _listener new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); _listener.Bind(new IPEndPoint(IPAddress.Any, port)); _listener.Listen(100); _running true; while (_running) { Socket client await _listener.AcceptAsync(); // 异步接受不阻塞循环 _ HandleClientAsync(client); // 为每个客户端起独立任务 } } private async Task HandleClientAsync(Socket client) { byte[] buffer new byte[1024]; try { while (true) { int len await client.ReceiveAsync(new ArraySegmentbyte(buffer), SocketFlags.None); if (len 0) break; string msg Encoding.UTF8.GetString(buffer, 0, len); Console.WriteLine($收到: {msg}); byte[] resp Encoding.UTF8.GetBytes(ok); await client.SendAsync(new ArraySegmentbyte(resp), SocketFlags.None); } } catch (Exception ex) { Console.WriteLine($客户端异常: {ex.Message}); } finally { client.Close(); } } }不要直接在生产里用上面的代码它缺了分包处理和断线重连但作为理解异步服务端的最小模型它足够清晰。AcceptAsync是 TAP 版本的异步接收底层仍走 IOCP不会卡线程。HandleClientAsync里每个客户端一条独立的异步链看似并发很高实际上线程开销很小。如果你明确要做大厅 房间制那么服务端还需要一层连接管理器用Dictionaryint, Socket保存已连接的客户端 ID 和 Socket。这个字典必须是线程安全的或者所有访问都集中在一个线程里操作。3. 封装一套带分包和心跳的客户端消息格式才是一切3.1 消息结构长度前缀 消息 ID JSON 体三个字段缺一不可Socket 传输层只有字节流它不关心你发的是“移动指令”还是“聊天内容”。所以客户端和服务端之间必须约定一套信封格式。我常用的方案是前四个字节存放消息体长度不含长度字段本身紧接着两个字节是消息 ID枚举转 short剩下的是消息体可以是 JSON 字符串也可以是二进制数据。这样设计的好处是接收端先把四字节长度读出来再根据长度循环读取对应字节数就能还原出一个完整的业务包。public static class PacketCodec { public static byte[] Encode(ushort msgId, string jsonBody) { byte[] bodyBytes Encoding.UTF8.GetBytes(jsonBody); byte[] packet new byte[4 2 bodyBytes.Length]; BitConverter.TryWriteBytes(packet.AsSpan(0, 4), bodyBytes.Length); BitConverter.TryWriteBytes(packet.AsSpan(4, 2), msgId); Buffer.BlockCopy(bodyBytes, 0, packet, 6, bodyBytes.Length); return packet; } public static bool TryDecode(byte[] rawData, out ushort msgId, out string jsonBody) { msgId 0; jsonBody string.Empty; if (rawData.Length 6) return false; int bodyLen BitConverter.ToInt32(rawData, 0); if (rawData.Length 4 2 bodyLen) return false; msgId BitConverter.ToUInt16(rawData, 4); jsonBody Encoding.UTF8.GetString(rawData, 6, bodyLen); return true; } }注意BitConverter.TryWriteBytes在 .NET Standard 2.1 里才可用Unity 2021 以上没问题老项目要换成BitConverter.GetBytes再CopyTo。另外字节序默认是小端如果服务端用 Java 或 Go 写需要统一确认大小端跨语言联调时这是最常见的坑之一。3.2 收包缓冲拆掉黏包和半包别贪图轮询回到 2.1 里那个ReceiveLoop_socket.Receive返回值不可控TCP 可能把两个小包合并成一个Receive返回也可能一个业务包分多次Receive。所以客户端必须维护一个收包缓冲区把读到的数据追加进去然后循环检查“缓冲区里是否已经凑齐一个完整包”。private Listbyte _recvBuffer new Listbyte(4096); private void ProcessReceivedData(byte[] chunk) { _recvBuffer.AddRange(chunk); while (_recvBuffer.Count 6) // 至少够读长度ID { int bodyLen BitConverter.ToInt32(_recvBuffer.ToArray(), 0); int totalLen 4 2 bodyLen; if (_recvBuffer.Count totalLen) break; // 半包等待更多数据 byte[] packet _recvBuffer.GetRange(0, totalLen).ToArray(); _recvBuffer.RemoveRange(0, totalLen); bool ok PacketCodec.TryDecode(packet, out ushort msgId, out string jsonBody); if (ok) { HandleMsg(msgId, jsonBody); } } }这里用Listbyte只是为了演示逻辑真要追求性能用MemoryStream或者环形缓冲更好。GetRange每次都会分配新数组GC 压力在频繁收包时会很扎眼。实际项目中我会用byte[] _buffer配合读写游标来避免反复拷贝这个优化可以放到后期。3.3 心跳与断线识别怎么让服务端知道客户端还活着TCP 本身有 keepalive但默认在 Windows 上 2 小时才会探测一次游戏场景肯定等不了。所以应用层心跳必须自己做。常见做法是客户端每隔 5 秒发一个空包消息 ID 固定为Heartbeat服务端 10 秒内收不到心跳就判定掉线清理连接。private async void StartHeartbeatLoop() { while (IsConnected) { await Task.Delay(5000); // 5秒一次 if (!IsConnected) break; byte[] packet PacketCodec.Encode(100, {}); // 假设 100 心跳 _socket.Send(packet); } }服务端那边要记录每个客户端最后一次心跳时间用一个DateTime字段就行每次收到任何包时都刷新它。清理工作由一个独立定时器触发遍历连接字典找出lastHeartbeat距现在超过 10 秒的强制Shutdown和Close。这里有个玄学问题心跳包里最容易写错的是消息 ID 和普通消息撞号。建议把消息 ID 从 100 开始分段0-99 留给系统消息100 是业务消息这样一个简单的数字规范就能避免不少排查时间。4. 把消息分发接进 Unity从 Update 到主线程回调的设计4.1 一个 MiniMessageDispatcher注册、注销、派发三步走Socket 层收到包后最终要驱动 Unity 里的角色移动、UI 刷新等逻辑。直接用一行switch(msgId)写业务逻辑后面会是灾难。更好的方式是做一个消息分发器客户端注册一组“处理器”收到对应消息 ID 时自动调用。using System; using System.Collections.Generic; using UnityEngine; public class MessageDispatcher : MonoBehaviour { private Dictionaryushort, Actionstring _handlers new Dictionaryushort, Actionstring(); public void Register(ushort msgId, Actionstring handler) { if (_handlers.ContainsKey(msgId)) { _handlers[msgId] handler; } else { _handlers[msgId] handler; } } public void Unregister(ushort msgId, Actionstring handler) { if (_handlers.TryGetValue(msgId, out Actionstring existing)) { existing - handler; if (existing null) _handlers.Remove(msgId); } } public void Dispatch(ushort msgId, string jsonBody) { if (_handlers.TryGetValue(msgId, out Actionstring handler)) { handler?.Invoke(jsonBody); } } }关键是这个分发器必须放在主线程里Dispatch因为Actionstring里大概率要操作Transform、Animator或者其他 Unity 对象。对应地Socket 后台线程把解码后的包放进队列主线程Update里取出并调用Dispatch。4.2 主线程轮询还是事件驱动Unity 没有消息泵轮询最稳C# 的async/await在 Unity 里如果用了SynchronizationContext后台线程的延续会回到主线程执行但有坑如果场景切换或者脚本被 disable延续可能跑在错误时机Debug 模式下还容易因为 Unity 对象被销毁而抛MissingReferenceException。所以我不会在 Socket 回调里直接操作 Unity 组件而是统一采用“入队 主线程轮询”模式。Update方法每帧检查队列配合FixedUpdate处理需要固定时间步的逻辑比如发送移动同步消息。private void Update() { while (_recvQueue.TryDequeue(out byte[] rawPacket)) { if (PacketCodec.TryDecode(rawPacket, out ushort msgId, out string json)) { _dispatcher.Dispatch(msgId, json); } } }这个模式有一个额外的好处如果你用的是 Unity 的PlayerLoop自定义注入也可以在同一位置处理网络消息而不影响渲染帧率。帧率波动时网络消息仍按每帧一送或按数据量取值误差在可接受范围内。4.3 错误处理连接中断后怎么安全退出TCP 连接断开时Receive会抛SocketException或者返回 0。返回 0 表示服务端正常关闭异常则可能是网络闪断或对端崩溃。处理方式两者都要捕获异常后先尝试Shutdown再Close最后通知上层。private void OnDisconnected() { IsConnected false; try { _socket.Shutdown(SocketShutdown.Both); } catch (Exception ex) { Debug.LogWarning($Shutdown 异常: {ex.Message}); } finally { _socket.Close(); _socket null; } _onDisconnectCallback?.Invoke(); }这里_onDisconnectCallback要放到主线程里调用否则 UI 弹窗或者角色状态复位都会在错误的线程上执行。一个更稳的策略是断线回调和收包走同一个队列只是包类型换成一个带消息 ID 的事件结构体这样线程模型统一不容易出隐性 bug。5. 多人同屏的坑从 TCP 黏包到 Unity 主线程卡顿的排障手册5.1 黏包和半包是正常现象别用Received长度当报文现象客户端每秒发 10 条移动消息服务端偶尔一条Receive就读到了几百字节解析后消息内容错乱或者一条 4KB 的完整包Receive只返回了 1KB。原因TCP 是流协议没有消息边界。发送方可能把多个小段合并成一个大段Nagle 算法接收方的Receive缓冲也可能在数据未完全到达时就被读取。解决按 3.2 的方案在接收端维护字节缓冲先读四字节长度再按长度读完整包。同时可以设置_socket.NoDelay true关闭 Nagle 算法降低小包合并导致的延迟但会增加少量网络报文适合游戏实时性要求高的场景。5.2async/await在 Unity 中莫名其妙不执行现象客户端连上后ConnectAsync返回了但后续ReceiveAsync卡住不触发或者服务端AcceptAsync只接受了一个客户端就再也不接受新的。原因很可能是没有给 Socket 设置非阻塞模式或者异步方法丢失了异常。TAP 风格的ReceiveAsync如果不被await异常会被吞掉而 Unity 的SynchronizationContext会让延续排到主线程如果主线程正卡在一个死循环里回调永远跑不到。解决所有 Async 方法外层包try/catch并在进入异步循环前确认IsConnected另外不要在主线程里做长时间运算或Thread.Sleep。调试时打开EditorLog的异常输出或直接看SocketError枚举。5.3 跨语言跨平台字节序不一致整数解析全是乱码现象C# 客户端发的数字Java 服务端读出来是负数或大数或者 Unity Android 和 Windows 之间互相收发坐标值完全错乱。原因BitConverter默认使用小端字节序而 Java 的DataOutputStream.writeInt是大端不同平台 CPU 架构也会影响默认端序。解决强制约定网络字节序为大端。C# 端可以这样转换ushort msgIdBigEndian (ushort)IPAddress.NetworkToHostOrder((short)msgId);如果消息体是 JSON字符串编码统一用 UTF-8不要用Encoding.Default。在双方联调前各自打印一段固定报文的 Hex 字节一眼就能看出端序是否对齐。5.4 Unity 编辑器切后台时Socket 直接崩溃现象PC 端切到其他窗口或者移动端锁屏再回来时连接已断开严重时编辑器直接抛出SocketException。原因Unity 在后台会暂停渲染循环但不会自动冻结线程如果 Socket 逻辑在OnApplicationPause里被错误关闭或者网络回调触发了已销毁的 GameObject。解决用OnApplicationPause(bool pause)控制心跳暂停和恢复而不要直接关 Socket。断线后进入重连流程每次重连间隔至少 3 秒避免服务端把重连风暴当成攻击。另外后台时服务端的Receive会正常返回数据这些数据要缓存在队列里回前台后集中处理而不是丢弃。5.5 局域网能通、外网连不上多半是防火墙或路由 NAT现象本机或同一 WiFi 下测试正常打包给异地朋友联机客户端一直连接超时。原因服务端监听了127.0.0.1而不是0.0.0.0或者 Windows 防火墙拦截了端口再或者运营商 NAT 导致端口映射没做。解决监听地址改为IPAddress.Any测试时临时关闭防火墙或用管理员权限放行端口真正要外网联机需要路由器做端口转发或者使用云服务器中转。如果只是临时测试可以用局域网内另一台机器开TcpListener监听0.0.0.0再拿手机连局域网 IP 验证一下是否通。6. 朝帧同步再进一步把 UDP 版 Socket 和可靠消息补进你的框架如果你做的是 MOBA 或者动作类游戏TCP 的队头阻塞会影响操作反馈。UDP 没有黏包概念但丢包和乱序得自己处理。两种路线全自研可靠 UDP或者在 UDP 之上实现 ACK 和重传。我一般会在现有代码上扩展出一个UdpClientBehaviour核心是ReceiveFromAsync配合EndPoint。UDP 下每个包都是独立消息不需要拆包但包体要加上序号和时间戳接收方才能检测乱序和丢包。using System.Net; using System.Net.Sockets; using System.Threading.Tasks; using UnityEngine; public class UdpClientBehaviour : MonoBehaviour { private Socket _udpSocket; private EndPoint _remoteEndPoint; private byte[] _recvBuffer new byte[4096]; public void Connect(string ip, int port) { _udpSocket new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); _remoteEndPoint new IPEndPoint(IPAddress.Parse(ip), port); } public async Task ReceiveLoopAsync() { while (_udpSocket ! null) { int len await _udpSocket.ReceiveFromAsync(_recvBuffer, SocketFlags.None, _remoteEndPoint); byte[] data new byte[len]; System.Array.Copy(_recvBuffer, data, len); // 解析消息编号丢进重排序缓冲 } } }UDP 的ReceiveFromAsync在 .NET 里支持ValueTaskint比 TCP 版本更简洁关键在于_remoteEndPoint在ReceiveFrom后会刷新为实际发送方地址多客户端时要注意区分来源。丢包重传的窗口大小取决于你的游戏类型帧同步通常采用 N 帧延迟等待不逐包重传状态同步则用最新的状态覆盖旧状态可以接受中间丢失。如果你不想自己造可靠 UDP 的轮子可以在协议层先用 TCP 做连接握手和关键消息再开一条 UDP 通道传输高频位置数据。这是很多商业项目的混合方案工程量比纯自研可靠 UDP 小得多而且稳定性更好。最后说一个我被坑过多次的习惯不要把 Socket 相关代码和 MonoBehaviour 生命周期绑死而是做成一个独立的NetworkService类由游戏启动时创建、退出时销毁。这样断线重连时不会因为场景切换丢失 socket 引用也更方便做自动化压力测试。实现完分包、心跳、分发和断线重连后记得写一个本地回环测试服务端和客户端跑在同一个进程里绕开真实网络先把逻辑层 bug 过滤掉再上局域网联调。希望这套从零搭的方案能帮你在 Unity 联机这条路上少踩几个坑。本文还有配套的精品资源点击获取
