简介:这份基于Qt的局域网即时通信系统设计文档,面向计算机、软件工程等专业学生以及需要完成课程设计或毕业设计的开发者。文档围绕即时通信的核心场景展开,涵盖群聊与私聊、聊天字体设置、聊天记录保存、文件互传以及在线用户列表维护等功能,并给出从需求分析到模块实现的完整思路。压缩包内仅1个doc文档,约789KB,篇幅紧凑,内容包含引言与开发背景、Qt开发技术简介、功能性、数据、技术、性能、编写环境等需求分析、软件结构设计、各模块流程图与代码实现,以及软件测试、结束语、参考文献和外文文献翻译等章节,目录层级清晰,便于按模块查阅。已有131人学习关注,适合用作局域网通信项目的参考方案,帮助读者理解TCP/IP传输、信号与槽机制、异步文件收发和心跳连接保持等关键实现,也可作为撰写设计报告与组织代码结构的素材。
1. 局域网即时通信系统为什么用 Qt 自己写一遍
办公室十几台机器,一条通知要挨个喊;车间里的工控机不允许接外网,却要互传工单;实验室的测试机群需要同步状态。这类场景的共同点是:通信半径不超过一个网段,但消息不能发到外部服务上,也不希望每台机器都装一套重客户端。基于 Qt 的局域网即时通信系统正好卡在这个位置——QTcpServer/QTcpSocket 负责连接与收发,QUdpSocket 负责节点发现,QJsonDocument 把消息体序列化成可读文本,界面用 QWidget 或 QML 都能在同一个工程里收口。适合已经会写 C++、想给内部工具补一层即时消息能力的开发者,也适合把 Qt 当作唯一跨平台技术栈的团队,Windows、Linux、macOS 一套代码编译过去。下面按选型、发现、消息、排错的顺序,把一套能真正跑起来的实现拆开讲。
2. Qt 网络模块选型与通信帧格式设计
2.1 QTcpServer、QUdpSocket 与 QWebSocket 怎么选
局域网即时通信系统最容易在第一步走偏:把所有功能都堆到一个 TCP 连接上。实际做过几轮之后,常见做法是把「发现」和「通信」拆成两条通道,各用各的协议。
QUdpSocket 走广播或多播,只做一件事——让新上线的机器知道同网段里还有谁。它的特点是无需预先知道对方 IP、无连接、丢一两个包无所谓,下一轮广播就补回来了。QTcpServer/QTcpSocket 走点对点长连接,承担真正的消息投递,因为 TCP 有序、可靠、自带重传,做聊天和文件分块都合适。QWebSocket 适合你已经有一个浏览器端或 Web 控制台的场景,纯桌面程序用它反而多一层 HTTP 握手的开销。
| 维度 | QUdpSocket 广播 | QTcpSocket 长连接 | QWebSocket |
|---|---|---|---|
| 是否需要预先知道对端地址 | 不需要 | 需要 | 需要 URL |
| 消息可靠性 | 不保证,可能丢包 | 有序可靠 | 有序可靠 |
| 典型用途 | 局域网设备扫描、心跳广播 | 消息收发、文件分块 | 浏览器端接入 |
| 单机连接数上限 | 无连接概念 | 受文件描述符限制,通常几百到几千 | 同 TCP |
| 实现复杂度 | 低 | 中,需要处理粘包与半包 | 中,需要跑 HTTP 服务 |
选型结论就是双通道:UDP 负责「找到人」,TCP 负责「说上话」。心跳也走 TCP,因为心跳丢失需要立刻反映到连接状态上,UDP 心跳丢包的误判代价太高。
2.2 Qt 5.15.2 离线安装与模块勾选
Qt 安装教程里被问得最多的两个问题,一是装哪个版本,二是为什么编译时提示unknown module(s) in Qt: serialport。后者不是环境坏了,而是安装时没有勾选对应组件。Qt 官方安装器把模块拆得很细,network、widgets 属于 qtbase 自带,serialport、sql、charts 这些都需要在组件树里单独勾。
离线安装包下载下来后按默认路径装即可,若走在线安装器,安装设置里可以配置国内镜像源,速度会明显好转。装完先确认三件事:qmake 版本、网络模块是否可用、插件目录是否齐全。
# 1) 确认 qmake 与编译器指向同一个套件 qmake -v # 输出应类似 QMake version 3.1 / Using Qt version 5.15.2 # 2) 确认 network 模块的静态库存在(Linux 举例,Windows 则是 .lib/.dll) ls $QTDIR/lib | grep -i network # 3) 确认平台插件存在,否则程序起来会报 "could not find or load the Qt platform plugin" ls $QTDIR/plugins/platforms工程文件里显式声明用到的模块,比依赖默认值可靠得多:
QT += core gui network greaterThan(QT_MAJOR_VERSION, 4): QT += widgets CONFIG += c++17 TARGET = LanIM TEMPLATE = app SOURCES += main.cpp mainwindow.cpp peer.cpp discovery.cpp HEADERS += mainwindow.h peer.h discovery.hQT += network是必需项,少了它 QTcpServer 头文件直接找不到;c++17是为了用上结构化绑定和std::optional这类语法,不影响 Qt 本身。如果你用的是 CMake,对应的写法是find_package(Qt5 COMPONENTS Core Gui Widgets Network REQUIRED),再target_link_libraries把Qt5::Network链进去。用 vscode 配置 Qt Designer 时,把 Designer 的可执行文件路径加到设置里,改完 .ui 文件保存就会自动生成 ui_xxx.h。
2.3 自定义帧格式:长度前缀加 JSON 载荷
TCP 是字节流,没有消息边界。直接write(json)然后对端readAll(),当发送频率一高、消息一长,一定会遇到粘包和半包。解决办法是在正文前面加一个固定长度的长度字段。
| 字段 | 长度 | 说明 |
|---|---|---|
| magic | 2 字节 | 固定值,用来快速判断是不是本协议的包 |
| version | 1 字节 | 协议版本,方便后续平滑升级 |
| type | 1 字节 | 0 文本、1 心跳、2 文件块、3 控制指令 |
| length | 4 字节 | 后续 JSON 正文字节数,大端序 |
| body | 变长 | UTF-8 编码的 JSON 文本 |
用 JSON 而不是 protobuf,主要是为了调试方便:抓包或打日志时能直接看懂内容,Qt 自带的 QJsonDocument 读写也就几行代码。长度字段用网络字节序(大端),避免 x86 和部分 ARM 板子之间解析错位。
// frame.h —— 组帧与长度字段读取 #pragma once #include <QByteArray> #include <QtEndian> static const quint16 kMagic = 0x4C51; // 'LQ' inline QByteArray makeFrame(quint8 type, const QByteArray &json) { QByteArray head(8, '\0'); qToBigEndian(kMagic, reinterpret_cast<uchar *>(head.data())); head[2] = 1; // version head[3] = char(type); // frame type qToBigEndian(quint32(json.size()), reinterpret_cast<uchar *>(head.data() + 4)); return head + json; // 一次写入,减少小包数量 }makeFrame把头和正文拼成一个 QByteArray 再交给 socket 的write(),比先写头再写体少一次系统调用。qToBigEndian负责字节序转换,第五个字节开始的 4 个字节就是长度。注意head[3]直接用 char 存 type,读取时记得转回 quint8,否则大于 127 的类型值会变成负数。
3. UDP 广播做局域网设备扫描与 TCP 长连接建立
3.1 广播发现的最小可用实现
发现逻辑就两条:每台机器周期性向广播地址发一条「我在」的报文;同时监听同一个端口,收到别人的报文就更新节点表。端口要固定,并且绑定成 ShareAddress,否则同一台机器上开第二个实例会绑定失败。
// discovery.cpp —— 周期性广播 + 监听应答 #include "discovery.h" #include <QJsonDocument> #include <QJsonObject> #include <QHostInfo> #include <QDateTime> Discovery::Discovery(QObject *parent) : QObject(parent) { udp = new QUdpSocket(this); // ShareAddress 允许多个进程绑定同一端口,ReuseAddressHint 让重启后立刻可绑定 udp->bind(QHostAddress::AnyIPv4, 45454, QUdpSocket::ShareAddress | QUdpSocket::ReuseAddressHint); connect(udp, &QUdpSocket::readyRead, this, &Discovery::onDatagram); timer = new QTimer(this); connect(timer, &QTimer::timeout, this, &Discovery::announce); timer->start(3000); // 3 秒一轮,兼顾发现速度与广播噪声 } void Discovery::announce() { // 只广播必要信息,不暴露用户名等隐私字段 QJsonObject obj{ {"type", "presence"}, {"node", QHostInfo::localHostName()}, {"port", 45455}, // 本机 TCP 监听端口,供对方回连 {"ts", QDateTime::currentMSecsSinceEpoch()} }; udp->writeDatagram(QJsonDocument(obj).toJson(QJsonDocument::Compact), QHostAddress::Broadcast, 45454); } void Discovery::onDatagram() { while (udp->hasPendingDatagrams()) { QByteArray buf; buf.resize(int(udp->pendingDatagramSize())); QHostAddress sender; quint16 senderPort = 0; udp->readDatagram(buf.data(), buf.size(), &sender, &senderPort); const QJsonDocument doc = QJsonDocument::fromJson(buf); if (!doc.isObject()) continue; // 忽略非 JSON 报文 const QJsonObject o = doc.object(); if (o.value("type").toString() != "presence") continue; emit peerFound(sender.toString(), o.value("port").toInt(), o.value("node").toString()); } }pendingDatagramSize()必须在 readDatagram 之前调用,不然缓冲区大小对不上。每收到一条 presence 就发信号给上层,由上层决定是新建连接还是刷新已有节点的时间戳。广播地址用QHostAddress::Broadcast就是 255.255.255.255,如果所在网段做过隔离,可以改成QHostAddress("192.168.1.255")这种定向广播地址。
3.2 心跳、超时与节点表维护
节点表要有淘汰机制,否则关机或拔网的机器会一直挂在列表里。用两个时间常数:心跳间隔和超时阈值,后者通常是前者的 2 到 3 倍。
| 参数 | 建议值 | 取值理由 |
|---|---|---|
| 广播间隔 | 3 s | 新节点最多 3 秒内被发现,广播流量可忽略 |
| TCP 心跳间隔 | 5 s | 及时发现断链,同时不至于让空闲连接频繁唤醒 |
| 节点超时 | 12 s | 容忍两到三次心跳丢失,避免误判 |
| 重连退避 | 1/2/4/8 s | 指数退避,防止对方没起来时疯狂重连 |
| 单消息大小上限 | 4 MB | 超过就转文件通道,避免内存被单条消息吃满 |
心跳报文本身很短,走 TCP 时可以直接复用文本帧,type 设为 1,body 里只放时间戳。发送方不关心回复,接收方每次收到就刷新该节点的lastSeen。另起一个 1 秒精度的 QTimer 扫描节点表,超过 12 秒没更新的标记为离线并从界面移除,同时abort()掉对应的 socket。
注意:不要把心跳判断写在disconnected信号里代替。TCP 半开连接——也就是对端断电、网线拔掉但没发 FIN 的情况——本地 socket 不会立刻收到断开通知,只有靠应用层心跳才能发现。
3.3 从发现到连接的握手流程
握手顺序固定为三步:UDP 收到 presence,本机判断对方节点号是否大于自己(避免双方同时连),然后发起 TCP 连接并发送一条 login 报文。
// peer.cpp —— 收到发现信号后发起连接并登录 void PeerManager::onPeerFound(const QString &ip, int port, const QString &node) { if (node == m_localNode) return; // 自己发的广播,跳过 if (m_peers.contains(node)) return; // 已有连接,仅刷新时间戳 // 只让节点号较大的一方主动连接,防止出现两条重复链路 if (m_localNode < node) return; auto *sock = new QTcpSocket(this); connect(sock, &QTcpSocket::connected, this, [this, sock, node]() { QJsonObject login{{"type", "login"}, {"node", m_localNode}}; sock->write(makeFrame(3, QJsonDocument(login).toJson(QJsonDocument::Compact))); }); connect(sock, &QTcpSocket::disconnected, this, [this, node]() { m_peers.remove(node); // 断开后清出节点表,等待下轮广播重连 }); sock->connectToHost(ip, quint16(port)); }m_localNode用机器名或 UUID 生成,双方比较字符串大小即可,规则简单且不会出现两边同时主动连接的死锁。连接成功后立刻发 login,对方收到后把 socket 与节点号绑定存表,之后所有业务消息都走这条连接。如果 3 秒内没连上,交给重连定时器按退避序列重试。
4. 消息编解码、粘包拆包与 Qt 界面设计落地
4.1 qt读写json:消息体的封装与字段约定
消息统一走 JSON,字段名固定下来,后面加功能只需要加字段不改变解析逻辑。
// codec.cpp —— 文本消息的封装与解析 QByteArray buildTextMessage(const QString &from, const QString &to, const QString &text, qint64 msgId) { QJsonObject o{ {"type", "text"}, {"msgId", QString::number(msgId)}, // 用字符串存,避免 JS 端精度丢失 {"from", from}, {"to", to}, {"text", text}, {"ts", QDateTime::currentMSecsSinceEpoch()} }; return QJsonDocument(o).toJson(QJsonDocument::Compact); } void handleMessage(const QByteArray &body) { QJsonParseError err{}; const QJsonDocument doc = QJsonDocument::fromJson(body, &err); if (err.error != QJsonParseError::NoError) { qWarning() << "json parse failed:" << err.errorString() << "offset" << err.offset; return; // 坏包直接丢,不做重试 } const QJsonObject o = doc.object(); const QString type = o.value("type").toString(); if (type == "text") emit textArrived(o.value("from").toString(), o.value("text").toString()); else if (type == "ack") emit messageAcked(o.value("msgId").toString()); }QJsonDocument::Compact比 Indented 少很多空白字节,网络传输选前者。QJsonParseError一定要接,错误信息里的 offset 能直接告诉你是第几个字节开始不对,对排查截断包特别有用。msgId 用字符串传递是为了和浏览器端 JSON 对齐,qint64 超过 2^53 后 JavaScript 会丢精度。
4.2 粘包与半包:长度前缀拆包实现
发送端已经加了 8 字节头,接收端就要按同样的规则切。核心是维护一个累积缓冲区,能切多少切多少,剩下的留到下次 readyRead。
// peer.cpp —— 按长度前缀拆包 void Peer::onReadyRead() { m_buffer.append(m_socket->readAll()); while (m_buffer.size() >= 8) { const uchar *p = reinterpret_cast<const uchar *>(m_buffer.constData()); if (qFromBigEndian<quint16>(p) != kMagic) { m_buffer.clear(); // 魔数不对,说明流已错位,直接重置 m_socket->abort(); return; } const quint32 len = qFromBigEndian<quint32>(p + 4); if (len > 4u * 1024 * 1024) { // 长度字段被污染,防止无限等包 m_socket->abort(); return; } if (m_buffer.size() < int(8 + len)) break; // 半包,等下一次数据 const quint8 type = quint8(m_buffer.at(3)); const QByteArray body = m_buffer.mid(8, int(len)); m_buffer.remove(0, int(8 + len)); emit frameReady(type, body); } }三个关键点:魔数校验放在最前面,一旦错位立刻断开重连而不是继续解析;长度上限 4 MB 是防御性检查,没有它,一个被篡改的长度字段会让程序一直等一个永远不来的包;break而不是continue是半包处理的核心,缓冲区里剩下的不足一帧就老实等下一次 readyRead。
4.3 qt界面设计:QListView 加自定义 delegate 的聊天列表
聊天列表不要用 QTextEdit 拼 HTML,消息一多滚动会卡。常见做法是 QListView 配 QAbstractListModel,每条消息一个 item,气泡绘制放在自定义 delegate 的paint()里。
// bubledelegate.cpp —— 只画一层背景气泡和文本 void BubbleDelegate::paint(QPainter *p, const QStyleOptionViewItem &opt, const QModelIndex &idx) const { const bool self = idx.data(SelfRole).toBool(); const QString text = idx.data(Qt::DisplayRole).toString(); QRect r = opt.rect.adjusted(8, 4, -8, -4); const int maxW = int(opt.rect.width() * 0.72); // 气泡最宽占七成,留出边距 QFontMetrics fm(opt.font); QRect textRect = fm.boundingRect(QRect(0, 0, maxW, 10000), Qt::TextWordWrap, text); const int bubbleW = qMin(textRect.width() + 24, maxW); const int bubbleH = textRect.height() + 20; QRect bubble(self ? r.right() - bubbleW : r.left(), r.top(), bubbleW, bubbleH); p->setRenderHint(QPainter::Antialiasing, true); p->setBrush(self ? QColor(0x95, 0xEC, 0x69) : QColor(0xFF, 0xFF, 0xFF)); p->setPen(Qt::NoPen); p->drawRoundedRect(bubble, 8, 8); p->setPen(QColor(0x22, 0x22, 0x22)); p->drawText(bubble.adjusted(12, 10, -12, -10), Qt::TextWordWrap, text); }sizeHint()要返回和paint()一致的高度,否则列表项会重叠或者留大片空白。self 通过自定义 role 传进来,自己发的靠右、对方发的靠左。用 QPainter 直接画比堆 QLabel 省内存,一千条消息的列表滚动也不会掉帧。Qt 绘图的效率差异主要来自重绘范围,QListView默认只重绘可见项,这也是不选 QTextEdit 的原因。
4.4 局域网文件传输的分块与进度
文件不走 JSON 正文,否则 base64 编码会膨胀三分之一。做法是先发一条 file_begin 控制帧,再按 64 KB 一块发二进制帧,最后发 file_end。接收端按 msgId 建临时文件追加写入,每块回一个 ack 或按累计字节数更新进度条。
| 参数 | 建议值 | 原因 |
|---|---|---|
| 分块大小 | 64 KB | 与多数网卡 MTU 和内核缓冲区匹配,吞吐较好 |
| 单帧上限 | 4 MB | 与拆包逻辑里的长度上限保持一致 |
| 并发传输数 | 2~3 | 再多会互相抢带宽,进度条也看不过来 |
| 断点续传粒度 | 1 MB | 记录已确认偏移,重连后从该位置继续 |
传输前先用QFileInfo拿到文件大小和修改时间发给对方,接收端校验剩余磁盘空间。发送端不要一次把整个文件读进内存,用QFile::read(65536)循环读,读一块发一块,同时用bytesWritten信号做流控,未确认的字节数超过 1 MB 就暂停读取。
5. 多端并发压测、典型故障定位与发布打包
5.1 断线重连与半开连接检测
前面提到 TCP 半开连接不会主动通知,靠心跳检测。实现上是每个 Peer 对象挂两个时间戳:lastSendHeartbeat和lastRecvAny。发送定时器每 5 秒发一次心跳,检查定时器每秒扫一遍,若now - lastRecvAny > 12s,就认为链路已断,调用abort()并触发重连。
重连要退避,不能固定 1 秒死循环。用一个成员变量记录失败次数,间隔取 1、2、4、8、16 秒封顶 30 秒,连上后清零。压测时可以用脚本同时启动 50 个客户端实例,观察是否有连接堆积在 SYN_SENT 状态;如果服务端 accept 队列满了,QTcpServer的maxPendingConnections默认是 30,超过部分会被内核丢弃,这时应该调大这个值并检查是否在 accept 后立刻读取了数据。
5.2 典型故障对照表
| 现象 | 常见原因 | 定位手段 |
|---|---|---|
| 编译报 unknown module(s) in Qt: serialport | 安装时未勾选对应组件 | 重跑安装器补勾,或检查 .pro 里的 QT 行 |
| 广播收不到任何报文 | 端口未用 ShareAddress 绑定,或防火墙拦截 UDP 45454 | 用ss -lun看绑定,临时关掉本机防火墙验证 |
| 收到消息但界面不刷新 | 跨线程信号未用 QueuedConnection | 检查 connect 第五个参数,槽函数里别直接碰 UI |
| 长消息被截断 | 拆包缓冲区被 clear,或长度字段字节序反了 | 打印每个帧的 len 和 buffer.size() 对比 |
| 程序发布后提示缺少 platform plugin | 插件目录未随程序拷贝 | 用 windeployqt 自动收集依赖 |
| 两台机器能发现不能连接 | 只允许一端主动连的规则写反 | 打印双方节点号,确认比较方向一致 |
5.3 Qt 发布软件打包与最后一步验证
Windows 上用 windeployqt 把 Qt 运行库和插件收集到输出目录,命令在构建目录下执行:
# 先 release 构建,再收集依赖 qmake && mingw32-make release windeployqt --release --no-translations --no-opengl-sw LanIM.exe # 检查生成结果,platforms 目录里必须有 qwindows.dll ls platforms--no-translations去掉多语言文件,--no-opengl-sw去掉软件渲染回退,如果程序用了 QML 或复杂动画就别加后面这个参数。Linux 下对应的做法是ldd检查依赖,或者用 linuxdeployqt 打成 AppImage;macOS 用 macdeployqt 生成 .app。发布前做一次干净机器验证:找一台没装过 Qt 的机器跑一遍,重点看节点发现、文本收发、文件传输三个入口,这比在本机反复调试更能暴露插件缺失的问题。
本文还有配套的精品资源,点击获取