ARTICLE DETAIL

资讯详情

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

Unity多人联机Socket通信从零搭建:TCP/UDP、粘包处理与心跳重连实战

Unity多人联机Socket通信从零搭建:TCP/UDP、粘包处理与心跳重连实战 简介基于C#与Socket的Unity多人网络通信系统课程设计资源面向游戏开发方向的在校学生与Unity3D开发者可帮助理解服务器—客户端架构、数据序列化、多线程收发与断线重连等网络编程核心问题。压缩包内共有2000个文件以C#脚本、Unity场景与资源文件为主包含231个cs脚本、40个prefab预制体、31个shader着色器、27个png贴图、27个dll插件、22个asset资源等整体29.76MB目录结构完整可直接打开工程参考学习。资源完整展示了从服务器监听、客户端连接到双方数据交互的实现流程并涉及多线程管理、错误处理、数据压缩与性能优化等关键环节。通过对照实际代码可掌握System.Net下Socket类的用法、字节流序列化与反序列化、后台线程收发消息等技巧也可将工程改造为多人游戏原型或课程设计项目。已有367人学习下载适合需要实际项目参考或快速上手Unity网络通信的开发者。1. 做多人联机Unity项目最先撞上的一关就是Socket通信所谓“基于Socket的多人网络通信系统”落到Unity工程里就是客户端用C#的Socket/TcpClient建立TCP或UDP连接服务端用C#监听、接收、转发玩家数据让同一个房间里的多个客户端能互相看到位置、动作和消息。很多团队一提到联机就想着上现成中间件但换皮不换里底层绕来绕去还是这套API。这个方向适合刚接手联机原型、需要快速验证玩法的开发者也适合想搞懂网络层细节、准备自己写同步逻辑的从业者。它解决的第一个问题不是性能而是“消息怎么安全地到对方手里”。2. 为什么是Socket而不是UnityTransport选型与C#网络编程基础2.1 Socket、TCP与UDP多人通信先分清楚传输层写多人网络通信第一步不是打开Unity写脚本而是先确定传输层协议。Socket本身不是协议它只是操作系统提供的网络编程接口在C#里直接打交道的通常是TcpClient、TcpListener、UdpClient以及底层的Socket类。Unity引擎里自带的UNET已被移除后来的UnityTransportUTP是给DOTS和多人服务配套的底层传输它再封装也还是在Socket之上做文章。所以直接从Socket入手反而能把边界看得更清楚。选TCP还是UDP直接决定同步方案怎么写。一个简单的经验回合制、休闲社交、大厅聊天、房间管理这类弱实时场景TCP足够强对抗的射击、竞速、格斗默认走UDP因为TCP的重传和拥塞控制会带来明显延迟抖动。下面是两张协议的对比参考时看实时性要求即可对比项TCPUDP连接状态有连接、有会话无连接、无会话可靠性可靠丢包重传不可靠丢包不重传消息顺序保证顺序不保证顺序延迟表现抖动可能较大相对平稳但会丢数据应用层职责拆粘包、解半包序列号、确认、重传、去重典型场景大厅、房间、状态同步位置同步、操作指令、语音做标题里这种“多人网络通信系统”时我的建议是先用TCP把链路跑通把消息协议、连接管理、心跳机制都做出来等确认实时性不够了再把频率最高的位置同步切到UDP其余管理消息仍走TCP。不要一开始就双通道并行那样排错成本会翻倍。2.2 阻塞、异步与SocketAsyncEventArgs服务端并发模型怎么选多人通信的服务端要同时服务多个客户端最直接的做法是每来一个客户端就开一个线程在线程里阻塞读取。这套模型在客户端数量少时能跑但线程数量一多上下文切换会把服务端拖垮。常见做法是用async/await把阻塞I/O变成异步任务配合WaitHandle和Channel做消息分发写起来接近同步代码性能也够支撑中小规模。C#里TcpListener的AcceptTcpClientAsync和NetworkStream的ReadAsync/WriteAsync天然适合这种模型。一个典型的接收循环长这样// 服务端主循环持续接收新连接交给独立任务处理 public async Task RunAsync(CancellationToken ct) { var listener new TcpListener(IPAddress.Any, 9000); listener.Start(128); while (!ct.IsCancellationRequested) { var tcpClient await listener.AcceptTcpClientAsync(); // fire-and-forget不等待该客户端处理结束立刻接收下一个连接 _ HandleClientAsync(tcpClient, ct); Interlocked.Increment(ref _clientCount); } }listener.Start(128)里的128是backlog即操作系统允许排队的连接数不是最大连接数。AcceptTcpClientAsync每次只返回一个新连接后续处理放到不等待的任务里服务端才能持续接受新连接。Interlocked.Increment用于计数避免多线程下普通自增丢次数。这套写法在几百个连接内都非常可靠只有同时在线很多且单连接吞吐很高时才考虑SocketAsyncEventArgs。SocketAsyncEventArgs是另一种高性能方案通过事件回调避免任务调度开销但代码复杂度明显更高句柄管理、异步状态复用都很容易出错。我一般会在连接数预期超过上千、且每个客户端持续高频收发时再上它普通联机原型用async/await完全足够。2.3 粘包和半包C# Socket通信里绕不开的边界问题TCP是字节流没有消息边界。客户端连续Send两条消息服务端可能在一次Read里收到全部数据这叫粘包反过来一条消息被拆成两次Read才读完叫半包。如果不做应用层协议服务端根本不知道哪几个字节属于哪条消息多人通信的消息就会串台。解决思路是在每条消息前加固定长度的头部。最简做法是“4字节消息长度 消息体”长度字段表示消息体字节数。发送端在C#里这样封包// 构造一帧消息4字节长度 消息体 public static byte[] BuildPacket(byte[] body) { var packet new byte[4 body.Length]; // 统一使用大端字节序避免与服务器、其他客户端产生大小端分叉 BinaryPrimitives.WriteInt32BigEndian(packet, body.Length); Buffer.BlockCopy(body, 0, packet, 4, body.Length); return packet; }接收端要严格按照“先读满4字节长度再读满指定长度消息体”的流程缺多少字节就继续读多少一次读取永远不要试图“解析出多条消息”。这样才能把TCP流重新切回消息边界。这块在第三章的服务端和客户端里都会具体落地。3. 从零搭一套基于Socket的多人通信C#服务端与Unity客户端3.1 搭一个C# TCP服务端接收、解析与多客户端接入先用一个控制台项目把服务端搭出来因为服务端不依赖Unity调试起来比在编辑器里看日志快得多。新建.NET控制台应用放进下面的代码骨架。这里不引入任何第三方库只依赖System.Net.Sockets、System.IO、System.Threading.Tasks。服务端的核心职责有三件接收新连接、按帧读取消息、把消息广播给其他客户端。下面这段是一次完整接收并解析一帧消息的实现private static async Task HandleClientAsync(TcpClient client, CancellationToken ct) { using var stream client.GetStream(); var buffer new byte[4096]; while (!ct.IsCancellationRequested) { // 1. 先读满4字节长度头 await ReadExactlyAsync(stream, buffer, 4, ct); int bodyLength BinaryPrimitives.ReadInt32BigEndian(buffer.AsSpan(0, 4)); if (bodyLength 0 || bodyLength 64 * 1024) { break; // 非法长度直接断开避免恶意数据拖垮服务端 } // 2. 再按长度读满消息体 var body new byte[bodyLength]; await ReadExactlyAsync(stream, body, bodyLength, ct); // 3. 把收到的消息转发给其余客户端 await BroadcastAsync(body, ct); } } private static async Task ReadExactlyAsync(NetworkStream stream, byte[] buffer, int count, CancellationToken ct) { int offset 0; while (offset count) { int read await stream.ReadAsync(buffer.AsMemory(offset, count - offset), ct); if (read 0) { throw new EndOfStreamException(连接已被对端关闭); } offset read; } }ReadExactlyAsync是解决半包的关键网络流一次Read只保证返回当前可读的数据不保证填满请求长度。循环读直到凑齐count字节才能交给上层解析。长度头设为4字节ReadInt32BigEndian读取时保证跨平台字节序一致。64 * 1024是单帧消息体上限防止客户端发超长数据导致内存暴涨超过就直接断开。广播时要小心并发写同一个Socket。多个客户端的消息可能同时触发广播如果两个任务同时Write同一个NetworkStream会报“流不可写”或数据交错。常见处理是在服务端维护一个全局写锁或每个客户端一个专用发送队列。几百连接以内用全局lock就够private static readonly object WriteLock new object(); private static readonly ListTcpClient Clients new ListTcpClient(); private static Task BroadcastAsync(byte[] packet, CancellationToken ct) { byte[] fullPacket BuildPacket(packet); // 在头部加上长度 lock (WriteLock) { foreach (var c in Clients) { try { c.GetStream().Write(fullPacket, 0, fullPacket.Length); } catch { /* 对端断开稍后由心跳清理 */ } } } return Task.CompletedTask; }lock(WriteLock)保证同一时刻只有一个任务在遍历客户端列表发送避免两个任务同时写同一个TcpClient。要注意的是发送绝不能放在Parse消息的循环里直接执行否则一个慢客户端会把整个接收循环拖停。先把消息投递到队列再单独处理发送这是服务端吞吐量的分水岭。3.2 Unity客户端封装MonoBehaviour里的Socket连接与收发Unity客户端这部分核心是处理好异步接收和主线程的关系。接下来给出一个可以直接挂到场景空物体上的客户端组件支持连接、发送和接收消息using System; using System.Collections.Concurrent; using System.IO; using System.Net.Sockets; using System.Text; using System.Threading; using System.Threading.Tasks; using UnityEngine; public class SocketClient : MonoBehaviour { [SerializeField] private string serverHost 127.0.0.1; [SerializeField] private int serverPort 9000; private TcpClient _client; private NetworkStream _stream; private readonly ConcurrentQueuestring _incoming new ConcurrentQueuestring(); public async void Connect() { try { _client new TcpClient(); await _client.ConnectAsync(serverHost, serverPort); _stream _client.GetStream(); Debug.Log(已连接到服务端); _ ReceiveLoopAsync(); // 后台异步接收不阻塞主线程 } catch (Exception e) { Debug.LogError($连接失败{e.Message}); } } private async Task ReceiveLoopAsync() { var header new byte[4]; while (_client.Connected) { try { // 读长度头 await ReadExactlyAsync(_stream, header, 4); int len BinaryPrimitives.ReadInt32BigEndian(header); var body new byte[len]; await ReadExactlyAsync(_stream, body, len); // 线程里只解析和入队不碰任何Unity对象 _incoming.Enqueue(Encoding.UTF8.GetString(body)); } catch (Exception e) { Debug.LogWarning($接收异常{e.Message}); break; } } } public void Send(string message) { var body Encoding.UTF8.GetBytes(message); var packet BuildPacket(body); _stream.Write(packet, 0, packet.Length); } private void Update() { // 主线程统一处理消息规避跨线程访问Unity对象 while (_incoming.TryDequeue(out string msg)) { HandleMessage(msg); } } private void HandleMessage(string msg) { Debug.Log($收到消息{msg}); } private void OnDestroy() { _client?.Close(); } }async void Connect()是Unity中较常见的错误写法但这里刻意保留因为按钮或Start可以直接调用它且异常不会丢失。优点是写法简单缺点是不能用await等待调用后要立刻判断连接状态。接收循环使用后台执行ReceiveLoopAsync内部只做I/O和解析把解析完的字符串放进ConcurrentQueueUnity主线程在Update里消费队列。这样就不会出现后台线程去改Transform、调用Instantiate导致的崩溃。ConcurrentQueue是线程安全集合入队和出队可以同时进行。Unity的Update只在主线程执行因此HandleMessage里可以安全操作GameObject、Animator等。要注意的是Debug.Log本身允许跨线程但会带来微小的调用开销调试阶段无妨正式发布时建议只在接收循环外打关键日志。3.3 消息协议设计消息类型、序列化与字节布局多人通信最怕的就是“所有消息都长一个样”服务端无法区分这是位置同步还是聊天内容。一般做法是消息体最前面加一个类型字段后面跟业务字段。类型字段常见实现是1字节枚举ID最多支持256种消息对Unity联机项目完全够用。下面是一套可扩展的最小协议约定消息ID类型枚举含义主要字段1Join加入房间playerId, playerName2Move位置同步playerId, x, y, z3Chat聊天消息playerId, content4Leave主动离开playerId250Heartbeat心跳clientTime序列化方式上简单原型直接用JsonUtility序列化数据类然后转成UTF8字节。JsonUtility在Unity中序列化公共字段或[SerializeField]字段不序列化属性。示例[Serializable] public class MoveMessage { public int playerId; public float x; public float y; public float z; } // 发送端 var msg new MoveMessage { playerId 1, x 1.2f, y 0f, z 3.4f }; string json JsonUtility.ToJson(msg); byte[] body Encoding.UTF8.GetBytes(json); byte[] packet BuildPacket(body);消息类型放在哪取决于你封包的设计。上一章给出的是“长度消息体”没有类型字段。实际项目里建议改成“1字节类型 4字节长度 消息体”或者把类型塞进消息体头部让服务端在读长度前先读到类型。推荐布局是[0] 消息类型 (byte) [1..4] 消息体长度 (int32大端) [5..] 消息体客户端、服务端解析时先读第0字节确定消息路由再读4字节长度最后读消息体。这样广播时可以直接按类型决定转发策略例如Move消息只转给同房间玩家Heartbeat消息不需要广播。3.4 在编辑器里跑通最小闭环先让两个客户端互相看见把上面三步连起来就能在本地跑通第一个闭环。先启动服务端控制台程序确认它监听在9000端口。然后打开Unity项目建一个空场景把SocketClient挂到空物体上Inspector里Host填127.0.0.1、Port填9000在Awake或按钮回调里调用Connect。多人验证有个容易踩的坑Unity默认一次只能打开一个编辑器实例同一个项目不能开两份。常见做法是在场景里放两个SocketClient对象一个模拟玩家A一个模拟玩家B两个对象都连同一个服务端。服务端把A发来的Move消息广播给BB发来的消息也广播给A两边都能收到对方的位置。如果你的玩法需要两个独立窗口就Build一个客户端exe再用编辑器连同一个服务端。最小闭环验证的标准是A发送一条消息B的Console出现A的消息B回复一条A也能看到。能走到这步Socket通信的主干就算通了。往后加心跳、重连、房间逻辑都是在这条主干上修修补补。4. 把通信线程与Unity主线程对齐心跳、重连与状态同步4.1 Unity主线程与Socket线程为何跨线程调用会卡死或丢数据Unity的绝大多数API只能在主线程调用Transform、GameObject、Camera、物理组件都是如此。Socket接收天然在后台线程很多人第一次写网络脚本会把接收循环写在MonoBehaviour的Update里然后就在循环里直接Read。这样做的后果是主线程被网络I/O阻塞Unity的渲染、物理、UI全部卡住编辑器表现为“播放后卡成幻灯片”。更隐蔽的错误是后台线程里调用Unity对象。例如在ReceiveLoopAsync里直接执行transform.position new Vector3(...)Unity不会立刻报错但会随机出现“无法确定Transform是否在主线程”的异常甚至直接导致编辑器崩溃。这个问题的根源是Unity对象不是线程安全的任何跨线程访问都是未定义行为。正确的对齐方式在3.2节已经给出后台线程只做接收、解析、入队主线程在Update里统一处理。如果你在别的脚本里需要得到最新位置不要在后台线程改字段让主线程去读因为普通非volatile字段的可见性没有保证。用ConcurrentQueue投递数据主线程消费后更新状态是Unity里最可靠的消息对齐方式。对于高频位置同步还可以用双缓冲后台线程写最新快照主线程每帧交换引用避免Queue频繁分配。4.2 心跳与超时让服务端及时清理掉线玩家多人网络通信里最恶劣的场景不是报错而是“玩家拔了网线服务端却不知道”。TCP在连接空闲时没有任何通知断开的连接可能占着列表几天不释放。心跳机制解决的就是这个问题实现上也最简单客户端定时发送一条短消息服务端更新该客户端最后活跃时间服务端定时巡检超过阈值就强制断开。客户端在Update里累计心跳计时每到2秒发一条Heartbeat消息服务端收到后只更新时间戳不广播。服务端巡检可以用一个周期Taskprivate static readonly DictionaryTcpClient, DateTime LastSeen new(); private static async Task CheckAliveAsync(CancellationToken ct) { while (!ct.IsCancellationRequested) { await Task.Delay(5000, ct); // 每5秒巡检一次 DateTime now DateTime.UtcNow; ListTcpClient dead new(); foreach (var kv in LastSeen) { if ((now - kv.Value).TotalSeconds 15) { dead.Add(kv.Key); } } foreach (var c in dead) { c.Close(); // 触发接收循环抛异常从而走清理逻辑 } } }心跳间隔、超时阈值、巡检周期要一起调。间隔设2秒、超时15秒、巡检5秒适合局域网和移动网络。如果玩家在弱网环境2秒间隔可能频繁触发断线那就把间隔放宽到5秒、超时30秒代价是僵尸连接多占用一段时间。参数调整没有绝对标准先按下面这张表设定再根据真实网络情况微调参数推荐值调整方向心跳间隔25秒网络抖动大就调大超时阈值1030秒比间隔至少大3倍巡检周期5秒服务端负载高就调大实现时记得用UtcNow而不是Now避免服务器时区跳变引发误判。时间比较用毫秒还是秒都要统一不然会出现“明明有玩家在线却被踢下线”的玄学问题。4.3 断线重连与场景切换把玩家状态恢复到连接建立之前联机游戏只要走出编辑器就一定会遇到断线。重连不是简单地把TcpClient重新new一个玩家在服务端的身份、是否在房间、位置数据都要恢复。用Socket做重连时务必要设计连接状态机public enum ConnectionState { Disconnected, Connecting, Connected, Reconnecting }客户端在收到“连接断开”后不是直接抛给玩家重连界面而是进入Reconnecting状态尝试重新建立连接。重连不能用固定1秒间隔一直打服务端重启、玩家切换网络时短时间内重试只会增加无效流量。常见做法是指数退避第一次1秒第二次2秒第三次4秒最多到8秒或16秒同时限制最大重试次数。这样可以避免网络恢复前疯狂连接也避免服务端被一堆重连请求打垮。场景切换是另一个容易翻车的地方。很多人把SocketClient挂在某个场景的GameObject上切换场景时OnDestroy把连接关闭回到主场景又得重新连。正确做法是把网络管理器做成常驻单例放到DontDestroyOnLoad切换场景时保持连接不断开只重置表现层。玩家重连后服务端要支持用playerId重新绑定会话客户端重连成功后重新发送Join消息服务端恢复玩家所在的房间和状态。这样从“断线”到“重新开始操作”的路径是完整的。5. 解决基于Socket的多人通信的常见问题连不上、卡顿、内存暴涨5.1 连不上本地服务端回环地址、防火墙与端口占用现象Unity客户端Connect时报SocketException: Connection refused或者一直转圈后超时。原因最常见的是服务端根本没启动其次是端口被其他进程占用改个端口就好。还有一个隐蔽原因TcpListener监听的是IPAddress.Any但Unity编辑器里填的host是局域网IP而服务端所在机器防火墙拦截了入站连接。解决先确认服务端控制台是否打印“listening”。本地测就用127.0.0.1不要用主机名。如果局域网真机连不上用PowerShell在服务端执行Test-NetConnection -ComputerName 本机IP -Port 9000看TcpTestSucceeded是否为True。为False就检查Windows防火墙给程序放行。端口冲突时用netstat -ano | findstr 9000看占用进程换一个端口或杀掉旧进程。5.2 消息串台粘包导致的解析错位现象A发了两条消息“你好”和“位置(1,2,3)”B收到的是乱码或者两条消息拼在一起。原因TCP流没有边界接收端如果按“每次Read到的字节数”直接当一条完整消息就会把两条消息拆错或拼接错。半包更隐蔽一条消息只读了一半就开始解析解析出来的字段全错。解决统一用“4字节长度头消息体”的帧格式接收端必须循环读满固定长度。感到蹊跷时先打印收到的原始字节数再对照长度头十有八九是长度读取错位。长度头多读一个字节后面所有消息都会错位这是做Socket通信最容易积累的翻车点。建议在一开始就把封包和解包封装成独立类所有消息收发都走同一套代码不要每个脚本自己写Read。5.3 编辑器卡顿在Update里做同步接收现象游戏运行后帧率波动大旋转摄像机时明显卡顿但打开Profiler又看不到明显的CPU热点。原因网络Receive阻塞了主线程。常见写法是Update里调用_stream.Read()去等数据服务器没消息时Read会一直阻塞整个主线程的渲染、输入、更新全部停摆。这个Bug在本地环回测试时不容易暴露因为服务端和客户端在同一台机器数据几乎立刻到达一旦部署到远程服务器延迟拉高卡顿就非常明显。解决接收循环必须放在非主线程的Task中解析完成的数据投递到ConcurrentQueue主线程的Update只做非阻塞的出队。对于位置同步这种高频消息不要在Update里逐个处理队列里的每条消息而是只取最新快照例如每帧只处理最后一条位置消息中间状态直接丢弃从而避免卡顿。5.4 大量客户端连接被拒backlog与端口复用现象本地测试时5个客户端连接正常一小时内联调20个客户端后面几个总报连接超时重启服务端又恢复。原因客户端数量超过了TcpListener启动时的backlog上限新连接在内核排队数量达到上限后被丢弃。另一个原因是服务端频繁重启端口处于TIME_WAIT状态新监听绑不上。解决启动监听时显式指定backlog例如listener.Start(512)。开发期经常重启服务端的话在创建TcpListener之前设置SocketOptionName.ReuseAddressvar socket new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); var listener new TcpListener(socket); listener.Start(512);顺便检查下代码里是否每个客户端处理完还有未释放的TcpClient连接数只涨不跌往往是客户端列表只增不减导致资源耗尽。心跳超时清理逻辑加上后这个问题通常会一并缓解。5.5 真机连不上局域网服务器Android权限与iOS本地网络现象编辑器里连接本地服务器一切正常打包到Android手机、连同一个WiFi却连不上iOS上第一次连接可能弹出“允许查找并连接到本地网络设备”的提示拒绝后连不上。原因Android打包时没有声明INTERNET权限iOS 14以后访问本地网络必须声明本地网络用途否则系统默认拒绝。解决在Unity的Player Settings里把Android的Internet Access设为Require同时确认打包后的AndroidManifest里有uses-permission android:nameandroid.permission.INTERNET/。iOS需要在Info.plist里添加NSLocalNetworkUsageDescription描述一句话例如“用于连接同一局域网内的游戏服务器”。做过这一步之后真机连接局域网服务端就是一个纯配置问题不再需要改代码。6. 多人通信上线前的验证延迟、丢包与压力测试6.1 用几十行代码写一个压测客户端上线前最容易忽略的就是同时在线量。可以用一个控制台程序循环创建TcpClient模拟几十个客户端连接并定时发送消息观察服务端是否还能及时广播for (int i 0; i 50; i) { var c new TcpClient(); await c.ConnectAsync(127.0.0.1, 9000); _ SendLoopAsync(c, i); // 每个客户端每秒发一条Move消息 }这段不是为了模拟真实玩家而是用来暴露服务端的资源瓶颈比如连接数上限、广播锁竞争、内存占用。6.2 延迟与丢包先看时间戳再谈优化验证延迟不要在本地环回上测环回延迟接近于零测不出问题。至少要在局域网内用两台设备测或用一台云服务器同时跑服务端和客户端。每个消息带上客户端的发送时间戳服务端或对端收到后计算差值。延迟曲线如果出现周期性尖峰先看GC和日志网络层丢包则要看是否有大量重传这个通过服务端计数和OS工具观察。6.3 现场日志让问题可以被复现联机问题最难的是复现。我一般会在接收循环关键节点打带连接ID和时间戳的日志写入本地文件而不是只打到ConsoleUnity真机上Console是看不见的。数据格式固定成一行一个事件包含毫秒时间戳、玩家ID、消息类型、字节长度。这样玩家反馈“卡了一下”“被踢了”时拿着日志就能还原当时的消息序列。协议改版后会有很多踩坑但日志格式从第一天就定好后面会省很多事。我自己在Socket联机这个方向最深的体会是先把消息边界和线程对齐做对再去调心跳和压力。这两件事不做后面所有功能都是空中楼阁。希望这份从选型到排错的经验能帮到你。本文还有配套的精品资源点击获取
返回列表