当前位置: 首页 > news >正文

Unity网络基础UDP客户端

第一部分:核心概念预习

在看代码之前,你需要明白这几个核心概念:

  1. UDP (User Datagram Protocol):就像寄明信片。你只管把信发出去,不需要先跟对方建立连接,速度极快,但不保证对方一定能收到,也不保证顺序。适合做对实时性要求高的游戏(如王者荣耀、吃鸡的移动同步)。

  2. Socket(套接字):网络通信的基石。如果把网络比作公路,Socket 就是跑在公路上的货车,负责装载和卸载货物(数据)。

  3. 多线程 (ThreadPool):Unity 的主线程(Update)负责画面的渲染。网络接收和发送如果放在主线程,一旦网络卡顿,游戏画面就会卡死。所以我们需要把网络收发放到后台去打工(子线程)

  4. 队列 (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 Queue<BaseMsg> sendQueue = new Queue<BaseMsg>(); // receiveQueue: 收件箱。后台线程收到消息后,先堆在这里。 private Queue<BaseMsg> receiveQueue = new Queue<BaseMsg>(); // 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),因为如果不改,它会引发严重的性能问题!

代码的优点(利):
  1. 结构清晰:采用了收发分离的队列思想,逻辑上很整洁。

  2. 多线程异步:网络通信没有阻塞 Unity 的主线程,不会掉帧。

代码的缺点(弊)与修改方案(重要!):
  1. 🚨 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# 原生的 Queue<T>不是线程安全的!主线程在 Update 里 Dequeue(取消息),后台线程同时在 ReceiveMsg 里 Enqueue(塞消息)。两个线程同时抢夺一个变量,大概率会引发程序崩溃!

  • 修复:使用 lock 关键字加锁,或者使用线程安全队列 ConcurrentQueue<T>(需要引入 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.jsqmd.com/news/512862/

相关文章:

  • Cortex-M3 异常处理机制的设计哲学
  • 25大数据 3-1 字符串函数
  • 102类农业害虫图像识别数据集分享(适用于YOLO系列深度学习分类检测任务)
  • 2026年厦门二手房装修公司推荐:局部改造工期短且环保材料靠谱品牌分析 - 品牌推荐
  • 从入门到精通:Kafka核心原理与实战避坑指南
  • SEO_从零开始学习SEO的完整步骤与实战方法
  • 制作微PE-Win系统盘
  • 永辉超市购物卡回收高效1分钟变现全教程 - 淘淘收小程序
  • 软件库带后台源码 v3.1
  • 基于单片机的药品分拣系统
  • 收藏!24家大模型面试实战经验(9个offer),小白/程序员入门必看
  • 使用yolov26实现目标检测
  • 2026年厦门二手房装修公司推荐:局部改造与全屋整装口碑好公司及真实案例解析 - 品牌推荐
  • 2025-2026年厦门二手房装修公司推荐:全屋整装高性价比品牌与用户真实反馈 - 品牌推荐
  • 2026年充电桩加盟品牌榜单:城市公共场站投资靠谱选择与运营避坑指南 - 品牌推荐
  • 新能源汽车车载双向OBC,PFC,LLC,V2G 双向充电桩MATLAB仿真模型及应用分析
  • java基于微信小程序的物流仓储管理系统 可视化
  • PunchoutLevel的product、shelf、Aisle的是什么
  • 京东e卡回收安全教程来袭,价格与流程全知晓 - 淘淘收小程序
  • 6.Adobe Animate遮罩动画
  • 厦门二手房装修公司如何选不踩雷?2026年靠谱推荐及免费量房设计服务 - 品牌推荐
  • STM32驱动HX711 24位ADC程序
  • SpringBoot+Vue 办公信息系统管理平台源码免费分享【适合毕设/课设/学习】Java+Vue+MySQL
  • 音叉这玩意儿在光热振动里真是妙啊,特别是用COMSOL建模的时候,玩参数就像调电子琴的旋钮。先给你们看段核心参数设置代码
  • 2026年充电桩加盟品牌推荐:县域下沉市场创业痛点解决靠谱品牌与扶持政策 - 品牌推荐
  • Dify 模型供应商下载失败解决办法
  • 从CUDA到MUSA(四):GPU架构揭秘——从Warp到Occupancy
  • 高频注入代码 增强滑膜esmo代码 HFI方波高频注入,提 高频注入代码 增强滑膜esmo代码...
  • 2026年充电桩加盟品牌推荐:社区目的地充电高性价比合作模式与避坑指南 - 品牌推荐
  • 计算机毕业设计springboot校园智能卡管理系统设计与实现 高校一卡通数字化管理平台的设计与实现 基于Spring Boot框架的校园智慧卡服务系统开发