资讯动态

Qt局域网聊天室:TCP自定义帧、粘包处理与线程模型

发布时间:2026/10/28 1:01:47 来源:尧图企业网站定制
简介面向计算机专业毕业设计与Qt入门进阶者的局域网聊天室项目源码围绕私聊、文件传输与管理员权限管理展开需配合本地或服务器MySQL 5.7使用并限定在同一局域网网段运行。压缩包共87个文件约6.54MB以16个cpp源文件、14个h头文件、7个ui界面文件为主辅以图片、qrc资源、工程配置与构建产物便于梳理界面层、逻辑层和数据库访问层的组织方式。项目通过定时器轮询数据库标志位实现在线/离线状态刷新私聊普通消息走UDP文件传输走TCP并在无中转服务器时由发送双方动态切换服务端与客户端身份。已有4111人学习下载适合参考协议选择、身份切换、权限控制和Qt工程搭建也能据此理解局域网即时通信的完整实现思路。1. 局域网聊天室的需求边界谁在用、要交什么、不做什么选题会上最容易被质疑的一类题目就是“功能听起来谁都懂”。局域网聊天室正好卡在这个位置说出来三句话讲完真动手才会撞上粘包、线程亲和性、在线状态残留、断线重连后消息重复这些硬骨头。用 Qt 做这件事的好处在于QTcpServer、QTcpSocket、QThread 加上信号槽这套机制能把网络层和界面层拆得比较干净答辩时你能指着代码讲清楚一条消息从哪个线程进来、被哪个对象接住、最后落到哪个窗口。这套方案面向三类人要交一份能跑通、能讲清、还能往外扩展的毕设或课设的同学想把 Qt 网络编程从头串一遍的初学者以及需要一个可复用 C/S 通信骨架的开发者。下面按需求边界、协议设计、服务端、客户端、踩坑排查、验收答辩的顺序把这份局域网聊天室方案完整拆开。2. 通信协议与线程模型自定义帧 网络线程隔离局域网聊天室的技术难点不在“把字发出去”而在“发出去之后对方能不能完整、有序、不重复地收到”。这一章定两件事应用层协议长什么样以及 socket 归哪条线程管。这两件事定错了后面界面做得再漂亮也是返工。2.1 传输层选型TCP 长连接、UDP 广播还是 HTTP 轮询先排除几个看起来省事的方案。HTTP 轮询的延迟取决于轮询间隔1 秒一次的话聊天体验像发邮件改成长轮询又要维护挂起的请求服务端连接数一多就难看。UDP 广播做局域网发现很舒服但消息可靠性、顺序、去重全得自己补毕设周期里补不完整就是一堆偶发丢消息。WebSocket 语义上合适可在 Qt 里要么依赖 QtWebSockets 模块要么自己实现握手反而多一层。我一般给这个场景的结论是消息通道走 TCP 长连接自己定一层轻量应用层协议。理由是 Qt 原生就有 QTcpServer 与 QTcpSocket依赖为零TCP 本身保证有序可靠粘包问题在应用层用长度字段就能干净解决局域网内 RTT 极低长连接的心跳开销可以忽略。方案延迟可靠性Qt 依赖毕设讲解难度TCP 长连接 自定义帧低TCP 保证仅 QtNetwork中协议可画图UDP 广播最低需自行实现仅 QtNetwork高重传逻辑难讲圆HTTP 轮询高HTTP 保证需 QNetworkAccessManager低但技术含量不足WebSocket低TCP 保证需 QtWebSockets中高握手要额外解释选型定了之后还有一个容易忽略的点连接的生命周期。一条 TCP 连接从登录成功开始到主动退出或心跳超时结束中间所有消息都复用同一条连接不在每条消息上重新建连。这样服务端的会话表才有意义在线状态也才有地方挂。2.2 帧格式定义魔数、长度、类型与 JSON 负载TCP 是字节流没有消息边界。你write两次对面可能一次readyRead全收到你write一次大包对面可能分三次收到。解决办法是在应用层给每条消息加一个头写明“这条有多长”。帧头设计成固定的 9 个字节偏移长度字段说明04magic固定值用来识别非法连接与错位流44length负载字节数网络字节序81type消息类型枚举9lengthbodyUTF-8 编码的 JSON魔数看着像炫技实际很有用客户端连错端口、或者有人拿 telnet 乱敲服务端第一帧就对不上魔数可以直接断开不用等解析 JSON 报错。长度字段用大端网络字节序是为了避免 x86 与 ARM 混编时对不上毕设里虽然大概率同架构但这个习惯值得保留。编码部分// frame_codec.h —— 应用层帧的封装与拆解 #pragma once #include QByteArray #include QJsonObject #include QJsonDocument #include QtEndian constexpr quint32 kMagic 0x51544348; // QTCH帧起始标识 constexpr int kHeaderSize 9; // 4 魔数 4 长度 1 类型 constexpr quint32 kMaxBody 1024 * 1024; // 单帧上限 1MB防止被撑爆内存 enum class MsgType : quint8 { Login 1, LoginAck 2, Chat 3, Broadcast 4, UserList 5, Heartbeat 6, Logout 7, Error 8 }; inline QByteArray encodeFrame(MsgType type, const QJsonObject payload) { const QByteArray body QJsonDocument(payload).toJson(QJsonDocument::Compact); QByteArray frame; frame.reserve(kHeaderSize body.size()); const quint32 magic qToBigEndianquint32(kMagic); const quint32 len qToBigEndianquint32(quint32(body.size())); frame.append(reinterpret_castconst char *(magic), 4); frame.append(reinterpret_castconst char *(len), 4); frame.append(char(type)); frame.append(body); return frame; } // 返回值 false 有两种含义数据还不够一帧或流已损坏 // 调用方需要自己保留 buffer等下一次 readyRead 再试 inline bool decodeFrame(QByteArray buffer, MsgType type, QJsonObject payload) { if (buffer.size() kHeaderSize) return false; const quint32 magic qFromBigEndianquint32(buffer.constData()); if (magic ! kMagic) { buffer.clear(); return false; } // 非法流直接丢弃 const quint32 len qFromBigEndianquint32(buffer.constData() 4); if (len kMaxBody) { buffer.clear(); return false; } // 超长帧视为攻击 if (buffer.size() kHeaderSize int(len)) return false; // 半包等更多数据 type MsgType(quint8(buffer.at(8))); payload QJsonDocument::fromJson( buffer.mid(kHeaderSize, int(len))).object(); buffer.remove(0, kHeaderSize int(len)); // 消费掉这一帧 return true; }几个参数值得在答辩时点出来。kMaxBody是防御性设计没有上限的话一个伪造的 length 字段就能让服务端尝试分配几个 Gbuffer.remove(0, n)是消费已解析数据写错成buffer.clear()就会把半包一起清掉表现为“偶尔丢消息”MsgType用quint8而不是int是为了让帧头长度固定解析时不用猜。负载选 JSON 而不是 protobuf 或自定义二进制理由很实际JSON 可读抓包能直接看懂答辩演示时把一条帧贴到屏幕上就能讲清字段含义调试阶段也能用最小脚本发测试消息。代价是体积和解析开销局域网几十人规模完全感知不到。2.3 线程模型socket 归属线程与跨线程投递Qt 有一条铁律QObject 有线程亲和性QTcpSocket必须在它所属的线程里创建和使用别的线程直接调它的方法就是未定义行为典型症状是界面偶发卡死或者QSocketNotifier: Socket notifiers cannot be enabled or disabled from another thread这条警告刷屏。服务端的规模判断很简单局域网聊天室按 50 到 200 人估每人几条心跳和聊天消息单线程事件循环完全吃得下因为事件驱动本身不阻塞处理一条消息就是几百微秒。所以我一般不在服务端开线程池而是把精力放在“单条消息处理不能有阻塞调用”上。真要展示多线程把每个客户端会话包进一个QThread是可以的但会引入锁和生命周期管理答辩时被追问“你为什么要多线程”反而不好回答。客户端不一样界面要重绘、要滚动、要处理输入法网络 I/O 放在 UI 线程虽然能跑但接收大消息或重连时会出现肉眼可见的卡顿。做法是把网络层塞进独立线程// network_worker.h —— 客户端网络层跑在独立线程 class NetworkWorker : public QObject { Q_OBJECT public: explicit NetworkWorker(QObject *parent nullptr) : QObject(parent) {} public slots: // 必须在 worker 线程里调用socket 也在这里 new void start(const QString host, quint16 port) { socket_ new QTcpSocket(this); connect(socket_, QTcpSocket::connected, this, NetworkWorker::onConnected); connect(socket_, QTcpSocket::disconnected, this, NetworkWorker::onDisconnected); connect(socket_, QTcpSocket::readyRead, this, NetworkWorker::onReadyRead); connect(socket_, QTcpSocket::errorOccurred, this, NetworkWorker::onError); socket_-connectToHost(host, port); } void sendFrame(int type, const QJsonObject payload) { if (!socket_ || socket_-state() ! QAbstractSocket::ConnectedState) return; socket_-write(encodeFrame(MsgType(type), payload)); // 小包无需 flush事件循环会处理大数据量时可在此判断 bytesToWrite } signals: void frameReceived(int type, const QJsonObject payload); void stateChanged(const QString text); // 交给界面显示“已连接/重连中” void errorRaised(const QString text); private slots: void onReadyRead() { buffer_.append(socket_-readAll()); // 一次 readAll 拿全避免漏读 MsgType type; QJsonObject payload; while (decodeFrame(buffer_, type, payload)) { emit frameReceived(int(type), payload); // 跨线程自动走 QueuedConnection } } private: QTcpSocket *socket_ nullptr; QByteArray buffer_; // 半包缓存必须跟着连接走不能是局部变量 };宿主侧的接法是这样的QThread *t new QThread(this); auto *w new NetworkWorker; w-moveToThread(t); connect(t, QThread::started, w, [w]{ ... });。注意两点一是moveToThread之前不能有父对象否则 Qt 会拒绝移动二是信号槽跨线程默认是Qt::QueuedConnection参数必须能被 Qt 元对象系统拷贝QJsonObject满足这个条件传QByteArray或自定义结构体时要先qRegisterMetaType。onReadyRead里的while循环是关键一次readyRead可能带来一帧半也可能带来五帧只解一次就是典型的“表情包消息发得快就丢”。这一点在后面的排查章节还会再提。3. 服务端实现会话表、消息路由与在线状态维护服务端是这套方案的“黑匣子”也是答辩时最能体现设计水平的部分。它要维护三样东西谁在线、消息往哪送、连接什么时候该被踢掉。3.1 会话表设计两套索引把 socket 和用户绑在一起只用一张QListQTcpSocket*也能广播但一旦要做私聊和“某某上线了”的定向通知就得能从一个用户找到连接也能从一条连接反查用户。所以我用两套索引结构键值用途socketIndexQTcpSocket*quint64 sessionId收到数据时反查会话userIndexQString userNamequint64 sessionId私聊、踢人、判断重名sessionsquint64 sessionIdSession 结构体存昵称、进入时间、最近心跳// session.h —— 一条连接对应的会话实体 struct Session { quint64 id 0; QString userName; // 登录成功后才有值 QByteArray buffer; // 半包缓存跟着连接走 qint64 lastActiveMs 0; // 最近一次收到任意帧的时间 bool authed false; // 是否已完成登录握手 };注意buffer放在Session里而不是放在处理函数里这是粘包处理能正确工作的前提。很多人写成void onReadyRead() { QByteArray buf socket-readAll(); ... }看起来能用实际上只要一次半包剩下的半截就永远丢了。还有一个细节sessionId用自增整数而不是指针是为了日志里能打印、能给客户端引用也避免了 socket 销毁后指针悬空的问题。所有对外的引用都用 id通过 id 再去查表查不到就当已被清理。用户重名怎么处理是需求阶段就该定的事。我一般定成同名登录时服务端返回Error帧code1001客户端在登录页提示“昵称已被占用”而不是服务端强行踢掉旧连接。后者演示时容易出现两个人互相踢的奇观讲起来很尴尬。3.2 消息路由单播、广播、离线缓存的三种投递消息路由集中在一个函数里所有入口都调它好处是分发逻辑只有一份加新消息类型时不用改八处// server.cpp —— 统一出口所有下行消息都从这里走 void ChatServer::deliver(DeliverMode mode, quint64 targetId, MsgType type, const QJsonObject payload) { const QByteArray frame encodeFrame(type, payload); switch (mode) { case DeliverMode::Unicast: { // 私聊 / 定向回执 Session *s findSession(targetId); if (s s-authed) s-socket-write(frame); break; } case DeliverMode::Broadcast: { // 群聊 / 在线列表变更 for (Session s : sessions_) { if (s.authed s.id ! targetId) // 群聊不回显给自己 s.socket-write(frame); } break; } case DeliverMode::BroadcastAll: { // 上线通知等需要包含自己 for (Session s : sessions_) { if (s.authed) s.socket-write(frame); } break; } } }三种模式的差别值得说清楚。Unicast用于私聊和登录回执找不到会话就静默丢弃不报错——对方可能刚好下线了。Broadcast排除发送者自己因为客户端本地已经乐观显示了自己发的消息服务端再回显一次就是重影。BroadcastAll用在在线列表推送这类所有人都要知道的事件上。群聊消息里我建议带一个服务端生成的msgId和时间戳不要用客户端时间。原因是局域网里几台机器的时间可能差几分钟界面上按时间排序会乱而msgId单调递增客户端去重和排序都有依据。这也是后面做“断线重连补消息”的伏笔。write之后不需要手动flushQt 的事件循环会在合适的时候把缓冲送出去。但如果你要统计“发送失败”就得接bytesWritten信号光看write的返回值只能说明数据进了缓冲区。3.3 心跳与超时剔除在线列表怎么保证不“僵尸”局域网里最常见的场景是笔记本合盖、网线被拔、程序被强制结束。这些情况下 TCP 不会立刻感知服务端在线列表里就会挂着一个永远不说话的用户别人给他发私聊全石沉大海。做法是双向心跳服务端每 30 秒广播一次Heartbeat客户端收到后回一个Heartbeat并更新lastActiveMs。服务端用QTimer每 15 秒扫一遍会话表超过 90 秒没有任何帧的会话直接abort()并清理。// 超时扫描90 秒没有任何帧往来就判定掉线 void ChatServer::onSweepTimeout() { const qint64 now QDateTime::currentMSecsSinceEpoch(); QListquint64 dead; for (const Session s : sessions_) { if (s.authed now - s.lastActiveMs 90 * 1000) dead.append(s.id); } for (quint64 id : dead) { removeSession(id, QStringLiteral(心跳超时)); // 内部会广播 UserList } }参数怎么定心跳间隔 30 秒、扫描周期 15 秒、超时阈值 90 秒是留了两次丢包的余量。间隔写太短比如 5 秒会让日志刷屏写太长比如 5 分钟演示时等不到人下线答辩现场会很尴尬。用abort()而不是disconnectFromHost()是因为后者在对方已经半死的情况下会等 TCP 四次挥手可能卡住十几秒而abort()立即释放描述符。清理会话时一定要先广播新的在线列表再销毁 socket顺序反了会出现在线列表里少一个人、但下一个事件又把他加回来的诡异现象。这个坑我在第 5 章里展开说。4. 客户端实现三段式界面 信号槽解耦客户端最容易写成“能跑但没法改”的形态所有逻辑堆在MainWindow里socket 是成员变量收到数据直接ui-textEdit-append()。这样第一版很快但加一个私聊窗口就要动十处代码。4.1 界面结构登录页、会话列表、消息区的三段式布局用 Qt Widgets 就够了不需要 QML。整体分三层登录页是一个独立的QDialog只负责收集主机、端口、昵称点“连接”之后把参数抛出去自己不碰网络主窗口用QSplitter左右分栏左侧是QListWidget显示在线用户和群聊入口右侧是QTextBrowser显示消息、下方QLineEdit加发送按钮。QTextBrowser而不是QTextEdit因为它支持富文本但默认只读天然避免用户在手滑时改了历史消息。消息渲染用 HTML 拼// 消息气泡渲染昵称加粗、时间置灰、正文转义 void ChatWindow::appendMessage(const QString user, const QString text, const QDateTime ts, bool self) { const QString color self ? QStringLiteral(#2b6cb0) : QStringLiteral(#2d3748); const QString html QStringLiteral( div stylemargin:4px 0;color:%1 b%2/b span stylecolor:#a0aec0;font-size:11px%3/spanbr/ span%4/span/div) .arg(color, user.toHtmlEscaped(), ts.toString(HH:mm:ss), text.toHtmlEscaped()); // 必须转义否则对方发 img 就中招 ui-chatView-append(html); }toHtmlEscaped()这行不能省。有人在群里发一个b或者一段style整个聊天窗口的样式就炸了排查半天发现是渲染层的问题。这属于典型的“演示前十分钟才发现”的坑。左侧在线列表要在用户上下线时更新我用QListWidget的 item 数据槽存 sessionId 或昵称避免用显示文本反查。4.2 网络层与界面层解耦只传信号不传 socket界面层拿到的东西应该只有“有人发了一条消息”这种语义事件而不是QJsonObject。所以在NetworkWorker和MainWindow之间再放一层轻量的ChatClient把协议细节翻译成业务信号// chat_client.cpp —— 协议翻译层界面只认这里的信号 void ChatClient::onFrame(int type, const QJsonObject obj) { switch (MsgType(type)) { case MsgType::LoginAck: { if (obj.value(ok).toBool()) { myName_ obj.value(name).toString(); emit loginSucceeded(myName_); } else { emit loginFailed(obj.value(msg).toString()); // 昵称重复等 } break; } case MsgType::Chat: { emit messageArrived(obj.value(from).toString(), obj.value(text).toString(), QDateTime::fromMSecsSinceEpoch( obj.value(ts).toVariant().toLongLong())); break; } case MsgType::UserList: { QStringList users; for (const auto v : obj.value(users).toArray()) users v.toString(); emit userListChanged(users); break; } case MsgType::Error: emit serverError(obj.value(code).toInt(), obj.value(msg).toString()); break; default: break; } }这样分层的好处在加功能的时候体现得最明显。想加“未读红点”只要在ChatClient里挂messageArrived统计不用动网络层想把 JSON 换成别的序列化格式只改编解码和这一层界面零改动。答辩时被问“你的模块怎么划分的”这张图网络层—协议层—界面层能直接画出来。信号槽还有一处容易翻车emit在 worker 线程里发出槽在 UI 线程执行默认排队连接所以槽函数里可以直接改界面不需要加锁。但如果槽里做了耗时操作比如查 SQLite 写历史就会卡住 UI 线程这个度要自己把握。4.3 本地消息记录与未读数QSettings 还是 SQLite聊天记录存不存、怎么存是需求阶段就要定的。以毕设的体量两种方案都合理方案优点缺点适用QSettings JSON 文件零依赖、几行代码每次全量读写条数一多就慢只存最近 N 条演示够用SQLiteQtSql支持分页查询、按会话过滤要建表、要处理连接线程问题想体现数据持久化设计我倾向于选 SQLite因为“消息持久化”本身就是个可以写进论文的模块而且实现成本不高。建一张表就够-- 单表存所有消息session_key 区分群聊与私聊 CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_key TEXT NOT NULL, -- group 或 private:昵称 sender TEXT NOT NULL, content TEXT NOT NULL, ts INTEGER NOT NULL, -- 毫秒时间戳 is_read INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_session_ts ON messages(session_key, ts);session_key用字符串拼而不是建会话表是为了少一次 join代价是改名后历史消息对不上毕设里可以接受。索引建在(session_key, ts)上因为唯一的查询模式是“某个会话按时间倒序取最近 50 条”。一个必须提醒的点QSqlDatabase的连接不能跨线程共享。如果你把数据库操作放在 UI 线程而网络层在 worker 线程那就只在 UI 线程用数据库别图省事在 worker 里也开一个连接。未读数就存在内存里退出时再刷一次库简单可靠。5. 避坑与排查粘包、跨线程 UI 崩溃和重连状态错乱这一章是我在类似项目里踩过的、也是答辩现场最容易被追问的几处。每一条都按“现象—原因—解决”讲。现象一短消息正常长消息或连发消息丢失、错位。原因在readyRead里只解一帧。TCP 没有消息边界一次信号可能对应零点多帧到多帧只调一次decodeFrame剩下的数据就留在buffer里等下次信号而下次信号可能很久才来。解决办法是把解析写成while (decodeFrame(...))循环并且buffer必须是会话级的成员变量随连接创建、随连接销毁。顺带检查buffer.remove(0, kHeaderSize len)是否真的消费了数据写成clear()会在半包时把已收到的部分也清掉。现象二程序偶发崩溃调用栈指向QWidget::update()或某个ui-成员控制台偶尔有Cannot create children for a parent in a different thread。原因是在 worker 线程里直接操作了界面对象。Qt 的界面类只能在主线程使用跨线程的直接调用不会报错只会在某个不确定的时刻崩。解决办法是所有 UI 更新都通过信号槽走连接方式保持默认的Qt::AutoConnection跨线程自动排队如果确实需要在 worker 里触发一次界面更新用QMetaObject::invokeMethod(widget, [w]{ ... }, Qt::QueuedConnection)。排查手段是在槽函数里打印QThread::currentThreadId()和主线程 id 对一下一眼能看出来。现象三断线重连之后界面出现重复消息或者发送按钮点了没反应。原因通常是重连时复用了旧的QTcpSocket对象同时旧的信号连接还挂着一条数据被两个槽处理或者NetworkWorker::start被调了两次socket_被覆盖旧对象泄漏。解决办法是把重连做成“销毁—重建”而不是“复用”先socket_-deleteLater()把buffer_清空再new QTcpSocket(this)重新 connect 信号。所有connect都用成员函数指针形式避免字符串形式在重连时重复绑定。现象四中文昵称在服务端日志里是乱码或者在线列表里显示成问号方块。原因是编码不统一。JSON 本身是 UTF-8但有人图快用QString::toLocal8Bit()编码 body在 GBK 环境下就崩了。统一做法是QJsonDocument::toJson()出来的QByteArray直接用不额外转码界面显示时QString::fromUtf8()源文件保存为 UTF-8 且在main里不需要再设setCodecForLocale。排查方式是在服务端把收到的 body 前 32 字节以十六进制打印看E4 BD A0“你”的 UTF-8还是C4 E3GBK一眼定位。现象五本机能连同宿舍另一台机器连不上或者演示当天服务端启动就bind失败。前者多半是监听地址写成了QHostAddress::LocalHost只绑了回环改成QHostAddress::Any即可另外要确认防火墙对监听端口的入站规则演示机最好提前放行。后者是端口被上一次没退干净的进程占着listen返回 false 但没人检查返回值。我的做法是启动时检查if (!server_-listen(QHostAddress::Any, port))就弹窗提示具体错误并支持换端口而不是静默失败——静默失败是演示事故的头号来源。6. 验收与答辩一份能复现的测试清单和三个演示脚本到这一步功能都写完了真正决定成绩的是“能不能证明它可靠”。我一般准备一张测试清单逐条跑、逐条打勾答辩时直接投出来比口头说“功能都实现了”有说服力得多。编号测试项操作预期结果T1多端登录三台客户端依次登录各自收到完整在线列表列表长度一致T2群聊广播A 连续发 20 条含表情的消息B、C 顺序正确、无丢失、无重复T3私聊定向A 私聊 B只有 B 收到C 无感知T4昵称冲突用已在线昵称登录返回错误码提示“昵称已被占用”不影响在线者T5异常断开直接结束 B 的进程90 秒内 A、C 的在线列表移除 BT6断线重连B 断网 30 秒后恢复自动重连成功历史消息仍在无重复条目T7大消息发送 8000 字文本对端完整显示程序不卡顿T8非法连接用 telnet 连接并乱发字节服务端断开该连接其他会话不受影响演示脚本我固定写三个每个两分钟以内避免现场翻车。第一个是“三端上线 群聊 私聊”重点讲清楚一条消息从QLineEdit到encodeFrame、到服务端路由、再到对端appendMessage的完整链路边走边在代码里定位到对应函数。第二个是“拔网线”故意杀掉一个客户端等在线列表更新讲心跳与超时剔除的参数怎么定的。第三个是“连发压力”用一个循环脚本连发 50 条消息证明粘包处理是对的——这一条最能体现你懂 TCP而不是只会调 API。答辩提问高频的三个问题答案要提前背熟为什么不用 UDP可靠性要自己做收益在局域网里不明显为什么消息用 JSON可读、可调试规模小不需要二进制优化粘包怎么解决的长度字段 会话级 buffer while 循环解析。如果被问到扩展性就说清楚当前是单机服务端、会话表在内存里往多人在线方向走要么加房间概念要么把会话表外置——说得出边界比硬吹“支持万人”可信得多。最后给一个我自己的习惯每次改完协议相关代码先不急着开界面写个十几行的小脚本往服务端灌一帧半包、一帧超长帧、一帧魔数错误的帧看服务端是不是按预期断开而不是崩掉。这个动作花不了五分钟但救过我两次演示。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