news 2026/11/1 1:06:50

Qt局域网聊天室实战:TCP粘包分帧、信号槽与打包避坑

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Qt局域网聊天室实战:TCP粘包分帧、信号槽与打包避坑

简介:面向计算机专业毕业设计与Qt入门进阶者的局域网聊天室完整工程,可直接用于毕设答辩或课程设计参考。项目实现私聊通信与文件传输,管理员可进行权限管理;在线状态通过定时器轮询数据库标志位实时刷新,普通消息走UDP、文件传输走TCP,发送方与接收方动态切换服务端/客户端身份,是理解Qt网络编程与数据库交互的典型范例。压缩包共87个文件、约6.54MB,以cpp与h源码为主体,配合7个ui界面文件、qrc资源与jpg/png图片素材,以及pro工程文件与Debug/Release编译目录,结构完整,可直接用Qt 5.7.1 MinGW 32位打开调试。运行前需在本地或服务器安装32位MySQL 5.7.31,并参照包内附带的帮助文档完成配置,仅限局域网同网段使用。已有4111人学习下载。

1. 局域网聊天室选 Qt 的理由:一套代码跑通 TCP、界面和打包

答辩现场最怕什么?教室断外网、机房只通内网、评委让你现场演示登录和发消息。这时候一个不依赖数据库、不依赖云服务、双击 exe 就能跑的局域网聊天室,比任何花哨的架构都管用。这套基于 Qt 的实现,骨架是QTcpServer+QTcpSocket+ Qt Widgets:服务端维护连接表、转发消息,客户端负责登录、群聊、私聊和在线列表。C++ 写网络层,Qt 的信号槽把 IO 和界面隔开,一套代码在 Windows 和 Linux 上都能编译,打包后单个目录拷走就能演示。适合课程设计、毕设,以及刚学完信号槽想找个真实项目练手的人。真正容易翻车的地方集中在三处:TCP 粘包、对象线程归属、中文编码。下面按通信骨架、服务端、客户端、排错、进阶的顺序拆。

2. 通信骨架拆解:4 字节长度前缀如何治住 TCP 粘包

TCP 是字节流,不是消息流。readyRead触发一次,你读到的可能是半条消息,也可能是两条粘在一起的消息。很多初次实现直接readAll()然后QString::fromUtf8()当成一条聊天内容,本地单机测试没问题,一旦连续快速发送或者消息里带换行,接收端就开始串行、丢字。这个问题不解决,后面的用户列表、私聊全都建立在流沙上。

2.1 先定帧再写业务:三种定界方案的取舍

常见做法有三种:分隔符定界(比如\n)、固定长度、长度前缀。分隔符方案写起来最快,但消息体里一旦出现分隔符就要转义,文件传输和富文本会很难受;固定长度浪费带宽,还得处理超长截断。我一般选长度前缀:先写 4 字节大端整数表示 JSON 体的字节数,再跟 JSON 体。接收端先读长度,再判断缓冲区够不够,不够就等下一批数据。

方案实现难度二进制安全扩展性适用场景
分隔符\n低差差纯文本命令通道
固定长度中好差定长协议、嵌入式
4 字节长度前缀中好好聊天、文件分片、RPC

消息体用QJsonObject,好处是字段可以随意加,客户端老版本读到不认识的 key 直接忽略,不会崩。局域网带宽不是瓶颈,JSON 的可读性在调试期价值很高,抓包或者打日志一眼就能看出是登录帧还是聊天帧。

2.2 编码与解码:两个函数定下全局规矩

编解码必须全项目只有一份实现,服务端和客户端各拷一份同样的代码,别一边用QDataStream一边手拼字节,字节序一反就全乱。下面这两个函数建议单独放一个protocol.h:

