
简介面向Unity游戏开发与C#网络编程学习者压缩包完整呈现了基于Socket的多人网络通信系统课程设计覆盖服务器端监听与连接管理、客户端请求收发、字节流序列化/反序列化、多线程收发与错误处理等关键环节适用于毕业设计、实训项目或入门多人在线游戏开发。包内共2000个文件以C#脚本、Unity场景、预设体、材质、着色器、PNG图片与DLL库为主脚本场景便于直接阅读核心实现库文件保障工程可运行压缩包约29.76MB结构清晰便于按需取用。已有368人学习下载适合希望搭建多人联机原型、理解TCP底层通信流程的开发者可借助此工程掌握Socket在Unity中的接入方式、多客户端连接管理与游戏状态同步思路也能作为二次开发与拆解学习的基础。1. 在Unity里做多人网络通信先搞清楚Socket这一层到底管什么做Unity多人游戏网络几乎是绕不开的坎。很多人一开始会去查Mirror、Photon这些现成框架但这类框架底层封装的仍然是Socket。一旦遇到连接不稳定、数据不同步、粘包拆包没搞懂Socket这一层排查起来就全是黑匣子。这份资源给的是Unity里用C#基于Socket写多人通信的完整课程设计从服务器端的监听、客户端的连接到游戏状态的序列化同步、多线程处理、断线重连都有覆盖。它的定位是教学骨架不是商业级框架但正好适合两类人一类是想把网络原理吃透再上框架的Unity开发者另一类是学校课程设计或毕业设计需要交网络通信模块的学生。看懂这份代码再去用Mirror这类封装框架思路会清晰很多。2. Socket通信的骨架从Bind、Listen到Accept、Connect把每个参数吃透2.1 TCP与UDP的选型Unity多人游戏默认走TCP但不是所有场景都合适Socket在C#里对应System.Net.Sockets命名空间下的Socket类Unity和普通C#控制台程序在这层没有本质区别区别只在于Unity的单线程主循环和它的生命周期管理。先解决选型问题——TCP还是UDP这决定了后面所有代码的写法。常见做法是MOBA、MMORPG这类对可靠性要求高的游戏用TCPFPS、竞速这类对延迟极度敏感、可以接受丢包重算的用UDP。TCP自带确认重传和顺序保证写起来省心但会有粘包问题而且网络差时延迟会明显上升。UDP没有这些保证但需要自己在应用层做可靠传输复杂度直接往上跳一层。这份课程设计用的是TCP对初学者是合理的。我一般建议第一次做多人同步的人先完整跑通TCP把连接、收发、线程、拆包这套流程走熟了再考虑UDP。TCP的关键方法就几个Bind绑定本地地址和端口Listen进入监听状态Accept接受客户端连接Connect发起连接Send和Receive收发数据。注意Unity的Socket类和.NET Framework的Socket类在API上几乎一致但Unity 2018之后建议用.NET 4.x或.NET Standard 2.0兼容级别这样能用上更新的异步API。2.2 服务器端代码一个可运行的TCP监听骨架先看服务器端最小实现。这段代码可以直接放进Unity项目的普通C#脚本里不依赖MonoBehaviour设计成独立类更清晰。using System; using System.Net; using System.Net.Sockets; using System.Text; using System.Threading; public class TcpServer { private Socket listenSocket; private Thread acceptThread; private volatile bool isRunning; public void Start(int port) { isRunning true; listenSocket new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); listenSocket.Bind(new IPEndPoint(IPAddress.Any, port)); listenSocket.Listen(10); // 最大等待连接数 acceptThread new Thread(AcceptLoop); acceptThread.IsBackground true; acceptThread.Start(); } private void AcceptLoop() { while (isRunning) { try { // Accept是阻塞调用会卡住当前线程直到有客户端连接 Socket client listenSocket.Accept(); // 每个新连接单独开线程处理避免一个客户端影响其他客户端 Thread clientThread new Thread(() HandleClient(client)); clientThread.IsBackground true; clientThread.Start(); } catch (SocketException ex) { if (isRunning) Console.WriteLine(Accept异常: ex.Message); } } } private void HandleClient(Socket client) { byte[] buffer new byte[4096]; while (isRunning) { int length client.Receive(buffer); if (length 0) break; // 客户端断开 string msg Encoding.UTF8.GetString(buffer, 0, length); Console.WriteLine(收到: msg); client.Send(Encoding.UTF8.GetBytes(服务器已收到: msg)); } client.Close(); } public void Stop() { isRunning false; listenSocket.Close(); } }逻辑说明Accept阻塞在独立线程上每来一个连接就新开一个线程处理这是最直观但也是资源开销最大的方案。volatile bool isRunning用来做线程间的停止信号避免直接暴力终止线程。参数说明IPAddress.Any代表监听本机所有网卡地址如果只想让局域网内访问用IPAddress.Parse(0.0.0.0)效果一样如果只允许本机调试可以改IPAddress.Loopback。Listen(10)是积压队列长度超过10个同时连接请求时多余的会被拒绝。Receive返回0表示对端正常关闭了连接。这个方案的瓶颈也很明显一个客户端一个线程100个客户端就是100个线程线程切换开销会拖垮服务器。课程设计规模无所谓但我还是建议后续把线程模型改成ThreadPool或者async/await。2.3 客户端代码Connect到服务器并循环收发客户端的核心就是Connect、Send、Receive三步。注意Unity的主线程不能做阻塞式网络等待否则帧率会直接掉到个位数所以客户端网络操作也放在单独线程。using System; using System.Net; using System.Net.Sockets; using System.Text; using System.Threading; public class TcpClientConnector { private Socket clientSocket; private Thread receiveThread; private volatile bool isConnected; public bool Connect(string ip, int port) { clientSocket new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); try { clientSocket.Connect(new IPEndPoint(IPAddress.Parse(ip), port)); isConnected true; receiveThread new Thread(ReceiveLoop); receiveThread.IsBackground true; receiveThread.Start(); return true; } catch (SocketException ex) { Console.WriteLine(连接失败: ex.Message); return false; } } private void ReceiveLoop() { byte[] buffer new byte[4096]; while (isConnected) { int length 0; try { length clientSocket.Receive(buffer); } catch (SocketException) { break; // 连接被强制关闭 } if (length 0) break; string msg Encoding.UTF8.GetString(buffer, 0, length); // 这里不能直接操作Unity对象需要用队列转发到主线程 Console.WriteLine(服务器说: msg); } isConnected false; } public void Send(string message) { if (!isConnected) return; byte[] data Encoding.UTF8.GetBytes(message); clientSocket.Send(data); } public void Disconnect() { isConnected false; clientSocket.Close(); } }逻辑说明客户端连接服务器后接收循环独立线程跑着发送操作由外部调用线程触发。这里有个Unity特有的坑——ReceiveLoop里如果直接改UI或者改场景里的GameObjectUnity会报cant access a destroyed object或者在主线程之外修改组件导致随机崩溃。参数说明IPAddress.Parse(ip)只接受点分十进制的IPv4地址如果你传入域名这里会直接抛异常需要用Dns.GetHostAddresses先解析。Receive缓冲区大小设为4096字节超过这个长度的消息会被分多次接收这正是后面要说的粘包半包问题的起点。运行这三段代码先启动服务器再启动客户端能收到服务器已收到的回显就说明整个TCP链路已经通了。这是最基础的地基地基之上才是协议设计。3. 消息协议从裸字符串到结构化数据的序列化与拆包3.1 为什么直接发字符串不行Unity游戏需要的是结构化数据第2章的代码用Encoding.UTF8.GetString收发消息这在演示链路时没问题但真正做游戏就不够用了。游戏里的网络消息是玩家位置、旋转角度、血量、动画状态、操作指令这些是结构体、是多个字段的组合。一个玩家移动的消息可能是playerId x y z rotation如果全拼成一个字符串再用分隔符切分字段一多就乱套而且字符串表示的浮点数还会损失精度。常见做法是定义一个ByteBuffer类统一做序列化。写入端用WriteInt、WriteFloat、WriteString读取端用对应的ReadInt、ReadFloat、ReadString。这样收发双方看到的是同一个数据格式约定也就是协议。3.2 消息帧结构长度前缀解决粘包消息ID分流处理TCP是流协议Send了两次不代表Receive会收到两次可能一次全收到也可能半个包。业界通用解法是给每条消息加一个长度前缀也就是帧。我习惯的帧结构是消息长度(4字节int) 消息ID(2字节short) 消息体(长度由消息长度字段指定)。接收端按这个格式拆包完整剥离一条消息后再处理下一条。给一个可运行的拆包逻辑public class MessagePacker { private byte[] buffer new byte[8192]; private int bufferLength 0; // 外部把收到的原始数据喂进来返回完整消息列表 public Listbyte[] Unpack(byte[] incoming, int incomingLength) { Listbyte[] messages new Listbyte[](); // 先把新数据拼到缓冲区尾部 Array.Copy(incoming, 0, buffer, bufferLength, incomingLength); bufferLength incomingLength; int offset 0; while (bufferLength - offset 6) // 至少读头部 { int msgLength BitConverter.ToInt32(buffer, offset); short msgId BitConverter.ToInt16(buffer, offset 4); // 不完整跳出等下一波数据 if (bufferLength - offset - 6 msgLength) break; // 完整消息拷贝消息体部分 byte[] body new byte[msgLength]; Array.Copy(buffer, offset 6, body, 0, msgLength); messages.Add(body); offset 6 msgLength; } // 把剩余未处理的数据挪到缓冲区开头 if (offset 0) { int remain bufferLength - offset; Array.Copy(buffer, offset, buffer, 0, remain); bufferLength remain; } return messages; } }逻辑说明核心是维护一个手动管理的字节缓冲区。每批数据进来先拼到尾部然后循环尝试拆解——头部6字节固定长度读取长度字段后判断消息体是否完整。不完整就跳出循环等下一次数据到达再拼。拆完一轮后把剩余数据往前挪避免缓冲区越堆越多。参数说明BitConverter.ToInt32默认是小端序如果你和服务器约定用大端序某些语言如Java默认大端这里要手动做字节序反转。缓冲区大小8192要和Receive的缓冲区分开理解——一个是Socket收数据的暂存区一个是拆包器的持久区。建议拆包器缓冲区至少是接收缓冲区两倍大小否则高频消息下还会出现二次拼接问题。3.3 Unity里的序列化方案BinaryFormatter谨慎用JsonUtility有局限有了帧结构还要有序列化方案。Unity环境中常见三种方案优点缺点适用场景二进制手工序列化ByteBuffer体积最小、速度最快需要手动维护协议、两端同步改动正式多人游戏JsonUtilityUnity自带简单、和MonoBehaviour兼容性好不支持Dictionary、不支持直接序列化属性只序列化字段原型、调试消息Newtonsoft.Json功能全、支持Dictionary/多态需要引入第三方DLL、体积稍大服务器与Unity间通信这个资源里用的是二进制手工方式也是我推荐的主方向。JsonUtility有个很坑的限定只序列化[Serializable]类的公有字段和标注了的私有字段属性Property完全不理会Dictionary直接抛异常。你辛辛苦苦写了个含Dictionary的网络消息结构一序列化全丢翻车现场。给一个二进制序列化示例public class PlayerStateMsg { public int playerId; public float x, y, z; public float rotY; public void WriteTo(ByteBuffer buffer) { buffer.WriteInt(playerId); buffer.WriteFloat(x); buffer.WriteFloat(y); buffer.WriteFloat(z); buffer.WriteFloat(rotY); } public static PlayerStateMsg ReadFrom(ByteBuffer buffer) { PlayerStateMsg msg new PlayerStateMsg(); msg.playerId buffer.ReadInt(); msg.x buffer.ReadFloat(); msg.y buffer.ReadFloat(); msg.z buffer.ReadFloat(); msg.rotY buffer.ReadFloat(); return msg; } }逻辑说明每来一个网络消息类型就写一组WriteTo和ReadFrom序列化与反序列化逻辑挨在一起。注意字段的顺序必须完全一致收发两端谁改了顺序另一端还在按旧顺序读数据就是乱的而且这种错乱根本不会抛异常你会看到玩家位置偶尔跳一下表现得像网络玄学问题。参数说明rotY只同步了Y轴旋转Unity的3D角色旋转通常只需要绕Y轴欧拉角同步全部三个欧拉角会有万向锁问题而且数据量多了三倍。这里省略了动画状态、速度等其他字段正式项目里这些都要进协议消息ID会从1排到几十上百。4. 状态同步与并发连接这些情况最容易翻车4.1 多客户端之间的状态广播写给谁、不写给谁多人游戏最核心的问题是状态广播。服务器收到一个客户端的移动消息后要把这个状态转发给其他人但转发给谁有讲究。最简单粗暴的是全量广播——所有在线客户端都发。人少可以人一多带宽立刻爆炸。通用的优化做法是在房间/频道维度做过滤同房间的才互相同步。更进一步的做法是AOIArea of Interest只同步视野范围内的玩家状态这是大地图MMO的标配。这份资源里的课程设计大概率范围内只做了全量广播或者房间广播。如果要做广播注意一个顺序问题服务器先更新自己的游戏状态再广播给所有客户端不能边收边发边改。正确顺序是收到消息 → 解析 → 修改该客户端对应的状态对象 → 遍历状态表 → 发给其他客户端。否则两个客户端同时发出移动指令服务器收到顺序不同状态就乱了。4.2 避坑章网络通信里的常见问题与排查记录网络部分的问题很有规律下面这几条是实操里最常碰到的按现象→原因→解决写出来省得你反复踩。问题1提示通常每个套接字地址只允许使用一次现象服务器运行一次之后停掉再启动就报SocketException: Address already in use而且等很久才能重新启动。原因TCP连接关闭后端口不会立刻释放而是进入TIME_WAIT状态默认持续2分钟。服务器进程退出时如果有连接还在这个状态端口就被占着。解决在绑定前设置地址复用选项。listenSocket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);注意参数ReuseAddress要在Bind之前设置绑定之后再设没有效果。如果这样还报错检查是不是有僵尸进程还占着端口Windows下用netstat -ano | findstr 端口号看PID然后去任务管理器结束对应进程。问题2消息错位和粘包收到的数据拼成了乱码现象客户端频繁发送消息时服务器经常一次收到两条消息的数据或者一条消息被拆成两半解析出来的数据对不上。原因TCP是字节流没有消息边界。调用Send两次对方可能一次Receive全收到调用Send一次大数据对方可能分成几次Receive。解决使用第3章的MessagePacker按先读长度字段再读消息体的方式拆包。除此之外Send端不要把Send调用忘在using块里using块会在方法结束时Dispose掉Socket直接导致诡异的发送一半连接关闭。问题3Unity主线程卡死或UI不更新现象客户端连接服务器时界面卡住几秒钟或者服务器发来的消息要等很久才在UI上显示。原因网络操作在Unity主线程上执行Connect和Receive是阻塞方法主线程一旦阻塞整个游戏画面就停了。这是Unity网络编程里最高频的翻车点。解决所有网络连接、收发都放到单独线程。线程里收到消息后不要直接操作UI把消息放进ConcurrentQueue然后在Update里取出处理。这是Unity线程模型的标准解法下一章给完整代码。问题4玩家断开连接后服务器端线程还在跑现象一个客户端异常断网服务器端对应的Receive线程不退出CPU占用越来越高日志还在打印一堆接收异常。原因客户端断网后服务器端Receive不会及时返回0或抛异常有些平台下它会阻塞很久类似假死状态。解决启用心跳机制服务器超过一定时间没收到某个客户端的心跳包就主动关闭这个连接并清理对应线程和状态数据。心跳间隔一般设5到10秒超时阈值设心跳间隔的2到3倍。客户端也要做断线重连逻辑服务器主动断开后客户端要感知并重连。问题5Receive缓冲区收到的数据被裁剪位置坐标全乱了现象玩家坐标偶尔出现巨大的跳变比如x坐标从100突然变成-5000然后一瞬间又跳回来。原因缓冲区大小不足一次Receive收到的数据长度超过了缓冲区容量后半部分数据被截断丢弃。解决把接收缓冲区调大或者在同一线程内连续调用Receive直到拿完整条消息。注意Receive返回的是实际收到的字节数不是缓冲区大小务必以返回值为准处理不要用数组长度做遍历边界。5. 主线程与网络线程的协作队列、心跳与断线重连5.1 用ConcurrentQueue做线程安全的消息桥Unity主线程是单线程的网络线程收数据主线程负责渲染和逻辑两者之间必须有一座桥。我的固定做法是网络线程把消息解析成对象扔进ConcurrentQueue主线程的Update每帧取出队列里的消息并处理。这里有几个做法上的细节很重要。第一不要在Update里一次性取完全部消息。队列里可能堆积了几百条逐条处理会造成这一帧的卡顿。我一般每帧最多取20条剩下的留到下一帧。第二往队列里放消息的对象要避免复用否则主线程还在用网络线程又往同一个对象里写新数据会出数据竞争。第三ConcurrentQueue虽然线程安全但它的Count属性在并发下是近似值判断是否为空用IsEmpty而不是Count 0。using System.Collections.Concurrent; using UnityEngine; public class NetworkMessageDispatcher : MonoBehaviour { private ConcurrentQueuePlayerStateMsg messageQueue new ConcurrentQueuePlayerStateMsg(); private TcpClientConnector client; void Start() { client new TcpClientConnector(); client.OnMessageReceived OnNetworkMessage; client.Connect(127.0.0.1, 8888); } void Update() { // 每帧最多处理20条防止一帧卡顿 int processed 0; while (!messageQueue.IsEmpty processed 20) { PlayerStateMsg msg; if (messageQueue.TryDequeue(out msg)) { ApplyPlayerState(msg); processed; } } } private void OnNetworkMessage(PlayerStateMsg msg) { // 网络线程调用只入队不做任何Unity操作 messageQueue.Enqueue(msg); } private void ApplyPlayerState(PlayerStateMsg msg) { // 主线程安全操作Transform GameObject player GetPlayerById(msg.playerId); if (player ! null) { Vector3 pos new Vector3(msg.x, msg.y, msg.z); player.transform.position Vector3.Lerp(player.transform.position, pos, 0.2f); } } }逻辑说明网络线程回调OnNetworkMessage只负责把消息入队主线程Update负责消费并应用到游戏对象上。用ConcurrentQueue避免了手动加锁的复杂度也避免了跨线程直接操作Transform的崩溃风险。Vector3.Lerp是插值移动目的是让看到的位置变化平滑一些但插值系数要按帧率调——60帧下0.2的插值意味着5帧内基本到位这个值还算合理。参数说明TcpClientConnector里的OnMessageReceived事件定义需要自行补全在ReceiveLoop解析出完整消息后触发。注意Update里的TryDequeue是线程安全的但ApplyPlayerState里如果触发了销毁GameObject之类的操作要额外保守——消息队列里的残留对象访问已被销毁的GameObject会报MissingReferenceException处理消息前先判断player null。5.2 心跳与断线重连不做这两个机制连接就是一场赌博网络连接随时可能断而且断得很隐蔽。客户端直接拔网线服务器端不会立刻感知因为TCP靠的是超时后重传机制这个过程可能要几十秒甚至几分钟。心跳就是为了快速探测死连接。客户端每隔心跳间隔发一个包服务器端记录每个客户端最后一次心跳时间定时检查超时超时就清理。心跳间隔的取值是个权衡。5秒以内服务器能及时感知断线但心跳包本身会占带宽10秒以上省流量但死连接会占用更久的线程和内存资源。课程设计和中小型游戏10秒心跳、35秒超时是比较平衡的组合。心跳包不携带业务数据就是一个带消息ID的空体服务器收到只更新时间戳不做业务处理。断线重连的难点在重连时的状态恢复。客户端重连成功后服务器端不知道它之前是几号玩家、在哪个房间所以客户端要保存好上次连接时的玩家ID和会话信息重连后重新发一次LoginMsg或RejoinMsg由服务器把状态补发给它。这个逻辑不做重连成功也只是个空壳玩家看到的是自己的角色消失或者卡在原地。5.3 局域网测试的实战配置防火墙、IP别用错本机测试用127.0.0.1没问题但要测真实的多人效果本机不行必须在局域网里跑。服务器电脑上用ipconfig查一下局域网IP比如192.168.1.100客户端填这个IP连接。注意Windows防火墙默认会拦截外部机器的入站连接测试前在防火墙里入站规则放行服务器端口否则客户端连过去永远超时。这属于防火墙层面没有放行导致的连接失败不是代码逻辑问题。安卓模拟器要注意一点模拟器内部的127.0.0.1是模拟器自己不是宿主机得用10.0.2.2访问宿主机端口这个是Android模拟器的特殊映射。真机测试时手机和电脑必须在同一局域网网段WiFi有AP隔离功能的话也连不上这是网络环境问题查代码查半天不如先查AP隔离设置。6. 延迟与带宽的进一步优化从能跑到能上线到了这一步你的系统已经能跑通服务器监听、多个客户端连接、状态同步、心跳断线重连都能工作。接下来几个技巧决定了这套系统能不能从实验室走向真实游戏场景。第一件要做的事是开启TCP_NODELAY。C#的Socket默认启用了Nagle算法它会把小包攒在一起发送以降低网络开销代价是延迟变大。游戏里的移动消息都是小包攒包带来的延迟会让位置同步明显变慢。设置方式只需一行clientSocket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.NoDelay, true);第二件事是合并消息。不要每帧对每个玩家都发一条状态包而是把所有玩家的状态攒进一个缓冲区每一到两个网络帧50毫秒左右批量发送一次。这条优化能把带宽降到原来的几十分之一。课程设计里的全量广播可能不受影响但当你真的处理几十个玩家同时移动时这个合并是质变。第三件事是对象池。高频的网络消息对象比如PlayerStateMsg会在GC里产生大量垃圾。消息量大的时候Unity的GC停顿会直接影响帧率。解决方法是用池化不是每次new一个消息对象而是从一个QueuePlayerStateMsg里取对象用完再还回去。配合之前的消息合并GC压力会小很多。这三件事做完延迟和带宽基本能达到中小型多人游戏的要求。真正上线前我建议做一次长时间稳定性测试服务器端连跑24小时以上客户端每隔半小时重连一次观察内存增长和线程数。你会发现线程泄漏通常和心跳清理不彻底有关内存增长多半是序列化时产生的大数组没有被正确置空。我自己做过一个教训很深的项目——服务器每30分钟内存涨100MB查了两天才发现是客户端断开时服务器端只关闭了Socket但没把对应客户端的最后一个消息缓冲区引用清掉导致GC一直无法回收。从那以后我每次做网络模块都会强制在断开连接的代码路径里走一遍「关闭Socket → 清队列 → 置空消息缓冲区 → 移除客户端状态表」的完整流程一个都不能漏。希望帮到你。本文还有配套的精品资源点击获取