Qt QUdpSocket UDP通信Demo:局域网广播组播与设备发现实践
简介:面向具备一定 C++ 基础、希望掌握 Qt 网络编程的开发者,这套 QTUDP 通信示例围绕 UDP 无连接、低开销、低延迟的特点,演示客户端与服务端之间的数据报收发,可用于音视频流、实时控制等场景的入门验证。压缩包共 12 个文件,约 10KB,含 4 个 cpp、2 个 h 源代码及 2 个 ui 界面文件,另有 2 个 pro 工程配置和 2 个 user 用户配置,便于导入 Qt Creator 对照运行。资源已有 823 人学习,读者可借此理解 QUdpSocket 的端口绑定、数据报发送与接收、readyRead 信号处理及基础错误检查,并梳理轻量 UDP Demo 的工程组织方式,为后续扩展多线程收发、编解码与稳定性处理打基础。
1. 局域网里那点事,用 QUdpSocket 其实很短
刚上手 Qt 网络模块的人,往往是在一个局域网小工具里第一次碰到 QUdpSocket:上位机要同时给十几台下位机推配置,用 TCP 得为每台维护一条连接、处理断线重连,换成 UDP 之后一次 writeDatagram 就把包扔出去了。这份 QTUDP通信Demo 就是这类需求的骨架,它没把协议做全,而是把 Qt 下 UDP 通信的完整路径摊开:套接字初始化、端口绑定、数据报收发、广播与组播,以及在 readyRead 信号里把裸字节还原成业务消息。适合三类人:刚上手 Qt 想找个能直接编译运行的起点、做过 TCP 被粘包和多连接折腾过、需要在局域网做设备发现和状态上报。它真正回答的不是“怎么写一个 UDP 程序”,而是“UDP 这一层在你的工程里该长什么样”。
2. 工程结构与技术选型:三层拆分让网络代码能离开界面活着
一个能直接编译的 Demo,最容易写坏的恰恰是结构。所有逻辑塞进 MainWindow 的槽函数里,二十行能跑通,等到要加心跳、加广播、加文件分片,界面类会膨胀到八百行,想抽出来复用的时候发现到处是 ui-> 指针。这个 Demo 的价值一半在收发本身,另一半在它把“界面”“网络”“协议”这三件事拆开了。
2.1 目录拆分:界面、协议、网络各管一摊
我一般会把这类项目摆成下面这个样子,src 下按职责分目录,而不是按文件类型分目录:
QTUdpDemo/ ├── QTUdpDemo.pro qmake 工程文件 ├── CMakeLists.txt CMake 构建(Qt6 优先) ├── src/ │ ├── main.cpp │ ├── ui/ │ │ ├── mainwindow.h │ │ ├── mainwindow.cpp 只负责输入框、按钮、日志展示 │ │ └── mainwindow.ui │ ├── net/ │ │ ├── udpreceiver.h 绑定端口、收包、抛信号 │ │ ├── udpreceiver.cpp │ │ ├── udpsender.h 组包、发送、解析目标地址 │ │ └── udpsender.cpp │ ├── proto/ │ │ ├── packet.h 协议头结构体与常量 │ │ └── packet.cpp 打包 / 解包 / 校验 │ └── model/ │ └── peerlist.h 在线对端列表(IP、端口、最后活跃时间) └── res/ └── icons.qrcnet 层只认 QByteArray 和 QHostAddress,不 include 任何界面头文件;proto 层只做字节层面的打包解包,纯函数,可以单独写单元测试;ui 层拿到的是已经解析好的消息对象。这条边界守住之后,后面换成命令行程序、换成无界面服务,net 和 proto 一行都不用改。这是我在自己项目里踩出来的规矩:网络层只要开始 emit 带 QString 的“已经格式化好的日志”,基本就废了,它会被界面需求牵着走。
具体到类的职责,UdpReceiver 持有 QUdpSocket、负责 bind 和 readyRead 槽;UdpSender 持有另一个 QUdpSocket,负责 writeDatagram 和发送失败的 errorString 上报;Packet 提供 encode/decode 两个静态方法,返回一个可选的业务消息结构。PeerList 用 QHash<QString, PeerInfo> 存活跃对端,key 用 “ip:port”,定时器扫过期。这几个类加起来不到五百行,但每一块都能单独拎走。
2.2 QUdpSocket 的能力边界,以及 Qt5 与 Qt6 的差异
选 QUdpSocket 而不是裸 socket,理由很实际:它把 Qt 的事件循环接上了,readyRead 信号天然和界面线程同频,不需要自己写 select 循环,也不需要处理 EINTR。跨平台方面 Windows 的 Winsock 初始化和 Linux 的 socket 选项差异都被抹平了。代价是它只暴露 UDP 的传输层能力,不帮你做任何可靠性保证,这一点必须提前想清楚。
| 维度 | QUdpSocket | QTcpSocket |
|---|---|---|
| 连接模型 | 无连接,每次带目标地址 | 先 connect,之后只写数据 |
| 消息边界 | 一条数据报就是一条消息 | 流式,需要自己切包 |
| 送达保证 | 不保证到达、不保证顺序 | 保证有序到达 |
| 单包上限 | 约 65507 字节(受 MTU 影响更小) | 无上限 |
| 典型场景 | 设备发现、状态上报、音视频帧 | 文件传输、命令通道 |
Qt5 和 Qt6 在这块 API 上的差异不大,但有三个点要注意。第一,QNetworkDatagram 是 Qt 5.8 才有的,如果工程要兼容更老的版本,接收侧只能退回 readDatagram(char*, qint64, QHostAddress*, quint16*)。第二,错误信号在 Qt 5.15 之前叫 error(QAbstractSocket::SocketError),5.15 起改名为 errorOccurred,Qt6 里只有后者,写 connect 的时候要么按版本做条件编译,要么直接用旧名字去连。第三,Qt6 的 CMake 侧需要用 qt_add_executable 和 qt_standard_project_setup,qmake 的 .pro 则几乎原样能编。
提示:如果从 Qt5 工程迁到 Qt6,先看 QTextCodec 相关的代码,Qt6 把它挪到了 core5compat 模块,而 UDP 本身的收发逻辑一般不需要改。
2.3 两份构建配置:.pro 与 CMakeLists
qmake 那份是给习惯 Qt Creator 直接打开的人准备的,四行就够:
QT += core gui widgets network CONFIG += c++17 TARGET = QTUdpDemo TEMPLATE = app SOURCES += src/main.cpp src/ui/mainwindow.cpp src/net/udpreceiver.cpp \ src/net/udpsender.cpp src/proto/packet.cpp HEADERS += src/ui/mainwindow.h src/net/udpreceiver.h \ src/net/udpsender.h src/proto/packet.h FORMS += src/ui/mainwindow.ui关键就在 QT += network 这一行,没有它链接阶段会报 QUdpSocket 未定义。CMake 那份对应 Qt6:
cmake_minimum_required(VERSION 3.16) project(QTUdpDemo LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) # 有 Q_OBJECT 的类必须开 find_package(Qt6 REQUIRED COMPONENTS Core Gui Widgets Network) qt_standard_project_setup() qt_add_executable(QTUdpDemo src/main.cpp src/ui/mainwindow.cpp src/net/udpreceiver.cpp src/net/udpsender.cpp src/proto/packet.cpp ) target_link_libraries(QTUdpDemo PRIVATE Qt6::Core Qt6::Gui Qt6::Widgets Qt6::Network)参数上要留意 find_package 里的 Network 组件,少写一个就是编译期找不到 QUdpSocket 头文件。CMAKE_AUTOMOC 必须为 ON,否则自定义类里继承 QObject 的 Q_OBJECT 宏不会被处理,链接时报 vtable 未定义,这个错误提示对新手极不友好,是典型的入门翻车点。Qt5 用户把 Qt6:: 换成 Qt5::、find_package 里写 Qt5 即可,qt_add_executable 换成普通的 add_executable。
3. 核心链路拆解:从 bind 到 readyRead 的一次完整收发
结构摆好之后,真正要读透的是那一两百行网络代码。UDP 的收发在 Qt 里被压缩成了四五个函数,但每个函数的参数都藏着坑,尤其 bind 和 receiveDatagram 的参数含义,写法上差一点行为就差很远。
3.1 构造与绑定:地址、端口和 BindMode 三件事
接收端必须绑固定端口,否则对端不知道往哪发。构造函数里就该把 bind 和信号连接做完,避免对象构造成功但实际不可用的半残状态:
// src/net/udpreceiver.cpp UdpReceiver::UdpReceiver(quint16 port, QObject *parent) : QObject(parent) , m_socket(new QUdpSocket(this)) { // AnyIPv4 表示监听本机所有 IPv4 网卡;用 Any 会同时尝试 IPv6 const bool ok = m_socket->bind(QHostAddress::AnyIPv4, port, QUdpSocket::ShareAddress | QUdpSocket::ReuseAddressHint); if (!ok) { qWarning() << "bind" << port << "failed:" << m_socket->errorString(); return; // 后续 readyRead 不会触发,调用方需检查 isListening() } connect(m_socket, &QUdpSocket::readyRead, this, &UdpReceiver::onReadyRead); } bool UdpReceiver::isListening() const { return m_socket->state() == QAbstractSocket::BoundState; }参数逐个说。第一个参数是监听地址,写 QHostAddress::AnyIPv4 会绑到 0.0.0.0,广播和组播都能收;写成某个具体网卡地址就只有那块网卡收得到,本机 127.0.0.1 发来的包会被丢掉——这是“本机能发不能收”最常见的原因。第二个参数是端口,0 表示让系统分配,但接收端绝不能用 0。第三个参数是 BindMode,默认值 DefaultForPlatform 在 Linux 上对应独占,两个进程先后绑同一端口时第二个直接失败;加上 ShareAddress | ReuseAddressHint 之后,同一台机器上跑两个实例调试才不会互相打架。工程上允许重复绑定是为了调试方便,正式部署时一个端口只应该有一个接收者。
3.2 发送路径:writeDatagram 的返回值能告诉你什么
发送端不一定要 bind,因为 sendto 时内核会自动分配源端口。但如果对端会回包,就必须 bind 一个固定端口,否则回包打到随机端口上没人收。发送逻辑本身很短,难的是目标地址的解析:
// src/net/udpsender.cpp qint64 UdpSender::sendText(const QString &text, const QHostAddress &to, quint16 port) { QByteArray packet = Packet::encode(Packet::TypeText, text.toUtf8()); const qint64 written = m_socket->writeDatagram(packet, to, port); if (written != packet.size()) { // UDP 下要么整包写入,要么 -1,出现部分写入说明包超长 qWarning() << "send failed, want" << packet.size() << "got" << written << m_socket->errorString(); } return written; } bool UdpSender::sendTextToHost(const QString &text, const QString &host, quint16 port) { QHostAddress addr; if (!addr.setAddress(host)) { // 先按 IP 字面量解析 // 域名解析是阻塞的,示例里放主线程,真实项目应换成 lookupHost 异步版 const QHostInfo info = QHostInfo::fromName(host); if (info.error() != QHostInfo::NoError || info.addresses().isEmpty()) { return false; } addr = info.addresses().first(); } return sendText(text, addr, port) > 0; }writeDatagram 的返回值是实际写入的字节数,UDP 语义下它只有两种结果:等于数据报长度,或者 -1。如果出现“小于包长”的返回值,通常意味着数据报超过了协议栈允许的上限(IPv4 下大约 65507 字节),或者是缓冲区瞬间被打满。返回值不是 -1 就以为发成功了是个常见的错觉——UDP 发送成功只代表数据交给了内核,不代表对方收得到,这一点在后面心跳和重传设计里会再提。
3.3 接收路径:readyRead 只说明“至少有一个包”
readyRead 的语义是“有数据可读”,不是“你的消息到了”,所以槽函数里一定要用 while 循环把缓冲区读空,否则剩下的包要等下一次数据到达才触发信号:
// src/net/udpreceiver.cpp void UdpReceiver::onReadyRead() { while (m_socket->hasPendingDatagrams()) { const qint64 size = m_socket->pendingDatagramSize(); if (size <= 0) { break; } QNetworkDatagram dg = m_socket->receiveDatagram(static_cast<int>(size)); if (!dg.isValid()) { continue; } const Packet::Message msg = Packet::decode(dg.data()); if (!msg.valid) { qWarning() << "drop malformed packet from" << dg.senderAddress().toString(); continue; // 脏包直接丢弃,不要让异常数据进业务层 } emit messageReceived(dg.senderAddress().toString(), dg.senderPort(), msg); } }三个函数的关系要理清:hasPendingDatagrams 判断队列里还有没有,pendingDatagramSize 返回队首那个包的长度,receiveDatagram 按给定长度取出并返回一个 QNetworkDatagram,里面同时带 senderAddress、senderPort 和 data。传参时把 pendingDatagramSize 的结果原样传进去,不要图省事传一个大值,否则可能把后面的包截断。emit 出去的是已经解包并校验过的结构体,界面层只负责展示,不做字节操作。
3.4 应用层协议:给每个数据报加个头
UDP 保留了消息边界,所以不需要像 TCP 那样处理粘包。但实际项目里仍有两种情况需要自己定协议:一是同一个端口上要区分文本、心跳、文件块等多种消息,二是单条业务消息可能超过 MTU。这个 Demo 里我用一个固定八字节的头来解耦:
| 字段 | 类型 | 字节数 | 说明 |
|---|---|---|---|
| magic | quint16 | 2 | 固定 0x5154,快速识别是不是本程序的包 |
| version | quint8 | 1 | 协议版本,方便后续兼容 |
| type | quint8 | 1 | 1 文本 2 心跳 3 文件块 |
| seq | quint32 | 4 | 递增序号,仅用于日志和丢失统计 |
| payloadSize | quint32 | 4 | 负载字节数,用于校验 |
// src/proto/packet.cpp QByteArray Packet::encode(quint8 type, const QByteArray &payload) { QByteArray out; QDataStream s(&out, QIODevice::WriteOnly); s.setByteOrder(QDataStream::BigEndian); // 网络字节序,跨平台必须显式指定 s.setVersion(QDataStream::Qt_5_15); // 两端版本要一致,否则 QString 序列化格式不同 s << quint16(0x5154) << quint8(1) << type << quint32(m_seq++) << quint32(payload.size()); out.append(payload); return out; }参数上有两个必须显式设定的东西:字节序和 QDataStream 版本号。默认字节序在小端机器上是 LittleEndian,接收方如果是大端设备就全乱;QDataStream 的版本决定了 QString 等类型的序列化格式,两端不一致时读出来的长度字段都是错的。负载部分直接用 append 塞原始字节,不经过 QDataStream,避免二次编码带来的额外开销。单包建议控制在 1400 字节以内,这个数和以太网 MTU 1500 减去 IP 头 20 字节、UDP 头 8 字节后的余量有关,超过就要自己分片加序号重组,那是另一个话题了。
4. 广播、组播与对端发现:一份代码覆盖三种投递方式
单播只能发给一个已知地址,但设备发现这类需求是一对多的,Demo 里把广播和组播都放进来,正是因为它俩是局域网场景下最省事的发现手段。
4.1 广播:setBroadcast 与子网边界
广播的写法几乎是单播加一行开关:
// 发送端:允许广播 m_socket->setBroadcast(true); QByteArray probe = Packet::encode(Packet::TypeHeartbeat, QByteArray()); // QHostAddress::Broadcast 等价于 255.255.255.255 m_socket->writeDatagram(probe, QHostAddress::Broadcast, 45454);这里有两个容易翻车的点。其一,不调用 setBroadcast(true) 直接往广播地址发,writeDatagram 会返回 -1,错误信息是“Permission denied”之类的措辞,看着像权限问题,实际是套接字没开广播选项。其二,255.255.255.255 只在本地子网内有效,路由器不会转发,跨网段必须用子网定向广播地址(比如 192.168.1.255),而且要提前算好掩码。接收侧就更简单了,只要 bind 的是 AnyIPv4 的对应端口,广播包会正常触发 readyRead,senderAddress 拿到的是发送方的实际 IP。
真要用广播做设备发现,节奏上建议“上电连发三次、间隔 300 毫秒”,而不是一直刷。广播包会被同一网段所有机器收到,频率高了纯属干扰别人。发现到对端之后立刻转成单播继续通信,广播只用在“我还不知道你是谁”的阶段。
4.2 组播:joinMulticastGroup 与网卡选择
组播比广播克制,只有加入了同一个组地址的进程才收得到。组地址范围是 224.0.0.0 到 239.255.255.255,其中 224.0.0.x 段被协议保留,实际项目里习惯用 239.x.x.x:
// 接收端:绑定任意地址后加入组播组 QUdpSocket *mc = new QUdpSocket(this); mc->bind(QHostAddress::AnyIPv4, 45455, QUdpSocket::ShareAddress | QUdpSocket::ReuseAddressHint); bool joined = mc->joinMulticastGroup(QHostAddress("239.255.43.21")); if (!joined) { qWarning() << "join multicast failed:" << mc->errorString(); } // 发送端:组播包的 TTL 一般设小一点,避免被路由器转出本网段 QUdpSocket *ms = new QUdpSocket(this); ms->setSocketOption(QAbstractSocket::MulticastTtlOption, 1); ms->writeDatagram(payload, QHostAddress("239.255.43.21"), 45455);绑定地址写 AnyIPv4 是必须的,写具体网卡地址在某些平台上会让 joinMulticastGroup 失败。多网卡机器上(笔记本同时开着有线和无线很常见),joinMulticastGroup 的第二个参数可以指定 QNetworkInterface,否则包可能从错误的网卡出去。TTL 设 1 表示只在本网段传播,这是局域网发现业务里最合理的值。判断网卡是否选对,可以遍历 QNetworkInterface::allInterfaces() 打印每个接口的 flags,看哪个带 IsUp 和 IsRunning。
4.3 心跳与在线列表:用 QTimer 维护对端状态
UDP 没有连接状态,对端什么时候下线你完全不知道,所以在线列表只能靠心跳推出来:
// src/model/peerlist.cpp void PeerList::onHeartbeat(const QString &ip, quint16 port) { const QString key = QString("%1:%2").arg(ip).arg(port); PeerInfo &info = m_peers[key]; info.ip = ip; info.port = port; info.lastSeen = QDateTime::currentMSecsSinceEpoch(); info.online = true; } void PeerList::sweep() { const qint64 now = QDateTime::currentMSecsSinceEpoch(); for (auto it = m_peers.begin(); it != m_peers.end(); ) { if (now - it->lastSeen > kTimeoutMs) { // 三个心跳周期没更新就摘掉 emit peerOffline(it->ip, it->port); it = m_peers.erase(it); } else { ++it; } } }心跳间隔我一般设 2 秒,超时阈值设 3 个周期即 6 秒,容忍偶发丢包。这里有个细节:超时不要只靠“最后一次心跳时间”,还要考虑本地时钟被系统校时改掉的情况,用单调时钟 QElapsedTimer 更稳妥,示例里用毫秒时间戳是为了好读。另外心跳包要单独一个 type,不要和业务消息混用,否则日志里满屏都是心跳,排查问题时根本看不清。
5. 避坑与排查:UDP 在真实网络里的五条经验
Demo 在本机上跑通不代表能上线,局域网环境里的问题基本集中在端口、地址、编码和性能这四块。下面五条是我在类似项目里真真切切碰过的。
5.1 现象:第二个实例一启动就收不到包
现象:同一个程序开两个窗口做自测,第二个窗口的日志一直为空,bind 返回 false,errorString 是“Address already in use”。原因:QUdpSocket 的默认绑定的绑定模式在多数平台上对端口是独占的,第一个实例没释放之前第二个拿不到。解决:接收端的 bind 调用统一加上 QUdpSocket::ShareAddress | QUdpSocket::ReuseAddressHint,两个实例就能同时监听同一端口,数据报会被复制给所有绑定者。需要注意这只适合调试,正式场景里两个进程抢同一端口,谁收到包是不确定的。
5.2 现象:本机自己发给自己,一条都收不到
现象:发送端显示 writeDatagram 返回了正数,接收端 readyRead 就是不触发,换到局域网另一台机器就正常。原因通常有两个:接收端 bind 的是某块具体网卡的地址,而发送端发往 127.0.0.1,地址对不上;或者目标是 255.255.255.255 广播,但接收端绑定到了具体地址,广播包被网卡过滤掉了。解决:接收端一律绑 QHostAddress::AnyIPv4,让它监听所有网卡包括回环;自测时优先往 127.0.0.1 单播,确认链路通了再试广播。另外 Windows 上首次运行会弹防火墙授权框,点“取消”之后再想收包就得去防火墙规则里手动放行,这个坑很隐蔽。
5.3 现象:中文消息到对端变成乱码或者问号
现象:发“温度异常”四个字,对方收到的是一串乱码,或者长度对不上解析失败。原因:QString 转 char* 时用了隐式转换,走的是 Latin-1 编码,中文直接被替换成问号;另一种情况是两端 QDataStream 版本不一致,长度字段按不同规则序列化。解决:所有字符串出入都显式走 toUtf8 / fromUtf8,协议头里的长度字段按字节数算而不是按字符数算;QDataStream 在两端写死同一个版本号,比如都写 QDataStream::Qt_5_15。上线前用一段纯中文加 emoji 的测试串跑一遍,比看十遍代码管用。
5.4 现象:高频发送时界面卡成幻灯片
现象:压测时每秒发几百个包,界面按钮点不动,日志窗口滚动有延迟。原因有两个叠加:一是每条数据报都 emit 一次信号、追加一次日志到 QTextEdit,控件重绘成了瓶颈;二是 readyRead 里没把队列读空,事件循环反复被唤醒。解决:接收槽里用 while 循环一次读光,攒到一个 QVector 后再发一个批量信号;界面侧用 QTimer 每 100 毫秒合并刷新一次,而不是来一条刷一条;再不行就把网络对象 moveToThread 到独立线程,用队列连接把结果传回界面。这个改法通常能把 CPU 占用从单核跑满降到个位数。
5.5 现象:大包发出去了,收到的却是半截
现象:发一条几十 KB 的 JSON,接收端解析时报“数据不完整”,pendingDatagramSize 拿到的长度比预期小。原因:超过了链路 MTU 触发 IP 分片,任何一个分片丢失整个数据报就作废;更极端的情况下超过 65507 字节,writeDatagram 直接返回 -1。解决:协议层强制单包不超过 1400 字节,超出就按序号切片,接收端做重组并设超时丢弃不完整的组;或者干脆把大块数据挪到 TCP 通道上,UDP 只承载控制消息。这条属于设计阶段就要定下来的约束,等上线再改代价很大。
6. 二次开发:把它抽成一个能复用的 UdpChannel
Demo 里的 Sender 和 Receiver 分成两个类是便于讲解,真到项目里我更愿意合成一个 UdpChannel,收发同一个套接字,接口窄一点,用起来省心。
6.1 封装边界与接口设计
我的做法是把 bind、发送、批量接收、对端列表全收进去,对外只暴露四个东西:
// src/net/udpchannel.h class UdpChannel : public QObject { Q_OBJECT public: explicit UdpChannel(quint16 localPort, QObject *parent = nullptr); bool start(); // bind + 连接信号 qint64 sendTo(const QByteArray &payload, const QHostAddress &to, quint16 port); void enableBroadcast(bool on); signals: void messagesReady(const QVector<Packet::Message> &batch); // 批量上抛 void errorOccurred(const QString &reason); };关键改动有三个。其一,接收侧不再逐条 emit,攒够一批或者定时器到点再发,界面侧订阅 messagesReady 就够了,刷新频率和解包频率解耦。其二,把在线列表也吃进来,start() 里顺带起心跳定时器和过期清扫定时器,调用方只需要关心“谁在线”这个信号。其三,错误统一走 errorOccurred 字符串,而不是把 QAbstractSocket 的枚举抛给上层,界面层不该知道 SocketError 是什么。这套接口换到命令行程序或者后台服务里照样能用,不依赖任何控件。
6.2 验证方法:别急着开界面
封装完先别写界面,用两个终端验证链路更高效。Linux 或 macOS 上可以用 nc 直接发一条 UDP 包到本地端口,看程序日志有没有打印对端地址:
# 终端 1:先跑起来你的接收端,监听 45454 ./QTUdpDemo --listen 45454 # 终端 2:发一条纯文本 UDP 包到本机 printf 'hello-udp' | nc -u -w1 127.0.0.1 45454 # 终端 2 变体:发广播到当前网段(需要系统允许) printf 'probe' | nc -u -w1 -b 255.255.255.255 45454看到日志里出现 127.0.0.1 和正确的负载长度,说明 bind 和 readyRead 这条链通了;再换到另一台机器上重试,通不过就按第 5 章的顺序查网卡地址、防火墙、子网掩码。之后再测组播,用 ip maddr 看网卡有没有真的加入组,这一步能省掉很多“代码明明没错”的争论。压测用 for 循环发一万个包,统计接收端实际收到的数量,丢包率在局域网内应该很低,如果掉得厉害,先看接收端是不是在界面线程里同步处理。
这套流程我后来固化成了习惯:每加一个网络模块,先写一个不依赖界面的最小可执行,用命令行和 nc 打通,再把界面接上去。从那以后我每次新建网络通信相关的类,都强制先跑一遍自环和跨机两条验证,再动 UI 代码。希望帮到你。
本文还有配套的精品资源,点击获取