简介:这是一份面向计算机、软件工程及通信工程专业本科生的计算机网络课程设计实践资源,基于Qt框架与C++语言实现完整的客户端-服务器即时通讯系统,覆盖TCP通信、多线程处理、用户在线状态管理、MySQL数据库集成等核心网络编程技能,特别适合作为课程设计或毕业设计参考方案。压缩包共160个文件,含17个核心cpp源码、15个头文件(h)、6个UI界面设计文件、5个可执行exe程序、42个Qt多语言翻译文件(qm)及60个运行依赖dll,另有课程设计报告(docx)、演示视频(mp4)、数据库配置(db)与日志配置(ini)等配套材料,整体大小为66.58MB。目前已有444人学习下载。读者可直接运行调试完整通信流程,复现登录认证、消息收发、在线列表刷新等关键功能,并结合附带的课程设计报告深入理解系统架构设计与实现逻辑,还可参考autosave备份文件对比开发过程中的代码演进思路。
1. 为什么用 Qt C++ 做即时通讯系统,是计算机网络课设最稳的“血泪选择”?
不是所有课设都值得熬夜——但这个是。当你在《计算机网络》课程设计选题表里看到「实现一个具备登录、好友列表、点对点消息收发、群聊基础功能的即时通讯系统」时,别急着翻 GitHub 找 Java Web 或 Python Flask 的轮子。真正能让你把 TCP/UDP、socket 编程、多线程、协议设计、心跳保活、粘包拆包这些核心知识点全串起来落地的,恰恰是 Qt C++ 这个看似“老派”实则极其扎实的技术栈。
它不依赖 Web 服务器或云服务,所有通信逻辑跑在本地局域网(甚至单机模拟),你亲手写QTcpSocket连接、QThread管理收发、QByteArray处理二进制协议、QJsonDocument序列化消息体——每一行代码都在回应谢希仁教材第 5 章“运输层”的每一个定义。更关键的是:它能编译成单个.exe文件,双击即运行,演示视频里 30 秒完成登录+发消息+对方弹窗,老师当场打分。我带过三届课设,凡是用 Qt C++ 完整跑通 TCP 双向通信 + 心跳检测 + 消息持久化(哪怕只存到本地 txt)的同学,报告里“实验原理”和“问题分析”章节自然就有料,答辩时被问“怎么解决粘包?”能掏出自己写的QDataStream封装类,而不是背书。
适合谁?大二下或大三上刚学完《计算机网络》+《C++ 程序设计》的同学;不想调通 Spring Boot 依赖冲突、也不愿啃 Node.js 异步回调地狱的务实派;需要一份可展示、可调试、可讲清每层原理的硬核作品。这不是炫技,是把课本里的“可靠传输”四个字,变成你电脑右下角那个正在闪烁的聊天窗口。
2. 从零搭起通信骨架:TCP 服务端与客户端的 Qt C++ 实现
2.1 为什么选 TCP 而非 UDP?——课设场景下的理性取舍
课程设计明确要求“消息可靠送达”,且不涉及高并发百万连接。此时强行上 UDP + 自研 ACK 机制,等于主动给自己加考题:你要重写超时重传、滑动窗口、乱序重组——这已超出课设范围,属于《网络编程》高阶实验。而 TCP 天然提供可靠、有序、无重复的字节流,Qt 封装的QTcpServer和QTcpSocket接口清晰,错误码明确(如QAbstractSocket::RemoteHostClosedError),调试时抓包(Wireshark)能看到完整的三次握手与 FIN 四次挥手,完美对应教材图 5-12 和 5-14。
提示:课设报告中“协议选择依据”一节,直接引用 RFC 793 中关于 TCP 面向连接、可靠交付的描述,并对比 UDP 的“尽最大努力交付”,比空谈“UDP 更快”更有说服力。
2.2 服务端:用 QTcpServer 监听连接并管理会话
核心逻辑是监听端口、接受新连接、为每个客户端创建独立 socket 并绑定信号槽。注意:不能在主线程直接waitForReadyRead()阻塞等待,必须用信号驱动,否则 GUI 冻结。
// server.h #include <QTcpServer> #include <QTcpSocket> #include <QMap> #include <QMutex> class ChatServer : public QTcpServer { Q_OBJECT public: explicit ChatServer(QObject *parent = nullptr); void startServer(quint16 port); signals: void clientConnected(const QString &ip, quint16 port); void messageReceived(const QString &from, const QString &content); private slots: void onNewConnection(); // 新连接到来 void onClientDisconnected(); // 客户端断开 void onClientReadyRead(); // 客户端发来数据 private: QMap<QTcpSocket*, QString> m_clients; // socket -> 用户名(登录后设置) QMutex m_mutex; };// server.cpp #include "server.h" #include <QJsonDocument> #include <QJsonObject> #include <QDebug> ChatServer::ChatServer(QObject *parent) : QTcpServer(parent) {} void ChatServer::startServer(quint16 port) { if (!this->listen(QHostAddress::Any, port)) { qCritical() << "Server failed to listen on port:" << port; return; } qDebug() << "Server listening on" << this->serverAddress().toString() << ":" << port; } void ChatServer::onNewConnection() { QTcpSocket *clientSocket = this->nextPendingConnection(); connect(clientSocket, &QTcpSocket::disconnected, this, &ChatServer::onClientDisconnected); connect(clientSocket, &QTcpSocket::readyRead, this, &ChatServer::onClientReadyRead); // 发送欢迎消息(JSON 格式) QJsonObject welcome; welcome["type"] = "welcome"; welcome["msg"] = "Connected to chat server. Please login."; QJsonDocument doc(welcome); clientSocket->write(doc.toJson()); emit clientConnected(clientSocket->peerAddress().toString(), clientSocket->peerPort()); } void ChatServer::onClientDisconnected() { QTcpSocket *socket = qobject_cast<QTcpSocket*>(sender()); if (!socket) return; m_mutex.lock(); QString username = m_clients.take(socket); m_mutex.unlock(); if (!username.isEmpty()) { qDebug() << "Client logged out:" << username; // 可广播下线通知 } socket->deleteLater(); } void ChatServer::onClientReadyRead() { QTcpSocket *socket = qobject_cast<QTcpSocket*>(sender()); if (!socket) return; QByteArray data = socket->readAll(); if (data.isEmpty()) return; // 解析 JSON 消息(此处简化,实际需处理粘包) QJsonParseError error; QJsonDocument doc = QJsonDocument::fromJson(data, &error); if (error.error != QJsonParseError::NoError) { qWarning() << "Invalid JSON from client:" << error.errorString(); return; } QJsonObject obj = doc.object(); QString type = obj["type"].toString(); if (type == "login") { QString username = obj["username"].toString(); if (username.length() < 2 || username.length() > 16) { sendError(socket, "Username must be 2-16 chars"); return; } m_mutex.lock(); bool exists = m_clients.values().contains(username); m_mutex.unlock(); if (exists) { sendError(socket, "Username already taken"); return; } m_mutex.lock(); m_clients[socket] = username; m_mutex.unlock(); // 广播上线 QJsonObject loginAck; loginAck["type"] = "login_ack"; loginAck["status"] = "success"; loginAck["username"] = username; socket->write(QJsonDocument(loginAck).toJson()); qDebug() << "User logged in:" << username; } else if (type == "message") { QString from = m_clients.value(socket, "unknown"); QString content = obj["content"].toString(); if (content.isEmpty()) return; // 存储到本地文件(课设加分项) QFile log("chat_log.txt"); if (log.open(QIODevice::Append | QIODevice::Text)) { QTextStream out(&log); out << QDateTime::currentDateTime().toString("[yyyy-MM-dd hh:mm:ss]") << " [" << from << "] " << content << "\n"; log.close(); } emit messageReceived(from, content); } } void ChatServer::sendError(QTcpSocket *socket, const QString &msg) { QJsonObject err; err["type"] = "error"; err["msg"] = msg; socket->write(QJsonDocument(err).toJson()); }参数说明与逻辑要点:
QTcpServer::listen()的QHostAddress::Any表示监听本机所有网卡,课设局域网测试足够;若需限制仅本机(如演示时防干扰),改用QHostAddress::LocalHost。m_clients用QMap而非QHash,因课设无需极致性能,QMap线程安全操作更直观(配合QMutex)。onClientReadyRead()中未做粘包处理——这是故意留的坑,将在第 4 章详解如何用QDataStream解决。此处先保证流程通,避免初学者被二进制解析劝退。- 日志写入
chat_log.txt是课设报告中“数据持久化”章节的硬证据,比空谈“可扩展数据库”更实在。
2.3 客户端:QTcpSocket 连接服务端并发送结构化消息
客户端需处理连接、登录、消息发送、接收响应四大状态。关键点在于:所有 socket 操作必须异步,用信号槽驱动,严禁waitForConnected()阻塞主线程(否则界面假死)。
// client.h #include <QTcpSocket> #include <QTimer> class ChatClient : public QObject { Q_OBJECT public: explicit ChatClient(QObject *parent = nullptr); void connectToServer(const QString &host, quint16 port); void login(const QString &username); void sendMessage(const QString &content); signals: void connected(); void disconnected(); void loginSuccess(const QString &username); void loginFailed(const QString &reason); void messageReceived(const QString &from, const QString &content); void serverMessage(const QString &msg); // 如 welcome private slots: void onConnected(); void onDisconnected(); void onReadyRead(); void onSocketError(QAbstractSocket::SocketError error); void onHeartbeatTimeout(); private: QTcpSocket *m_socket; QTimer *m_heartbeatTimer; QString m_username; bool m_isLoggedIn; };// client.cpp #include "client.h" #include <QJsonDocument> #include <QJsonObject> #include <QTimer> #include <QDebug> ChatClient::ChatClient(QObject *parent) : QObject(parent), m_socket(new QTcpSocket(this)), m_isLoggedIn(false) { connect(m_socket, &QTcpSocket::connected, this, &ChatClient::onConnected); connect(m_socket, &QTcpSocket::disconnected, this, &ChatClient::onDisconnected); connect(m_socket, &QTcpSocket::readyRead, this, &ChatClient::onReadyRead); connect(m_socket, static_cast<void (QTcpSocket::*)(QAbstractSocket::SocketError)>(&QTcpSocket::error), this, &ChatClient::onSocketError); // 心跳定时器:30秒发一次空包 m_heartbeatTimer = new QTimer(this); connect(m_heartbeatTimer, &QTimer::timeout, [this]() { if (m_socket->state() == QAbstractSocket::ConnectedState && m_isLoggedIn) { QJsonObject heartbeat; heartbeat["type"] = "heartbeat"; m_socket->write(QJsonDocument(heartbeat).toJson()); } }); } void ChatClient::connectToServer(const QString &host, quint16 port) { m_socket->connectToHost(host, port); } void ChatClient::onConnected() { qDebug() << "Connected to server"; emit connected(); // 启动心跳 m_heartbeatTimer->start(30000); } void ChatClient::onDisconnected() { qDebug() << "Disconnected from server"; m_heartbeatTimer->stop(); m_isLoggedIn = false; emit disconnected(); } void ChatClient::onReadyRead() { QByteArray data = m_socket->readAll(); if (data.isEmpty()) return; QJsonParseError error; QJsonDocument doc = QJsonDocument::fromJson(data, &error); if (error.error != QJsonParseError::NoError) { qWarning() << "Invalid JSON from server:" << error.errorString(); return; } QJsonObject obj = doc.object(); QString type = obj["type"].toString(); if (type == "welcome") { emit serverMessage(obj["msg"].toString()); } else if (type == "login_ack") { if (obj["status"].toString() == "success") { m_username = obj["username"].toString(); m_isLoggedIn = true; emit loginSuccess(m_username); } else { emit loginFailed(obj["msg"].toString()); } } else if (type == "message") { QString from = obj["from"].toString(); QString content = obj["content"].toString(); emit messageReceived(from, content); } else if (type == "error") { emit loginFailed(obj["msg"].toString()); } } void ChatClient::onSocketError(QAbstractSocket::SocketError error) { QString errorMsg; switch (error) { case QAbstractSocket::ConnectionRefusedError: errorMsg = "Server refused connection. Is it running?"; break; case QAbstractSocket::RemoteHostClosedError: errorMsg = "Server closed connection."; break; case QAbstractSocket::HostNotFoundError: errorMsg = "Server host not found."; break; default: errorMsg = "Network error: " + m_socket->errorString(); } emit loginFailed(errorMsg); } void ChatClient::login(const QString &username) { QJsonObject loginObj; loginObj["type"] = "login"; loginObj["username"] = username; m_socket->write(QJsonDocument(loginObj).toJson()); } void ChatClient::sendMessage(const QString &content) { if (!m_isLoggedIn || m_socket->state() != QAbstractSocket::ConnectedState) return; QJsonObject msgObj; msgObj["type"] = "message"; msgObj["content"] = content; m_socket->write(QJsonDocument(msgObj).toJson()); }关键设计解析:
- 心跳机制 (
m_heartbeatTimer):30 秒发一次{"type":"heartbeat"},服务端收到即刷新该 socket 的活跃时间。若连续 2 次未收到(即 60 秒),服务端主动close()连接。这是课设中体现“连接可靠性”的黄金细节,报告里可画状态转换图(Connected → Heartbeat Timeout → Disconnected)。 - 错误分类处理:
QAbstractSocket::ConnectionRefusedError对应服务端未启动;HostNotFoundError对应 IP 输错;RemoteHostClosedError对应服务端崩溃。每种错误给出具体提示,比笼统“连接失败”更专业。 - 登录状态隔离:
m_isLoggedIn标志位确保只有登录成功后才能发消息,避免未登录用户乱发数据污染服务端日志。
3. 让界面活起来:Qt Widgets 实现登录页、主聊天窗与消息气泡
3.1 登录窗口:QDialog 模态对话框 + 表单验证
课设演示视频第一帧就是登录页,它必须简洁、响应快、有基本校验。用QDialog而非QWidget,因其天然支持模态(exec()阻塞后续操作,直到登录成功或取消),符合用户心智模型。
// loginwindow.h #include <QDialog> #include <QLineEdit> #include <QPushButton> #include <QVBoxLayout> #include <QLabel> #include <QMessageBox> class LoginWindow : public QDialog { Q_OBJECT public: explicit LoginWindow(QWidget *parent = nullptr); QString getUsername() const { return m_usernameEdit->text().trimmed(); } QString getServerIp() const { return m_ipEdit->text().trimmed(); } quint16 getServerPort() const { return m_portEdit->text().toUShort(); } signals: void loginRequested(const QString &username, const QString &ip, quint16 port); private slots: void onLoginClicked(); private: QLineEdit *m_usernameEdit; QLineEdit *m_ipEdit; QLineEdit *m_portEdit; QPushButton *m_loginBtn; };// loginwindow.cpp #include "loginwindow.h" #include <QFormLayout> #include <QHBoxLayout> #include <QValidator> LoginWindow::LoginWindow(QWidget *parent) : QDialog(parent) { setWindowTitle("Chat System - Login"); setFixedSize(350, 200); auto *layout = new QVBoxLayout(this); auto *formLayout = new QFormLayout(); m_usernameEdit = new QLineEdit; m_usernameEdit->setPlaceholderText("Enter username (2-16 chars)"); m_usernameEdit->setValidator(new QRegularExpressionValidator(QRegularExpression("^[a-zA-Z0-9_]{2,16}$"), this)); formLayout->addRow("Username:", m_usernameEdit); m_ipEdit = new QLineEdit; m_ipEdit->setText("127.0.0.1"); // 默认本地 m_ipEdit->setPlaceholderText("Server IP (e.g., 192.168.1.100)"); formLayout->addRow("Server IP:", m_ipEdit); m_portEdit = new QLineEdit; m_portEdit->setText("8080"); m_portEdit->setValidator(new QIntValidator(1024, 65535, this)); // 限制端口范围 formLayout->addRow("Port:", m_portEdit); layout->addLayout(formLayout); auto *btnLayout = new QHBoxLayout(); m_loginBtn = new QPushButton("Login"); connect(m_loginBtn, &QPushButton::clicked, this, &LoginWindow::onLoginClicked); btnLayout->addStretch(); btnLayout->addWidget(m_loginBtn); btnLayout->addStretch(); layout->addLayout(btnLayout); } void LoginWindow::onLoginClicked() { QString username = getUsername(); QString ip = getServerIp(); quint16 port = getServerPort(); if (username.isEmpty()) { QMessageBox::warning(this, "Input Error", "Username cannot be empty!"); return; } if (ip.isEmpty()) { QMessageBox::warning(this, "Input Error", "Server IP cannot be empty!"); return; } if (port == 0) { QMessageBox::warning(this, "Input Error", "Invalid port number!"); return; } emit loginRequested(username, ip, port); // 不关闭窗口,由主窗口控制(登录成功后隐藏) }为什么用QRegularExpressionValidator?
- 课设报告中“安全性设计”章节可写:通过正则
^[a-zA-Z0-9_]{2,16}$限制用户名仅含字母、数字、下划线,长度 2-16,杜绝 SQL 注入式攻击(虽然后端无 DB,但体现安全意识)。 QIntValidator限定端口 1024-65535,避开系统保留端口(0-1023),避免学生填 80 导致权限错误。
3.2 主聊天窗口:QMainWindow 架构 + 消息气泡布局
主窗口采用标准QMainWindow,包含菜单栏(退出)、工具栏(在线用户)、中心部件(消息列表 + 输入框)。消息显示用QListWidget,但关键技巧是自定义QListWidgetItem的 delegate,实现左右气泡(自己发的消息靠右,别人发的靠左)。
// chatwindow.h #include <QMainWindow> #include <QListWidget> #include <QTextEdit> #include <QPushButton> #include <QMenuBar> #include <QAction> #include <QStatusBar> #include <QVBoxLayout> #include <QWidget> #include <QLabel> #include <QTimer> class ChatWindow : public QMainWindow { Q_OBJECT public: explicit ChatWindow(const QString &username, QWidget *parent = nullptr); void addMessage(const QString &from, const QString &content, bool isSelf = false); void setOnlineCount(int count); signals: void sendMessage(const QString &content); void logoutRequested(); private slots: void onSendClicked(); void onReturnPressed(); private: QListWidget *m_messageList; QTextEdit *m_inputEdit; QPushButton *m_sendBtn; QLabel *m_onlineLabel; QString m_username; QTimer *m_statusTimer; };// chatwindow.cpp #include "chatwindow.h" #include <QVBoxLayout> #include <QHBoxLayout> #include <QLabel> #include <QFont> #include <QDateTime> #include <QPainter> #include <QStyleOption> #include <QApplication> // 自定义消息气泡委托 class MessageDelegate : public QStyledItemDelegate { public: MessageDelegate(QObject *parent = nullptr) : QStyledItemDelegate(parent) {} void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override { QStyleOptionViewItem opt = option; initStyleOption(&opt, index); // 获取消息数据(格式:"[2023-10-01 14:23] Alice: Hello!" QString text = index.data(Qt::DisplayRole).toString(); bool isSelf = index.data(Qt::UserRole).toBool(); // 自定义角色存是否自己发的 painter->save(); // 绘制背景气泡 QRect bubbleRect = opt.rect.adjusted(10, 5, -10, -5); if (isSelf) { // 自己的消息:右对齐,蓝色背景 bubbleRect.moveRight(opt.rect.right() - 10); painter->fillRect(bubbleRect, QColor("#4CAF50")); // Material Green } else { // 他人的消息:左对齐,灰色背景 bubbleRect.moveLeft(opt.rect.left() + 10); painter->fillRect(bubbleRect, QColor("#E0E0E0")); } // 绘制文字 painter->setPen(isSelf ? Qt::white : Qt::black); painter->setFont(QFont("Segoe UI", 10)); painter->drawText(bubbleRect, Qt::AlignVCenter | Qt::AlignHCenter, text); painter->restore(); } QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override { QString text = index.data(Qt::DisplayRole).toString(); QFontMetrics fm(QApplication::font()); int width = qMin(400, fm.horizontalAdvance(text) + 40); // 最大宽度400 int height = fm.height() + 10; return QSize(width, height); } }; ChatWindow::ChatWindow(const QString &username, QWidget *parent) : QMainWindow(parent), m_username(username) { setWindowTitle("Chat - " + username); resize(600, 500); // 创建中心部件 QWidget *centralWidget = new QWidget; QVBoxLayout *mainLayout = new QVBoxLayout(centralWidget); // 消息列表 m_messageList = new QListWidget; m_messageList->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); m_messageList->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); m_messageList->setWordWrap(true); m_messageList->setItemDelegate(new MessageDelegate(m_messageList)); // 输入框和发送按钮 m_inputEdit = new QTextEdit; m_inputEdit->setFixedHeight(80); m_inputEdit->setPlaceholderText("Type your message here..."); connect(m_inputEdit, &QTextEdit::returnPressed, this, &ChatWindow::onReturnPressed); m_sendBtn = new QPushButton("Send"); connect(m_sendBtn, &QPushButton::clicked, this, &ChatWindow::onSendClicked); QHBoxLayout *inputLayout = new QHBoxLayout(); inputLayout->addWidget(m_inputEdit); inputLayout->addWidget(m_sendBtn); mainLayout->addWidget(m_messageList); mainLayout->addLayout(inputLayout); setCentralWidget(centralWidget); // 状态栏显示在线人数 m_onlineLabel = new QLabel("Online: 0"); statusBar()->addWidget(m_onlineLabel); // 菜单栏 QMenu *fileMenu = menuBar()->addMenu("File"); QAction *logoutAct = fileMenu->addAction("Logout"); connect(logoutAct, &QAction::triggered, this, &ChatWindow::logoutRequested); QAction *exitAct = fileMenu->addAction("Exit"); connect(exitAct, &QAction::triggered, qApp, &QApplication::quit); // 状态栏自动更新时间 m_statusTimer = new QTimer(this); connect(m_statusTimer, &QTimer::timeout, [this]() { statusBar()->showMessage("Last updated: " + QDateTime::currentDateTime().toString("hh:mm:ss")); }); m_statusTimer->start(1000); } void ChatWindow::addMessage(const QString &from, const QString &content, bool isSelf) { QString timeStr = QDateTime::currentDateTime().toString("[hh:mm:ss]"); QString displayText = timeStr + " " + (isSelf ? "You" : from) + ": " + content; QListWidgetItem *item = new QListWidgetItem(displayText); item->setData(Qt::UserRole, isSelf); // 存储是否自己发的 m_messageList->addItem(item); m_messageList->scrollToBottom(); } void ChatWindow::setOnlineCount(int count) { m_onlineLabel->setText(QString("Online: %1").arg(count)); } void ChatWindow::onSendClicked() { QString text = m_inputEdit->toPlainText().trimmed(); if (!text.isEmpty()) { addMessage(m_username, text, true); emit sendMessage(text); m_inputEdit->clear(); } } void ChatWindow::onReturnPressed() { if (QApplication::keyboardModifiers() & Qt::ShiftModifier) { // Shift+Enter 换行 m_inputEdit->insertPlainText("\n"); } else { onSendClicked(); } }气泡实现的核心技巧:
QStyledItemDelegate::paint()中,通过index.data(Qt::UserRole)获取消息归属(自己 or 他人),动态计算bubbleRect的位置(左/右对齐)和颜色。sizeHint()控制每条消息高度自适应,宽度限制 400px 防止长消息撑爆界面。onReturnPressed()支持Shift+Enter 换行、Enter 发送,这是专业 IM 的标配交互,演示时老师会注意到。
3.3 主程序入口:串联登录、客户端、主窗口的生命周期
main.cpp是课设的“总控台”,负责顺序启动:登录窗口 → 连接服务端 → 登录成功 → 显示主窗口 → 断开时清理资源。
// main.cpp #include <QApplication> #include <QMessageBox> #include "loginwindow.h" #include "chatwindow.h" #include "client.h" int main(int argc, char *argv[]) { QApplication app(argc, argv); LoginWindow loginWin; ChatWindow *chatWin = nullptr; ChatClient *client = nullptr; // 登录成功后创建客户端和主窗口 QObject::connect(&loginWin, &LoginWindow::loginRequested, [&loginWin, &chatWin, &client](const QString &username, const QString &ip, quint16 port) { // 创建客户端 client = new ChatClient(&loginWin); QObject::connect(client, &ChatClient::connected, [&loginWin, &chatWin, &client, username]() { // 连接成功,显示主窗口 chatWin = new ChatWindow(username); chatWin->show(); loginWin.hide(); // 绑定信号 QObject::connect(client, &ChatClient::loginSuccess, chatWin, &ChatWindow::setWindowTitle); QObject::connect(client, &ChatClient::loginFailed, [&loginWin](const QString &reason) { QMessageBox::critical(&loginWin, "Login Failed", reason); loginWin.show(); }); QObject::connect(client, &ChatClient::messageReceived, chatWin, &ChatWindow::addMessage); QObject::connect(chatWin, &ChatWindow::sendMessage, client, &ChatClient::sendMessage); QObject::connect(chatWin, &ChatWindow::logoutRequested, client, &ChatClient::disconnectFromHost); // 发送登录请求 client->login(username); }); QObject::connect(client, &ChatClient::disconnected, [&loginWin, &chatWin, &client]() { if (chatWin) { chatWin->close(); delete chatWin; chatWin = nullptr; } if (client) { delete client; client = nullptr; } loginWin.show(); }); // 开始连接 client->connectToServer(ip, port); }); loginWin.show(); return app.exec(); }资源管理要点:
client和chatWin在main()作用域内声明,确保生命周期可控;delete操作放在disconnected信号槽中,避免野指针。loginWin.hide()而非close(),因为登录窗口对象仍需存在以响应重连。- 所有
connect()使用 Lambda 捕获局部变量,比传统SLOT()更安全(避免对象销毁后信号触发崩溃)。
4. 粘包与心跳:解决 TCP 通信中两个最经典的“玄学”问题
4.1 为什么粘包必然发生?——从 TCP 字节流本质理解
很多同学第一次看到readyRead()里读出的数据既不像一条完整消息,又混着两条消息的开头,就慌了:“是不是我的 JSON 解析错了?” 其实这是 TCP 的固有特性:它提供的是无边界的字节流,而非消息队列。send()调用可能将多个小包合并(Nagle 算法),也可能将一个大包拆分(MTU 限制)。Qt 的QTcpSocket只是封装了底层 socket,无法改变这一事实。
提示:课设报告中“问题分析”章节,画一张简图:左边
send("A")、send("B"),右边readyRead()读到"AB"(粘包)或"A...B"(半包),标注“TCP 不保证应用层消息边界”。
4.2 用 QDataStream 实现可靠消息帧:长度前缀 + 协议头
解决方案是应用层定义消息边界。最简单可靠的方式是:每个消息前加 4 字节长度字段(quint32),再跟消息体(JSON 字符串)。QDataStream天然支持大小端和类型安全序列化,比手动memcpy更安全。
// 修改 server.cpp 的 onClientReadyRead() void ChatServer::onClientReadyRead() { QTcpSocket *socket = qobject_cast<QTcpSocket*>(sender()); if (!socket) return; // 使用 QDataStream 处理粘包 QDataStream in(socket); in.setVersion(QDataStream::Qt_5_15); while (true) { if (m_pendingDataSize == 0) { // 还未读取长度头 if (socket->bytesAvailable() < sizeof(quint32)) break; in >> m_pendingDataSize; } if (socket->bytesAvailable() < m_pendingDataSize) break; // 数据不全,等待下次 readyRead // 数据完整,读取消息体 QByteArray data; data.resize(m_pendingDataSize); in.readRawData(data.data(), m_pendingDataSize); // 解析 JSON QJsonParseError error; QJsonDocument doc = QJsonDocument::fromJson(data, &error); if (error.error != QJsonParseError::NoError) { qWarning() << "Invalid JSON:" << error.errorString(); m_pendingDataSize = 0; // 重置,丢弃错误包 continue; } // ... 后续处理逻辑(login/message 等) m_pendingDataSize = 0; // 重置 } }// 修改 client.cpp 的 sendMessage() void ChatClient::sendMessage(const QString &content) { if (!m_isLoggedIn || m_socket->state() != QAbstractSocket::ConnectedState) return; QJsonObject msgObj; msgObj["type"] = "message"; msgObj["content"] = content; QJsonDocument doc(msgObj); QByteArray jsonData = doc.toJson(); // 发送:4字节长度 + JSON数据 QByteArray packet; QDataStream out(&packet, QIODevice::WriteOnly); out.setVersion(QDataStream::Qt_5_15); out << (quint32)jsonData.size(); // 写入长度 out.writeRawData(jsonData.data(), jsonData.size()); // 写入数据 m_socket->write(packet); }关键参数与逻辑:
m_pendingDataSize是服务端成员变量(quint32),用于暂存当前待读取的消息长度。必须是成员变量,不能是局部变量,因为readyRead()可能分多次触发。QDataStream::setVersion()必须与客户端一致(推荐 Qt_5_15),否则大小端或类型长度不匹配导致读错。in.readRawData()直接读取原始字节,不经过文本编码,确保 JSON 的\uXXXX等转义正确。- 循环
while(true)是为了处理一次readyRead()中可能包含的多个完整消息(如网络抖动导致多个包合并到达)。
4.3 心跳包的双向验证:不只是“发包”,更要“收包反馈”
很多课设只实现了客户端发心跳,但服务端收到后不做任何响应,这无法验证连接真实性。真正的健壮性体现在:服务端收到心跳后,必须立即回一个{"type":"pong"},客户端收到pong才认为连接健康。
// server.cpp 中添加 pong 响应 void ChatServer::onClientReadyRead() { // ... 原有解析逻辑 ... if (type == "heartbeat") { // 立即回复 pong,证明服务端存活 QJsonObject pong; pong["type"] = "pong"; socket->write(QJsonDocument(pong).toJson()); return; // 不进入后续逻辑 } // ... 其余逻辑 }// client.cpp 中添加 pong 处理 void ChatClient::onReadyRead() { // ... 原有解析逻辑 ... if (type == "pong") { // 收到服务端响应,重置心跳超时计数器 m_heartbeatTimer->start(30000); return; } // ... 其余逻辑 }为什么需要双向?
- 单向心跳只能证明“客户端能发包”,但无法确认“服务端能收包且正常处理”。
- 若服务端进程卡死(如死循环),客户端仍会持续发心跳,却收不到
pong,
本文还有配套的精品资源,点击获取