// protocol.h #pragma once #include <QByteArray> #include <QJsonDocument> #include <QJsonObject> #include <QtEndian> static constexpr quint32 kMaxFrame = 1024 * 1024; // 单帧上限 1MB,防恶意长度 // 编码:4 字节大端长度 + JSON 体 inline QByteArray encodeFrame(const QJsonObject &obj) { const QByteArray body = QJsonDocument(obj).toJson(QJsonDocument::Compact); QByteArray frame; frame.resize(4); // qToBigEndian 需要 uchar*,frame.data() 是 char* qToBigEndian<quint32>(quint32(body.size()), reinterpret_cast<uchar *>(frame.data())); frame.append(body); return frame; } // 解码:从缓冲区里尽量多地取出完整帧,剩下的半包留在 buffer inline QList<QJsonObject> decodeFrames(QByteArray &buffer, bool *fatal = nullptr) { QList<QJsonObject> out; if (fatal) *fatal = false; while (buffer.size() >= 4) { const quint32 len = qFromBigEndian<quint32>( reinterpret_cast<const uchar *>(buffer.constData())); if (len == 0 || len > kMaxFrame) { // 长度非法,直接判定连接不可信 if (fatal) *fatal = true; buffer.clear(); return out; } if (buffer.size() < int(4 + len)) break; // 半包,等下一次 readyRead const QByteArray body = buffer.mid(4, int(len)); buffer.remove(0, int(4 + len)); QJsonParseError err{}; const QJsonDocument doc = QJsonDocument::fromJson(body, &err); if (err.error == QJsonParseError::NoError && doc.isObject()) out.append(doc.object()); } return out; }

kMaxFrame这个上限必须有。没有它,对端发一个0xFFFFFFFF的长度头,你的buffer.size() < 4 + len永远成立,内存只增不减,程序被一个畸形包拖死。len == 0也要拦,空帧没有业务含义,多半是协议错位。buffer.remove(0, n)在帧少的时候够用,如果单连接吞吐很高,可以改成读指针加定期压缩,但毕设场景没必要提前优化。

2.3 服务端接入与客户端连接的最小闭环

服务端继承QTcpServer,在newConnection里把 socket 拿出来挂信号。注意while (hasPendingConnections())循环取,一次信号可能对应多个排队连接:

// chatserver.cpp ChatServer::ChatServer(QObject *parent) : QTcpServer(parent) { connect(this, &QTcpServer::newConnection, this, &ChatServer::onNewConnection); } bool ChatServer::start(quint16 port) { // 明确只用 IPv4,避免双栈环境下 Any 绑定的平台差异 if (!listen(QHostAddress::AnyIPv4, port)) { qWarning() << "listen failed:" << errorString(); return false; } return true; } void ChatServer::onNewConnection() { while (hasPendingConnections()) { QTcpSocket *sock = nextPendingConnection(); m_clients.insert(sock, ClientInfo{}); connect(sock, &QTcpSocket::readyRead, this, [this, sock] { onReadyRead(sock); }); connect(sock, &QTcpSocket::disconnected, this, [this, sock] { onDisconnected(sock); }); } } void ChatServer::onReadyRead(QTcpSocket *sock) { QByteArray &buf = m_buffers[sock]; buf.append(sock->readAll()); bool fatal = false; const auto frames = decodeFrames(buf, &fatal); if (fatal) { sock->abort(); return; } // 协议错乱,直接断开 for (const QJsonObject &obj : frames) { m_clients[sock].lastActiveMs = QDateTime::currentMSecsSinceEpoch(); dispatch(sock, obj); // 按 type 字段分发 } }

客户端侧对称,connectToHost之后把readyRead接到同样的解码逻辑。参数上,端口选 1024 以上、避开 3306/8080 这类常被占用的;QHostAddress::AnyIPv4表示监听所有网卡,如果只想本机调试可以换QHostAddress::LocalHost。判断连接是否成功不要看connectToHost的返回值,它只表示发起,真正的结果在connected和errorOccurred信号里。失败时先看sock->errorString(),再看服务端listen是否真的返回了 true,这两步能排掉大半“连不上”的问题。

3. 服务端核心:连接表、登录态与消息路由怎么排

服务端是整个项目的交通枢纽,职责就三件:记住谁在线、把消息送到该去的地方、人走了把位置腾出来。听起来简单,但连接表的组织方式直接决定代码会不会写成几百行 if-else。

3.1 用 QHash 把 socket 和用户信息绑在一起

常见写法是QList<QTcpSocket*>存所有连接,发消息时遍历列表、再根据某个成员变量判断昵称。用户一多,查找昵称要做 O(n) 遍历,而且 socket 和用户信息分离,删连接时容易漏删索引。我一般用两个哈希:

