简介:这是一份基于C++与Qt框架开发的局域网聊天软件完整工程,针对局域网环境下的实时通信需求,采用C/S模式拆分了登录注册、好友列表、聊天主窗口、TCP服务端等模块。项目适合正在做课程设计、毕业设计,或希望进阶Qt网络编程的开发者参考学习。压缩包共146个文件,大小约4.53MB,以cpp/h源码、ui界面文件、pro工程配置为主,另含可执行文件、编译生成的目标文件及数据库文件,便于直接查看工程结构和程序运行效果。已有362人学习下载。从中可以系统看到QTcpSocket/QUdpSocket通信、多线程界面刷新、信号槽、QDataStream序列化消息、数据库存取等关键点的落地写法;同时登录窗口、好友列表和聊天窗口的设计也为完整客户端-服务器应用提供了现成样板,并可据此快速二次开发或迁移到其他平台。
1. 局域网聊天在 Qt 里不是画个窗口那么简单
产线机房里不能上外网,管理员和工艺员要实时沟通,消息还要自动写进工单系统。第一反应是装个飞秋,可飞秋不开放消息记录,接不了 AD 域登录。于是我用 Qt 做了一个局域网聊天工具,核心只有两个类:一个 TCP 服务端做消息中转,一个带聊天界面的客户端,再加上 UDP 广播做自动发现。很多人觉得这属于重复造轮子,但当你需要把消息和 PLC 报警关联、或者把聊天记录落库时,自己写反而最省事。下面直接从通信模型开始,到服务端、客户端、在线状态和落地验证,按可复现的顺序走一遍。
2. 通信模型先行:TCP 长连接、UDP 广播与 Qt 消息帧
2.1 为什么局域网聊天优先选 TCP 长连接而不是 UDP
局域网聊天常见两种方案。UDP 写起来简单,QUdpSocket::writeDatagram一个函数就能发出去,但聊天场景下消息一旦分片丢失,对端没有重传保证,消息顺序也要应用层自己编号。TCP 虽然要维护连接状态,可QTcpSocket把重传、顺序、流量控制都做掉了,消息内容不容易丢。因此“聊天消息”走 TCP,“服务发现”走 UDP,各司其职。
| 维度 | TCP 长连接 | UDP 单播/广播 |
|---|---|---|
| 可靠性 | 内核重传,丢包自动恢复 | 丢包无感知,需业务层确认 |
| 消息顺序 | 保证字节流有序 | 应用层需要编号 |
| 连接状态 | 连接/断线信号明确 | 无法直接知道对方还在不在 |
| 适用部分 | 聊天消息、文件传输 | 广播服务名、在线探测 |
局域网丢包率不高,但产线里电磁干扰、交换机端口重启都可能导致 TCP 瞬时断流,所以应用层心跳不能省。后面第 5 章会专门讲心跳怎么设。这里先定一个原则:所有业务消息统一走长连接,UDP 只负责让客户端找到服务端 IP。
2.2 用 4 字节长度头解决粘包:封装 Qt 消息帧
QTcpSocket是流式协议,一次readyRead可能收到半条消息,也可能收到好几条。最常见做法是“4 字节长度头 + JSON 体”。长度头固定 4 字节,大小端双方约定一致。我把编解码单独放到一个头文件里,服务端和客户端共用。
// MessageCodec.h #pragma once #include <QByteArray> #include <QDataStream> #include <QJsonDocument> #include <QJsonObject> namespace Chat { inline QByteArray encode(const QJsonObject &obj) { const QByteArray payload = QJsonDocument(obj).toJson(QJsonDocument::Compact); QByteArray buffer; QDataStream stream(&buffer, QIODevice::WriteOnly); stream.setVersion(QDataStream::Qt_6_5); stream.setByteOrder(QDataStream::LittleEndian); stream << static_cast<quint32>(payload.size()); buffer.append(payload); return buffer; } inline QList<QByteArray> decodeBuffer(QByteArray &buffer) { QList<QByteArray> frames; while (buffer.size() >= static_cast<int>(sizeof(quint32))) { QDataStream head(buffer); head.setVersion(QDataStream::Qt_6_5); head.setByteOrder(QDataStream::LittleEndian); quint32 len = 0; head >> len; const quint32 total = sizeof(quint32) + len; if (buffer.size() < static_cast<int>(total)) { break; } frames.append(buffer.mid(sizeof(quint32), len)); buffer.remove(0, total); } return frames; } }encode先写 4 字节长度,再写 UTF-8 JSON 数据。QDataStream的整数编码会受版本影响,这里固定Qt_6_5,服务端和客户端用同一个头文件就不会对不上。
decodeBuffer接收的是一个QByteArray&引用,内部处理完会消费掉已解析的数据。循环条件是缓冲区至少还有 4 字节,先看长度头,如果整个帧还没到齐就跳出,等下一次readyRead继续解析。这个函数天然解决了拆包问题。
有一点要注意:如果服务端和客户端用的是不同语言,或将来要接 Python 网关,QDataStream默认的字节序可能对不上。建议在协议文档里写明 Little Endian,或者直接改成手动写char[4]长度头。局域网内全 Qt 项目用QDataStream是最省事的。
3. 服务端落地:QTcpServer 监听与连接广播
3.1 用 QTcpServer 监听 9000 端口的最小服务端
先做一个能收消息、能广播消息的服务端。端口选 9000,避开 80、443 和 SSH 的常用端口。监听地址用QHostAddress::AnyIPv4,不要直接写Any,否则双栈环境下 IPv4 和 IPv6 可能同时触发回调,造成重复上线通知。
#include <QTcpServer> #include <QTcpSocket> #include <QJsonObject> #include <QJsonDocument> #include <QHash> #include <QList> #include "MessageCodec.h" class ChatServer : public QTcpServer { Q_OBJECT public: explicit ChatServer(QObject *parent = nullptr) : QTcpServer(parent) { connect(this, &QTcpServer::newConnection, this, &ChatServer::dispatchConnection); } public slots: bool startListen(quint16 port = 9000) { return listen(QHostAddress::AnyIPv4, port); } private slots: void dispatchConnection() { while (hasPendingConnections()) { QTcpSocket *client = nextPendingConnection(); m_clients.append(client); m_buffers.insert(client, QByteArray()); connect(client, &QTcpSocket::readyRead, this, [this, client]() { onReadyRead(client); }); connect(client, &QTcpSocket::disconnected, this, [this, client]() { handleDisconnected(client); }); QJsonObject sys; sys.insert("type", "notice"); sys.insert("text", QString("%1:%2 上线") .arg(client->peerAddress().toString()) .arg(client->peerPort())); broadcast(Chat::encode(sys)); } } void onReadyRead(QTcpSocket *client) { m_buffers[client].append(client->readAll()); const auto frames = Chat::decodeBuffer(m_buffers[client]); for (const QByteArray &frame : frames) { const QJsonObject obj = QJsonDocument::fromJson(frame).object(); if (obj.value("type") == "chat") { QJsonObject out; out.insert("type", "chat"); out.insert("from", obj.value("from").toString()); out.insert("text", obj.value("text").toString()); out.insert("time", QDateTime::currentDateTime().toString(Qt::ISODate)); broadcast(Chat::encode(out)); } } } void broadcast(const QByteArray &frame) { for (QTcpSocket *c : m_clients) { c->write(frame); } } void handleDisconnected(QTcpSocket *client) { if (!m_clients.removeAll(client)) { return; } m_buffers.remove(client); client->deleteLater(); } private: QList<QTcpSocket*> m_clients; QHash<QTcpSocket*, QByteArray> m_buffers; };startListen返回的 bool 可以直接在主函数里判断,如果失败就弹QMessageBox::critical并退出。QHostAddress::AnyIPv4在这里绑定了本机所有 IPv4 地址,产线上常见的 192.168.1.10、10.20.30.40 都能被客户端连到。
newConnection信号里面用while (hasPendingConnections())而不是只取一个,是因为系统 backlog 可能一次排了多个连接,只取一个的话剩下的会一直排着,甚至延迟到下一次事件循环才触发。
m_buffers是每个 socket 独立的收包缓存,m_clients保存所有存活连接。上线的系统通知也走broadcast,这样所有客户端都能在自己的消息列表里看到“谁上线了”,不需要额外写一套通知逻辑。
3.2 半包处理:每个 socket 独立 buffer,而不是在 readyRead 里直接解析
服务端最容易出的问题是直接在readyRead里把socket->readAll()读出来就解析。一次 recv 可能只收到半个包:长度头收到了,但 JSON 内容还在路上。如果这时候直接解析,要么报错,要么把下一个包的开头当内容。
所以代码里一定是先m_buffers[client].append(client->readAll()),再把整个未消费缓冲区交给decodeBuffer。decodeBuffer会自己判断长度够不够,不够就保留在m_buffers[client]里,等下一个readyRead继续追加。
每个 socket 独立缓存很重要。如果只有一个全局m_buffer,A 连接的尾包会污染 B 连接的数据。可以做一个压测验证:两个客户端同时发 1000 条随机长度消息,等全部收完再去比较消息条数和每个 uuid 是否完整。
3.3 广播转发的策略:回给发送者还是只发给其他人
上面的broadcast会把消息回给发送者自己。好处是客户端只需要在一个messageReceived槽里渲染所有消息,不用区分哪条是自己发的,本地延迟也更低。坏处是如果客户端自己有落库逻辑,可能和服务端的转发重复,需要靠uuid去重。
我的做法是消息里带uuid。客户端发送时生成QUuid::createUuid().toString(QUuid::WithoutBraces),服务端原样转发,客户端渲染前检查这个 uuid 是否处理过。这样即使服务端后续加了多节点转发,也不会重复显示。
后面如果要做私聊,JSON 里要加target字段,服务端判断target是room:xxx还是user:bob再选择转发集合。广播只适合群聊和小规模局域网,单聊一定要做白名单过滤。
4. 客户端收发包与聊天界面:把信号槽串起来
4.1 用 QTcpSocket 连接服务端并发送首条消息
客户端比服务端多一个 UI 层,所以我把网络收发单独放在ChatClient类里,用信号把消息抛给ChatWindow。这样窗口只负责渲染,socket 只负责收发,职责清楚。
#include <QTcpSocket> #include <QJsonObject> #include <QJsonDocument> #include <QUuid> #include "MessageCodec.h" class ChatClient : public QObject { Q_OBJECT public: ChatClient(const QString &host, quint16 port, QObject *parent = nullptr) : QObject(parent) , m_socket(new QTcpSocket(this)) { m_socket->connectToHost(host, port); connect(m_socket, &QTcpSocket::connected, this, [this]() { emit connectedToServer(); }); connect(m_socket, &QTcpSocket::readyRead, this, &ChatClient::onReadyRead); connect(m_socket, &QTcpSocket::errorOccurred, this, [this](QAbstractSocket::SocketError) { emit serverError(m_socket->errorString()); }); } void sendText(const QString &from, const QString &text) { QJsonObject obj; obj.insert("type", "chat"); obj.insert("from", from); obj.insert("text", text); obj.insert("uuid", QUuid::createUuid().toString(QUuid::WithoutBraces)); m_socket->write(Chat::encode(obj)); } signals: void connectedToServer(); void messageReceived(const QJsonObject &obj); void serverError(const QString &msg); private slots: void onReadyRead() { m_buffer.append(m_socket->readAll()); const auto frames = Chat::decodeBuffer(m_buffer); for (const QByteArray &frame : frames) { emit messageReceived(QJsonDocument::fromJson(frame).object()); } } private: QTcpSocket *m_socket; QByteArray m_buffer; };connectToHost是异步的,不能马上write。如果用户点发送时还没连接完成,write的数据也会进入 socket 发送缓冲区,TCP 会在连接建立后发出,但 UI 上应该提前置灰输入框,等connectedToServer信号再放开。
errorOccurred在 Qt 5.15 之后是推荐写法,老项目里叫error(QAbstractSocket::SocketError)。信号参数用了 lambda 包裹,避免槽函数签名不一致导致编译报错。
4.2 QTextEdit 与 QLineEdit 组合:聊天窗口最小布局
聊天窗口不需要很复杂的结构。一个QTextEdit显示历史消息,一个QLineEdit输入,一个QPushButton发送,再加一个QListWidget显示在线人员。下面是窗口构造函数里的关键部分。
ChatWindow::ChatWindow(QWidget *parent) : QWidget(parent) { auto *layout = new QVBoxLayout(this); auto *history = new QTextEdit(this); history->setReadOnly(true); history->setPlaceholderText("等待消息..."); auto *inputRow = new QHBoxLayout; auto *input = new QLineEdit(this); input->setPlaceholderText("输入消息,回车发送"); auto *sendBtn = new QPushButton("发送", this); layout->addWidget(history, 1); inputRow->addWidget(input, 1); inputRow->addWidget(sendBtn); layout->addLayout(inputRow); connect(input, &QLineEdit::returnPressed, this, [this, input]() { const QString text = input->text().trimmed(); if (!text.isEmpty()) { emit sendRequested(text); input->clear(); } }); connect(sendBtn, &QPushButton::clicked, this, [=]() { emit sendRequested(input->text().trimmed()); input->clear(); }); m_history = history; }returnPressed是QLineEdit的信号,按回车直接发送,符合聊天软件的操作习惯。发送前必须trimmed(),不然一句全空格消息也会被发出去。
历史消息可以用append追加 HTML,但用户输入内容必须做转义。看下面的槽:
void ChatWindow::appendMessage(const QJsonObject &obj) { const QString from = obj.value("from").toString(); const QString text = obj.value("text").toString(); const QString time = obj.value("time").toString(); m_history->append(QString("<b>%1</b> [%2]<br>%3") .arg(from, time, text.toHtmlEscaped())); }toHtmlEscaped()会把<、>、&转成实体,防止用户发一个<img>或大字号标签破坏整个聊天室的消息样式。局域网聊天虽然是自己人用,但这条不能省,否则任何人都能往别人界面上塞一串 HTML。
4.3 中文不乱码:UTF-8 是唯一约定
Qt 6 的QString在内部是 UTF-16,QJsonDocument读写默认 UTF-8。只要消息体都经过MessageCodec.h编解码,中文不会乱码。真正乱码通常来自源码文件和本地编码不一致。
Windows 上 Qt 5.15 项目如果源码文件保存成 GB2312,字符串字面量QString::fromLiteral或普通tr()都可能显示成乱码。建议 CMake 里给 MSVC 加/utf-8,或者把所有.cpp、.h存成 UTF-8 with BOM。
排查编码可以先用系统命令确认:
file --mime-encoding MessageCodec.cpp输出要是us-ascii或utf-8。如果是iso-8859-1,说明文件里存在非 UTF-8 字符,Qt 编译器可能按本地编码去解释。对应 toqt 国际化场景,界面文案统一用tr()包裹,再加载qt_zh_CN.qm,这样后续要切英文界面时不用改业务代码。
5. 局域网自动发现与在线状态维护
5.1 UDP 广播服务名:客户端不用手动填 IP
产线 DHCP 环境下 IP 经常变,让用户手填服务器 IP 不现实。常见做法是服务端向局域网广播一段 JSON,客户端收到后再去连 TCP 端口。我在服务端加一个QUdpSocket,每 2 秒广播一次。
// 服务端 QUdpSocket *m_discovery = new QUdpSocket(this); m_discovery->bind(45678, QUdpSocket::ShareAddress); QTimer *announceTimer = new QTimer(this); announceTimer->setInterval(2000); connect(announceTimer, &QTimer::timeout, this, [this]() { QJsonObject obj; obj.insert("type", "chat-server"); obj.insert("name", m_serverName); obj.insert("port", 9000); const QByteArray data = QJsonDocument(obj).toJson(QJsonDocument::Compact); m_discovery->writeDatagram(data, QHostAddress::Broadcast, 45678); }); announceTimer->start();QHostAddress::Broadcast就是255.255.255.255,广播只会在当前子网内传播,不会跨三层。客户端在45678端口 bind 后监听广播,解析出port字段再发起 TCP 连接。
一台机器有多个网卡时,默认广播只会走主路由对应的网卡。跨网段场景下要枚举QNetworkInterface::allAddresses(),对每个接口子网计算广播地址后逐个发送,但这就超出了局域网聊天的最小范围。
5.2 心跳:服务端每 5 秒检测一次,超时 3 次判离线
TCP 连接不主动发数据时,拔掉网线或关掉交换机端口,对端可能延迟很久才能感知。所以客户端要定时发一个{"type":"ping"},服务端记录每个 socket 的最后活跃时间。
心跳参数我一般按下面这组设:
| 项目 | 值 | 说明 |
|---|---|---|
| 客户端心跳间隔 | 5 秒 | 小于超时时间的三分之一 |
| 服务端检测间隔 | 5 秒 | 和心跳间隔一致 |
| 离线判定阈值 | 15 秒 | 连续 3 个心跳未收到 |
| UDP 广播间隔 | 2 秒 | 让新客户端快速发现服务端 |
服务端的检测定时器长这样:
QTimer *healthTimer = new QTimer(this); healthTimer->setInterval(5000); connect(healthTimer, &QTimer::timeout, this, [this]() { const qint64 now = QDateTime::currentMSecsSinceEpoch(); for (auto it = m_lastSeen.begin(); it != m_lastSeen.end(); ++it) { if (now - it.value() > 15000) { it.key()->abort(); } } }); healthTimer->start();abort()会立即关闭 socket,并触发disconnected信号,最终走进handleDisconnected统一清理。这里不要手动删除QTcpSocket,交给deleteLater更安全。
客户端发 ping 的代码很简单,就是一个定时器调用m_socket->write(Chat::encode(pingObj))。服务端收到任何类型消息时都可以顺手更新m_lastSeen,不一定要单独处理ping类型。
5.3 在线列表刷新:QListWidget 接收上线通知
在线列表用QListWidget就够了,几十个人的场景不需要引入QAbstractListModel。服务端在上线时广播type=notice,下线时也要广播。客户端槽函数里根据 notice 更新QListWidget:
void ChatWindow::updateOnlineList(const QString &name, bool online) { if (online) { auto *item = new QListWidgetItem(name, m_userList); item->setForeground(QBrush(QColor("#2ecc71"))); m_userList->addItem(item); } else { for (int i = 0; i < m_userList->count(); ++i) { if (m_userList->item(i)->text() == name) { delete m_userList->takeItem(i); break; } } } }在线列表的更新不用追求强一致。UDP 广播发现只是辅助,一旦 TCP 连接建立,后续 presence 消息全部走 TCP,这样可以避免 UDP 丢包造成的“明明在却显示离线”。
6. 从最小聊天到可用的局域网聊天工具:重连、文件传输与验证
6.1 断线重连:指数退避重试
产线上交换机重启很常见,客户端必须能自动恢复。重连间隔我习惯从 1 秒开始,翻倍递增,上限 30 秒:
void ChatClient::scheduleReconnect() { m_reconnectAttempt++; const int delay = qMin(1 << m_reconnectAttempt, 30); QTimer::singleShot(delay * 1000, this, [this]() { if (m_manualClose) return; m_socket->abort(); m_socket->connectToHost(m_host, m_port); }); }连接成功时要把m_reconnectAttempt重置为 0。如果用户主动退出聊天窗口,要设置m_manualClose = true,否则关窗口后定时器还会继续重连。
6.2 TCP 通道传文件:消息加 type=file,配 QProgressDialog
传输小文件可以在同一个 TCP 连接里做。先发一个file-meta帧说明文件名和大小,再分块发file-chunk帧。块大小用 64 KB,局域网内传输效率和网络占用比较平衡:
const int chunkSize = 64 * 1024; QProgressDialog progress("发送中", "取消", 0, int(file.size()), this); while (!file.atEnd()) { const QByteArray chunk = file.read(chunkSize); QJsonObject body; body.insert("type", "file-chunk"); body.insert("data", QString::fromLatin1(chunk.toBase64())); m_socket->write(Chat::encode(body)); progress.setValue(int(file.pos())); QCoreApplication::processEvents(); if (progress.wasCanceled()) break; }这种方案会引入 Base64,体积增加 33%,只适合 50 MB 以下的小文件。真要大文件,建议自定义二进制帧,直接用原始字节写入长度头后面,不要走 JSON。界面上的进度条如果不想用QProgressDialog,可以用QProgressBar自定义 QSS,把setRange(0, file.size())和setValue(file.pos())对应起来,效果一样。
6.3 验证方法:看日志和抓包,别靠肉眼看界面
聊天气泡显示正常不代表协议对。在客户端onReadyRead里加一行qDebug() << isinstance frame length and type,服务端广播前也打一条,对照消息 uuid 就能判断谁丢了包。
想要看得更细,用 Wireshark 抓 TCP 9000 端口,过滤tcp.port == 9000。本地多开客户端时,回环流量要选lo接口。Windows 上用netstat -ano | findstr :9000确认监听 IP 和端口没被防火墙挡住。三次握手都看不到,就先去检查服务端防火墙是否放行了 9000 和 45678 两个端口。
本文还有配套的精品资源,点击获取