Unity网络基础UDP客户端
第一部分核心概念预习在看代码之前你需要明白这几个核心概念UDP (User Datagram Protocol)就像寄明信片。你只管把信发出去不需要先跟对方建立连接速度极快但不保证对方一定能收到也不保证顺序。适合做对实时性要求高的游戏如王者荣耀、吃鸡的移动同步。Socket套接字网络通信的基石。如果把网络比作公路Socket 就是跑在公路上的货车负责装载和卸载货物数据。多线程 (ThreadPool)Unity 的主线程Update负责画面的渲染。网络接收和发送如果放在主线程一旦网络卡顿游戏画面就会卡死。所以我们需要把网络收发放到后台去打工子线程。队列 (Queue)一种“先进先出”FIFO的数据结构。就像排队买票先排队的先买到。代码里用作消息的“收件箱”和“发件箱”。第二部分代码详解与注释我为你的代码加上了详尽的注释并解释了每个变量存在的意义。请对照着看using System; using System.Collections; using System.Collections.Generic; using System.Net; using System.Net.Sockets; using System.Threading; using UnityEngine; public class UdpNetMgr : MonoBehaviour { // 【单例模式】保证整个游戏只有一个网络管理器 private static UdpNetMgr instance; public static UdpNetMgr Instance instance; // 【核心变量说明】 // serverIpPoint: 记住服务器的地址和端口号也就是你要把数据发给谁。 private EndPoint serverIpPoint; // socket: 通信的工具就是前面比喻的“货车”。 private Socket socket; // sendQueue: 发件箱。你要发送的消息先排队放在这里。 private QueueBaseMsg sendQueue new QueueBaseMsg(); // receiveQueue: 收件箱。后台线程收到消息后先堆在这里。 private QueueBaseMsg receiveQueue new QueueBaseMsg(); // cacheBytes: 缓存区。用来存放每次从网络接收到的原始二进制字节大小512字节。 private byte[] cacheBytes new byte[512]; // isClose: 这是一个“开关”用来控制后台线程要不要继续干活。 private bool isClose true; void Awake() { instance this; // 切换场景时不销毁这个网络管理器保证网络一直连接 DontDestroyOnLoad(this.gameObject); } // 【主线程处理】 void Update() { // 为什么要在 Update 里处理消息 // 答因为 Unity 规定所有的游戏物体操作比如修改血量、移动位置必须在主线程执行 // 后台线程不能直接操作 Unity 的组件所以后台线程把消息放进 receiveQueue主线程来处理。 if(receiveQueue.Count 0) { // 从收件箱拿出一个消息 BaseMsg baseMsg receiveQueue.Dequeue(); switch (baseMsg) { // C# 的模式匹配语法如果是 PlayerMsg 类型的消息就把它赋值给 msg 变量 case PlayerMsg msg: print(msg.playerID); print(msg.playerData.name); print(msg.playerData.atk); print(msg.playerData.lev); break; } } } // 【启动客户端】告诉别人我是谁我要连谁 public void StartClient(string ip, int port) { if(!isClose) return; // 如果已经开启了就不重复开启 // 1. 记录服务器目标地址 serverIpPoint new IPEndPoint(IPAddress.Parse(ip),port); // 2. 设定客户端自己的地址和端口 (这里硬编码了本地127.0.0.1和8081端口) IPEndPoint clientIpPort new IPEndPoint(IPAddress.Parse(127.0.0.1),8081); try { // 3. 创建 UDP Socket (Dgram 代表数据报UDP专用) socket new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); // 4. 绑定自己的地址相当于给自己的邮箱挂上牌子告诉别人把信寄到这个端口 socket.Bind(clientIpPort); isClose false; // 打开线程开关 print(客户端网络启动); // 5. 将“接收”和“发送”任务扔给线程池后台运行不卡死主线程 ThreadPool.QueueUserWorkItem(ReceiveMsg); ThreadPool.QueueUserWorkItem(SendMsg); } catch(System.Exception e) { print(启动异常 e.Message); } } // 【后台线程接收消息】死循环一直盯着有没有信件来 private void ReceiveMsg(object obj) { // 临时变量用来记录是谁发给我的因为UDP任何人都能往这个端口发 EndPoint tempIpPoint new IPEndPoint(IPAddress.Any, 0); int nowIndex; int msgID; int msgLength; // 只要没关闭就一直循环 while (!isClose socket ! null) { // socket.Available 表示当前网卡里有没有别人发来的未读数据 if(socket.Available 0) { try { // 接收数据放入 cacheBytes并且把发送者的地址记录在 tempIpPoint 中 socket.ReceiveFrom(cacheBytes, ref tempIpPoint); // 校验如果发信人不是服务器就丢弃这封信防黑客捣乱 if (!tempIpPoint.Equals(serverIpPoint)) { continue; } // 【字节解析反序列化】 // 规定格式前4个字节是 消息ID - 接着4个字节是 消息长度 - 后面是 真实数据 nowIndex 0; msgID BitConverter.ToInt32(cacheBytes, nowIndex); // 读ID nowIndex 4; // 指针往后移动4个字节 msgLength BitConverter.ToInt32(cacheBytes , nowIndex); // 读长度 nowIndex 4; // 指针继续往后移动 BaseMsg msg null; // 根据不同的 ID 生成不同的消息对象 switch (msgID) { case 1: msg new PlayerMsg(); // 让消息自己去把剩下的字节解析成数据 msg.Reading(cacheBytes, nowIndex); break; } // 解析成功放入收件箱等待主线程 Update 去处理 if(msg ! null) receiveQueue.Enqueue(msg); } catch (SocketException s) { print(接收消息出问题 s.SocketErrorCode s.Message); } catch(Exception e) { print(接收消息出问题 非网络问题 e.Message); } } } } // 【后台线程发送消息】死循环一直盯着发件箱 private void SendMsg(object obj) { while (!isClose socket ! null) { // 如果发件箱里有东西 if(sendQueue.Count 0) { try { // 拿出一个消息转化为字节数组Writing方法发送给服务器 socket.SendTo(sendQueue.Dequeue().Writing(), serverIpPoint); } catch(SocketException s) { print(发送消息出错 s.SocketErrorCode s.Message); } } // 【老师注这里其实缺了一行很重要的代码下面扩展会讲】 } } // 【提供给其他脚本调用的发送接口】 public void Send(BaseMsg msg) { // 只是放进发件箱不直接发。后台的 SendMsg 线程会自动把它发出去。 sendQueue.Enqueue(msg); } // 【关闭连接与清理】 public void Close() { if(socket null ) return; // 发送最后一个消息告诉服务器“我下线了” QuitMgr msg new QuitMgr(); socket.SendTo(msg.Writing(), serverIpPoint); isClose true; // 关停两个后台线程 socket.Shutdown(SocketShutdown.Both); // 停止收发 socket.Close(); // 关闭 Socket socket null; } // 当这个游戏物体被销毁时比如游戏退出一定要关闭网络 private void OnDestroy() { Close(); } }第三部分总结、利弊与扩展避坑指南 作为你的老师我必须指出这段代码中的几个危险的雷区Bug因为如果不改它会引发严重的性能问题代码的优点利结构清晰采用了收发分离的队列思想逻辑上很整洁。多线程异步网络通信没有阻塞 Unity 的主线程不会掉帧。代码的缺点弊与修改方案重要 CPU 占用爆炸问题死循环陷阱问题在 SendMsg 方法中while (!isClose) 是一个无尽的循环。如果发件箱里没有消息它会一秒钟空转几百万次瞬间把你的电脑 CPU 单核占满 100%修复在没有消息时让线程“睡”一会儿。private void SendMsg(object obj) { while (!isClose socket ! null) { if(sendQueue.Count 0) { // 发送逻辑... } else { Thread.Sleep(5); // 让线程休息5毫秒极大地释放CPU性能 } } } 线程安全问题队列 Race Condition问题C# 原生的 QueueT不是线程安全的主线程在 Update 里 Dequeue取消息后台线程同时在 ReceiveMsg 里 Enqueue塞消息。两个线程同时抢夺一个变量大概率会引发程序崩溃修复使用 lock 关键字加锁或者使用线程安全队列 ConcurrentQueueT需要引入 System.Collections.Concurrent。// 修复发送队列接收队列同理 public void Send(BaseMsg msg) { lock(sendQueue) { sendQueue.Enqueue(msg); } } // 取出时也需要 lock lock(sendQueue) { msg sendQueue.Dequeue(); }硬编码绑定的问题问题StartClient 里写死了 127.0.0.1 和端口 8081。如果同一台电脑开两个客户端第二个就会因为 8081 端口被占用而报错崩溃。修复把客户端绑定的端口设为 0让操作系统自动分配一个空闲端口即可Q1:为什么我们接收网络数据要用 ThreadPool.QueueUserWorkItem而不是直接在 Update 函数里面调用 socket.ReceiveFrom()Q2:既然接收消息在后台线程为什么我们不能在 ReceiveMsg 函数里直接写 player.transform.position newPos 这样的代码来改变人物位置Q3:代码里的 isClose 这个 bool 变量起到了什么关键作用A1:因为 socket.ReceiveFrom() 可能会发生阻塞如果网络不好一直等不到消息或者即使使用 Available如果在 Update 里处理网络会消耗大量时间导致 Unity 主线程卡死游戏掉帧。放到后台可以避免影响游戏流畅度。A2:因为 Unity 引擎的底层规定了绝大多数的 UnityEngine API比如控制 Transform、修改 UI只能在主线程调用。后台线程强行调用会报错。所以我们要把数据放进 receiveQueue让主线程在 Update 里去拿数据并移动人物。A3:它是控制后台线程生死循环的开关。如果关掉游戏时OnDestroy 被调用不把 isClose 设为 true后台线程依然会像孤儿一样在内存里死循环导致游戏关闭后后台仍有进程残留。
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2434032.html
如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!