struct ClientInfo { QString name; // 登录后的昵称,未登录为空 bool loggedIn = false; qint64 lastActiveMs = 0; // 最近一次收到完整帧的时间 }; QHash<QTcpSocket *, ClientInfo> m_clients; // socket -> 状态 QHash<QString, QTcpSocket *> m_nameIndex; // 昵称 -> socket,私聊用 QHash<QTcpSocket *, QByteArray> m_buffers; // 每连接独立的粘包缓冲

三个容器各司其职:m_clients是主表,遍历广播用;m_nameIndex让私聊从 O(n) 降到 O(1);m_buffers必须按连接隔离,共用一个缓冲区是新手常犯的错,两个客户端同时发消息就会互相污染。ClientInfo用值语义存进QHash,取出来修改要小心,m_clients[sock].loggedIn = true这样写是安全的,因为operator[]返回引用;但如果你先ClientInfo ci = m_clients[sock];再改ci,改动不会回写。

3.2 登录、群聊、私聊三种帧的处理分支

所有帧都带type字段,dispatch就是一个 switch。登录要校验昵称非空且不重复,重复时回一个失败帧而不是直接断开,让客户端有机会提示用户换名字:

void ChatServer::handleLogin(QTcpSocket *sock, const QJsonObject &obj) { const QString name = obj.value("name").toString().trimmed(); if (name.isEmpty() || name.size() > 16 || m_nameIndex.contains(name)) { sendTo(sock, {{"type", "login_result"}, {"ok", false}, {"reason", "昵称重复、为空或超过16字"}}); return; } m_clients[sock].name = name; m_clients[sock].loggedIn = true; m_nameIndex.insert(name, sock); sendTo(sock, {{"type", "login_result"}, {"ok", true}}); broadcastUserList(); // 只在登录/登出时广播,别每条消息都发 }

群聊处理要校验登录态和文本长度,然后广播。广播时给每条消息补上服务端时间戳,客户端就不用信任本地时钟:

void ChatServer::handleChat(QTcpSocket *sock, const QJsonObject &obj) { const ClientInfo &ci = m_clients[sock]; if (!ci.loggedIn) return; // 未登录不允许发言 const QString text = obj.value("text").toString(); if (text.isEmpty() || text.size() > 2000) return; // 限长,防刷屏 broadcast({{"type", "chat"}, {"from", ci.name}, {"text", text}, {"ts", QDateTime::currentMSecsSinceEpoch()}}); }

私聊多一步目标查找。目标不存在或者已下线,要给发送方回一个错误帧,不能静默丢弃,否则用户以为发出去了:

QTcpSocket *target = m_nameIndex.value(to, nullptr); if (!target || !m_clients.value(target).loggedIn) { sendTo(sock, {{"type", "error"}, {"reason", "用户不在线"}}); return; } const QJsonObject out{{"type", "private"}, {"from", ci.name}, {"to", to}, {"text", text}}; sendTo(target, out); sendTo(sock, out); // 回显给自己,界面上才有发送记录

sendTo里最好加一道背压保护:if (sock->bytesToWrite() > 4 * 1024 * 1024) return;。正常聊天不会触发,但对面网络卡死、写缓冲堆积时,不加这行服务端内存会被撑大。

3.3 断线清理、心跳与超时踢人

disconnected信号是正常关闭窗口时触发的,但网线拔掉、进程被杀,服务端可能很久都收不到 FIN。所以要有心跳兜底。客户端每 15 秒发一个{"type":"ping"},服务端在onReadyRead里统一刷新lastActiveMs,再起一个定时器每 15 秒扫一遍,超过 45 秒没动静的踢掉:

void ChatServer::checkHeartbeat() { const qint64 now = QDateTime::currentMSecsSinceEpoch(); QList<QTcpSocket *> dead; for (auto it = m_clients.cbegin(); it != m_clients.cend(); ++it) { if (it.value().loggedIn && now - it.value().lastActiveMs > 45000) dead.append(it.key()); } for (QTcpSocket *s : dead) s->disconnectFromHost(); // 清理逻辑统一放到 onDisconnected }

不能在遍历m_clients的过程中直接删元素,迭代器会失效。先收集、再处理是稳妥写法。onDisconnected里做三件事:从m_nameIndex移除昵称、从m_clients和m_buffers移除 socket、广播新用户列表,最后deleteLater()。顺序反了会出现“列表里还有这个人,但私聊发不过去”的中间态。

3.4 用户列表广播的时机控制

用户列表只在三个时刻变:有人登录成功、有人断线、有人改名。把这三种情况收敛到一个broadcastUserList(),内部遍历m_clients收集已登录昵称,排个序再发。不要在每条聊天消息后面附带用户列表,那样流量翻倍,客户端还得频繁重绘列表,滚动位置都会丢。客户端收到userlist帧后整体替换QListWidget的内容,而不是增量 add/remove——用户量小的时候整体替换代码最简单,也不会出现重复项。真要优化,再给列表加版本号做差量。

4. 客户端成形:界面布局、信号槽和消息渲染的三个细节

客户端代码量通常比服务端大,因为界面占了很大一部分。但网络逻辑应该被压缩到一个ChatClient类里,主窗口只负责把用户操作翻译成帧、把收到的帧画到屏幕上。这个分层不做,后期加私聊、加文件传输会把MainWindow撑到上千行。

4.1 主窗口布局:QSplitter 两栏足够

左侧在线用户,右侧聊天记录加输入框,用QSplitter让用户能拖动分界。聊天区用QTextBrowser而不是QListWidget,因为前者支持富文本、可选中复制、自带滚动条,追加消息只要append()。输入区用QLineEdit加发送按钮,回车也触发发送:

MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) { auto *central = new QWidget(this); auto *splitter = new QSplitter(Qt::Horizontal, central); m_userList = new QListWidget(splitter); m_userList->setMaximumWidth(180); auto *right = new QWidget(splitter); auto *vbox = new QVBoxLayout(right); m_chatView = new QTextBrowser(right); m_chatView->setOpenExternalLinks(false); // 防止消息里的链接被误点 auto *hbox = new QHBoxLayout; m_input = new QLineEdit(right); m_input->setPlaceholderText("回车发送,/w 昵称 内容 发私聊"); m_sendBtn = new QPushButton("发送", right); hbox->addWidget(m_input); hbox->addWidget(m_sendBtn); vbox->addWidget(m_chatView); vbox->addLayout(hbox); splitter->addWidget(right); setCentralWidget(central); connect(m_sendBtn, &QPushButton::clicked, this, &MainWindow::onSendClicked); connect(m_input, &QLineEdit::returnPressed, this, &MainWindow::onSendClicked); }

setMaximumWidth(180)是给用户列表一个上限,不然拖拽后聊天区被挤得没法看。setOpenExternalLinks(false)这行看着不起眼,但消息内容来自其他客户端,允许外部链接被点击等于把浏览器控制权交出去,答辩时被问到就是一个说不清的安全点。

4.2 信号槽连接:把网络层和界面层隔开

ChatClient只暴露connectToServer、send和几个信号:connected、disconnected、frameReceived、errorOccurred。主窗口在构造函数里一次性连好,收到帧后按type分发到不同渲染函数。这样做的直接好处是,将来把网络层挪到子线程,界面代码一行都不用改——跨线程的信号槽默认走队列连接,自动安全。

void MainWindow::onFrameReceived(const QJsonObject &obj) { const QString type = obj.value("type").toString(); if (type == "chat") { appendMessage(obj.value("from").toString(), obj.value("text").toString(), false); } else if (type == "private") { appendMessage(obj.value("from").toString(), obj.value("text").toString(), true); } else if (type == "userlist") { m_userList->clear(); for (const QJsonValue &v : obj.value("users").toArray()) m_userList->addItem(v.toString()); } else if (type == "login_result" && !obj.value("ok").toBool()) { QMessageBox::warning(this, "登录失败", obj.value("reason").toString()); } }

onSendClicked里解析一个简单的/w 昵称 内容前缀来发私聊,比单独做个私聊窗口省事,演示时也直观。普通消息则组装{"type":"chat","text":...}丢给ChatClient::send。所有帧的组装都在客户端完成,服务端只做转发和校验,职责清晰。

4.3 消息渲染与中文编码统一

QTextBrowser::append接受 HTML,所以消息文本必须先toHtmlEscaped(),否则对方发一段<script>或者<img src=...>就会破坏渲染,甚至触发本地文件读取。发送方昵称同样要转义:

void MainWindow::appendMessage(const QString &from, const QString &text, bool priv) { const QString color = priv ? "#c0392b" : "#2c3e50"; const QString tag = priv ? "[私聊]" : ""; m_chatView->append(QString("<span style='color:%1'><b>%2</b>%3</span> %4") .arg(color, from.toHtmlEscaped(), tag, text.toHtmlEscaped())); m_chatView->verticalScrollBar()->setValue( m_chatView->verticalScrollBar()->maximum()); // 自动滚到底 }

编码这条链路要全程 UTF-8:源文件保存为 UTF-8,网络传输用QJsonDocument输出的 UTF-8 字节,界面显示用QString。MSVC 编译器默认按本地代码页读源文件,中文字面量会变成乱码,.pro里加一行就能解决:

msvc { QMAKE_CXXFLAGS += /utf-8 }

Qt6 默认按 UTF-8 处理源文件,这一行可以去掉,但保留也无害。不要用toLatin1()发中文,也不要用QTextCodec::setCodecForLocale去“修”乱码,那是治标,根子在于某一段用了错误的编码转换。

4.4 打包与资源文件:让别人电脑上也能双击运行

Release 编译出来的 exe 直接拷到没装 Qt 的机器上会提示缺Qt5Core.dll。Windows 上用windeployqt把依赖拉齐:

# 在 Qt 命令行环境里,进入 release 输出目录 windeployqt --release --no-translations --no-system-d3d-compiler LanChat.exe

--no-translations去掉用不上的翻译文件,--no-system-d3d-compiler去掉 D3D 编译器,能少几十兆。打包后整个目录压缩发给同学,解压双击即可。Linux 下对应的是把libQt5Core.so等依赖一起拷,或者用 AppImage 工具链。.pro文件保持最小:

QT += core gui network widgets CONFIG += c++17 TARGET = LanChat TEMPLATE = app

用 CMake 的话,find_package(QT NAMES Qt6 Qt5 REQUIRED COMPONENTS Widgets Network)这种写法能同时兼容 Qt5 和 Qt6,换环境不用改构建脚本。

5. 避坑与排查:粘包、跨线程、乱码、端口占用和掉线残留

这一章的五条都是我实际遇到过的,现象描述按“看到什么”写,方便对照。

5.1 发快了就串消息:粘包没处理干净

现象:单条发送正常,按住回车连发三条,接收端显示成一条拼接的长消息,或者第二条只剩半截。

原因:readyRead只代表“有数据到了”,不保证是一个完整帧。直接readAll()后当一条消息解析,两次发送的数据就会粘在一起;反过来,一个大帧被拆成两次到达,就会解析出半截 JSON,QJsonDocument::fromJson返回空对象。

解决:按第 2 章的decodeFrames做缓冲加循环解析,半包留在m_buffers[sock]里等下一批。排查时可以临时在onReadyRead里打印buf.size()和每次解析出的帧数,正常情况应该是“帧数 0 或 1,缓冲增长后突然吐出 1 帧”,如果每次都吐 0 帧且缓冲无限增长,说明长度头字节序或者偏移算错了。

5.2 界面卡死或用 QtConcurrent 后 socket 报错:线程归属搞错了

现象:把 socket 挪到子线程后,控制台出现QSocketNotifier: Socket notifiers cannot be enabled from another thread,或者readyRead再也不触发。另一种表现是加了“接收消息时睡 1 秒模拟处理”,整个界面直接卡住。

原因:QTcpSocket是QObject,它的线程归属在创建时确定,moveToThread之后所有读写都必须在该线程执行。直接在 UI 线程调用子线程里的write,或者反过来,都会触发未定义行为。而界面卡死通常是你在 UI 线程里做了阻塞操作,网络 IO 本身是异步的,根本不需要开线程。

解决:先问自己是不是真的需要多线程。Qt 的事件循环处理几十个聊天连接绰绰有余,真正的瓶颈在界面渲染和磁盘。确实要挪,就在子线程里创建 socket,通过QMetaObject::invokeMethod或队列连接的信号槽调write,绝不在外面直接碰它。判断线程归属用sock->thread() == QThread::currentThread(),调试时打一行日志就能定位。

5.3 中文变问号:编码链路有一环没对齐

现象:英文昵称和消息正常,中文昵称显示成??或者一串方块,私聊时按昵称查找还找不到人。

原因:三个高发点——源文件不是 UTF-8 保存,MSVC 按本地代码页解析中文字面量;发送时用了toLatin1();JSON 解析出来后又用QString::fromLocal8Bit转了一遍。

解决:源文件统一 UTF-8,MSVC 加/utf-8;网络层只用QJsonDocument::toJson和fromJson,它们内部就是 UTF-8,不要再手动转;界面显示统一QString。验证方法很简单:登录一个纯中文昵称,看服务端日志打印出来的name.size(),正常中文昵称长度是字符数而不是字节数,如果长度翻倍,说明中间被当成 Latin-1 处理过。

5.4 服务端起不来:端口占用和监听地址

现象:listen返回 false,errorString()显示Address already in use,或者本机能连、同网段另一台机器连不上。

原因:端口被上一个没退干净的进程占着;或者监听地址写成了QHostAddress::LocalHost,只绑了回环,局域网当然连不进去;还有一种是系统防火墙拦了入站。

解决:换一个不常用端口,比如 45678;确认listen用的是AnyIPv4而不是LocalHost;检查进程管理器里有没有残留的旧服务端。Windows 上可以用netstat -ano | findstr 45678看谁占着,Linux 用ss -lntp。另外服务端刚关闭立刻重启可能遇到 TIME_WAIT,等几十秒或者换个端口最快。

5.5 对方下线了列表还在:断线信号没接或没广播

现象:对方直接关窗口,自己这边的在线列表还显示他,点私聊提示“用户不在线”,但列表就是不刷新。

原因:客户端关闭时没有走disconnectFromHost(),进程直接退出,服务端要等 TCP 超时才感知;或者服务端接了disconnected但清理函数里忘了广播新列表;再或者异常断网,disconnected压根不触发。

解决:客户端closeEvent里主动disconnectFromHost()并等disconnected再退出;服务端onDisconnected里移除索引后必须调broadcastUserList();同时保留心跳兜底,45 秒无活动强制清理。三管齐下,正常关闭、异常退出、断网三种情况都能覆盖。

6. 进阶与验证:私聊、文件传输的边界和一套答辩演示脚本

私聊做完之后,很多人会想加文件传输。这里有个边界必须先说清:不要把整个文件读进内存再塞进一个 JSON 帧。一个 50MB 的文件 base64 编码后接近 67MB,超过kMaxFrame会被服务端直接判定为非法帧断开连接。正确做法是分片,每片 32KB,帧里带fileId、seq、total,接收端按fileId建临时文件顺序写,收齐后校验大小再改后缀。下面是最小分片发送逻辑:

// 发送端:按 32KB 切片,每片一个独立帧 void ChatClient::sendFile(const QString &path, const QString &to) { QFile f(path); if (!f.open(QIODevice::ReadOnly)) return; const QString fileId = QUuid::createUuid().toString(QUuid::WithoutBraces); const qint64 total = (f.size() + 32767) / 32768; qint64 seq = 0; while (!f.atEnd()) { const QByteArray chunk = f.read(32768); send({{"type", "file_chunk"}, {"fileId", fileId}, {"to", to}, {"name", QFileInfo(path).fileName()}, {"seq", seq++}, {"total", total}, {"data", QString::fromLatin1(chunk.toBase64())}}); } }

这个实现有几个明确的边界,答辩时被追问也答得上来:服务端要限制单连接同时进行的fileId数量,否则多个大文件并发会把内存吃光;接收端要校验seq连续性,缺片就丢弃整个文件并提示重传;文件名必须由接收端重新生成,不能直接用对端传来的name拼路径,否则../../这类路径穿越能写到任意目录。真要做得稳,还得加分片确认和断点续传,但毕设规模做到“分片 + 大小上限 + 文件名消毒”已经够讲清楚设计取舍了。

演示脚本我一般固定四个动作,按顺序走一遍,每个动作都有明确的预期现象:

步骤操作预期现象出问题先看哪里
1两台机器分别启动服务端和客户端,登录不同昵称双方列表互相出现对方服务端listen返回值、防火墙
2客户端 A 发群聊,B 发私聊给 AA 看到群聊和私聊两种颜色私聊回显、m_nameIndex
3直接关掉 B 的窗口A 的列表在数秒内移除 Bdisconnected与广播时机
4拔掉 B 的网线,等 45 秒A 的列表自动移除 B心跳定时器与超时阈值

这四步能过,现场基本不会翻车。我吃过一次亏:本地单机开两个客户端测得好好的,到了答辩教室两台机器一联,服务端绑的是LocalHost,对面根本连不进来,临时改代码重编译,场面很被动。从那以后我每次交这类项目前,都强制在两台物理机上断网跑一遍这四个动作,确认登录、群聊、私聊、掉线清理全部符合预期才敢打包。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/1 1:06:31

DRV8818+PIC18F86J50工业步进电机硬核控制实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/11/1 1:06:00

DRV8818PWPR与STM32F207ZG的工业步进电机驱动控制方案

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/11/1 1:05:59

工业双极步进电机控制:DRV8818PWPR+MKV58F1M0VLQ24实战解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/11/1 1:05:13

DRV8818PWPR+PIC18F47J53:工业级步进电机控制方案全解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/11/1 1:04:39

双极步进电机驱动方案:DRV8818PWPR与PIC32MX664F064L实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/11/1 1:04:22

布谷鸟算法优化BP神经网络:四分类与多分类MATLAB代码实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华