简介:面向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这类对可靠性要求高的游戏用TCP;FPS、竞速这类对延迟极度敏感、可以接受丢包重算的用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或者改场景里的GameObject,Unity会报"can't 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 List<byte[]> Unpack(byte[] incoming, int incomingLength) { List<byte[]> messages = new List<byte[]>(); // 先把新数据拼到缓冲区尾部 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) | 体积最小、速度最快 | 需要手动维护协议、两端同步改动 | 正式多人游戏 |
| JsonUtility(Unity自带) | 简单、和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 多客户端之间的状态广播:写给谁、不写给谁
多人游戏最核心的问题是状态广播。服务器收到一个客户端的移动消息后,要把这个状态转发给其他人,但转发给谁有讲究。最简单粗暴的是全量广播——所有在线客户端都发。人少可以,人一多带宽立刻爆炸。通用的优化做法是:在房间/频道维度做过滤,同房间的才互相同步。更进一步的做法是AOI(Area 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,直接导致诡异的"发送一半连接关闭"。
问题3:Unity主线程卡死或UI不更新
现象:客户端连接服务器时界面卡住几秒钟,或者服务器发来的消息要等很久才在UI上显示。
原因:网络操作在Unity主线程上执行,Connect和Receive是阻塞方法,主线程一旦阻塞,整个游戏画面就停了。这是Unity网络编程里最高频的翻车点。
解决:所有网络连接、收发都放到单独线程。线程里收到消息后不要直接操作UI,把消息放进ConcurrentQueue,然后在Update里取出处理。这是Unity线程模型的标准解法,下一章给完整代码。
问题4:玩家断开连接后,服务器端线程还在跑
现象:一个客户端异常断网,服务器端对应的Receive线程不退出,CPU占用越来越高,日志还在打印一堆接收异常。
原因:客户端断网后,服务器端Receive不会及时返回0或抛异常,有些平台下它会阻塞很久,类似假死状态。
解决:启用心跳机制,服务器超过一定时间没收到某个客户端的心跳包,就主动关闭这个连接,并清理对应线程和状态数据。心跳间隔一般设5到10秒,超时阈值设心跳间隔的2到3倍。客户端也要做断线重连逻辑,服务器主动断开后客户端要感知并重连。
问题5:Receive缓冲区收到的数据被裁剪,位置坐标全乱了
现象:玩家坐标偶尔出现巨大的跳变,比如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 ConcurrentQueue<PlayerStateMsg> messageQueue = new ConcurrentQueue<PlayerStateMsg>(); 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一个消息对象,而是从一个Queue<PlayerStateMsg>里取对象,用完再还回去。配合之前的消息合并,GC压力会小很多。
这三件事做完,延迟和带宽基本能达到中小型多人游戏的要求。真正上线前,我建议做一次长时间稳定性测试:服务器端连跑24小时以上,客户端每隔半小时重连一次,观察内存增长和线程数。你会发现线程泄漏通常和心跳清理不彻底有关,内存增长多半是序列化时产生的大数组没有被正确置空。我自己做过一个教训很深的项目——服务器每30分钟内存涨100MB,查了两天才发现是客户端断开时服务器端只关闭了Socket,但没把对应客户端的最后一个消息缓冲区引用清掉,导致GC一直无法回收。从那以后我每次做网络模块,都会强制在断开连接的代码路径里走一遍「关闭Socket → 清队列 → 置空消息缓冲区 → 移除客户端状态表」的完整流程,一个都不能漏。希望帮到你。
本文还有配套的精品资源,点击获取