
简介这是一份基于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_castquint32(payload.size()); buffer.append(payload); return buffer; } inline QListQByteArray decodeBuffer(QByteArray buffer) { QListQByteArray frames; while (buffer.size() static_castint(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_castint(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: QListQTcpSocket* m_clients; QHashQTcpSocket*, 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_bufferA 连接的尾包会污染 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-16QJsonDocument读写默认 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。服务端在上线时广播typenotice下线时也要广播。客户端槽函数里根据 notice 更新QListWidgetvoid 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 通道传文件消息加 typefile配 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 两个端口。本文还有配套的精品资源点击获取