简介:基于Qt框架的TCP网络编程示例工程,将客户端与服务器端功能整合于同一GUI程序中,使用QTcpSocket与QTcpServer实现连接建立、数据收发与状态反馈,适合正在学习Qt Network模块或需要快速搭建局域网通信界面的开发者参考。压缩包共13个文件,包含4个cpp源文件、3个h头文件、3个ui界面文件以及pro工程文件等,代码结构紧凑,界面与逻辑分离,便于阅读和二次修改。资源包仅12KB,体积轻量。已有494人下载学习。通过该工程,读者可以直观看到QTcpSocket的connectToHost、write、readyRead等关键API的调用方式,同时了解QTcpServer监听、接受连接的流程,以及如何在界面中实时刷新连接状态和收发消息。对于希望掌握Qt网络编程基础、快速入手TCP通信界面的开发者,这份源码具有较好的参考价值。
1. 一个界面同时跑通客户端和服务端:这个QT项目解决了什么
第一次用QT写网络程序的人,十有八九会被同一个问题卡住:服务端一个工程,客户端一个工程,调试时要在两个Qt Creator窗口之间来回切。这个Demo把客户端和服务端集成在一个可执行程序里,界面上一键切换,既能当服务端监听,也能当客户端连出去。它基于QT实现完整的TCP通信,UI和Socket网络逻辑都在这一个工程里,特别适合想搞懂TCP握手、粘包、心跳,又不想维护两个工程的人。如果你正在做局域网小工具——端口探测、文本互传、数据上报——拿它当壳子改,比从零搭快很多。
2. 技术选型与架构:从QTCPServer到QThread的信号槽链路
2.1 为什么选择QT生态:跨平台UI与Socket绑定的距离
传统的C++ TCP编程要自己管理socket句柄、阻塞accept、select模型,写着写着界面就卡住了。QT把socket封装成QTCPServer和QTcpSocket,配合事件循环,网络事件来了自动触发信号,不需要自己写线程去accept。对于需要界面显示收发记录、连接状态的工具类程序,QT的跨平台UI能做到一套代码跑三端。这个Demo没有用Socket原生的API,而是直接走QT的网络模块,这在QT项目里算常规操作。如果你以前写过WinSock或Linux socket,再来看QT这套封装,会觉得信号槽模型确实省心。
QT网络模块最大的价值是异步事件驱动。QTcpSocket继承自QIODevice,底层的事件循环会在数据到达时自动触发readyRead信号。也就是说,你不需要开一个收包线程去阻塞read,也不需要自己处理EAGAIN之类的错误码。信号槽机制天然适合做网络事件分发:连接建立、连接断开、有数据到、写缓冲空,都是明确的信号,槽函数在UI线程里执行。对于这个集成式通信Demo,这种模型让界面刷新和网络收发在逻辑上完全解耦。
跨平台方面,QT帮我们隐藏了socket层差异。Linux上默认的TCP_NODELAY设置、Windows上的SOCKET句柄管理,都由QT底层处理。你只要用QHostAddress、QAbstractSocket这些统一的类,代码就能在MinGW、MSVC、Clang之间切换。这个工程的高层架构就是建立在这一点上:界面层不认识什么FD_SETSIZE,只认QTcpSocket指针。
2.2 集成式进程模型:一份代码、两种启动模式
要在一个程序里同时容纳客户端和服务端,最常见的做法不是把两种逻辑硬塞进MainWindow,而是抽象出一个TcpManager类,里面持有QTCPServer和QTcpSocket两个成员。MainWindow只负责两件事:响应界面按钮,通知TcpManager启动某一端;接收TcpManager发来的信号,把状态和消息刷新到UI。
架构上分三层:
- 界面层:MainWindow、连接对话框、消息列表
- 逻辑层:TcpManager,负责监听、连接、收发
- 协议层:消息帧编解码器,负责处理粘包、半包和心跳
TcpManager内部可以放在一个QThread里跑,也可以依赖QT事件循环直接在GUI线程跑。对于Demo来说,直接在GUI线程跑不会有明显性能问题,但后续要做文件传输,就要考虑把网络IO扔到工作线程。常见做法是:TcpManager继承QObject,用moveToThread迁移到QThread,所有对外信号用队列连接。这个工程的主窗口有一个模式切换下拉框,选择服务端时禁用连接按钮,启用监听按钮;选择客户端时反过来,从视觉上就能看出当前这一端在做什么。
为什么有了QThread还要用信号槽?因为跨线程调用不能直接访问UI控件。工作线程里emit出来的信号,如果接收者是在主线程,QT会自动以队列连接方式投递,槽函数在主线程事件循环里执行,天然解决线程同步。我一般会把网络请求统一封装成invokeMethod调用,确保TcpManager的公共方法都在它所在的线程里执行,避免阻塞连接导致界面卡顿。
2.3 目录结构与代码骨架
这个工程采用qmake管理,目录结构大致是这样:
qt_tcp_demo/ ├── qt_tcp_demo.pro ├── main.cpp ├── mainwindow.h ├── mainwindow.cpp ├── tcpmanager.h ├── tcpmanager.cpp ├── protocol.h ├── protocol.cpp └── resources/ └── app.rcqt_tcp_demo.pro里需要network模块,这是很多新手容易漏的地方。如果忘了加QT += network,编译时会报一堆找不到QTCPServer的堆栈链接错误。main.cpp里用QApplication拉起事件循环,MainWindow是主入口,TcpManager负责网络逻辑,protocol放消息帧定义和编解码函数。我把线程相关的代码抽成了一个worker子类,后续如果要把收发逻辑独立到线程,直接扩展worker就行。
这套结构的好处是界面和网络完全解耦。测试时不需要打开界面,可以直接写一个QtTest调用TcpManager的listen和connectToHost方法,跑自动化验证。mainwindow里只关心用户动作和信号显示,不直接操作socket。对于要把这个Demo改成工具型产品的开发者,这个结构比把所有代码堆在mainwindow里好维护得多。
目录树里没有单独的thread类,是因为QThread在这里只是作为TcpManager的运行容器用,不需要再暴露给界面。你看到tcpmanager.cpp里如果用了moveToThread,那它的信号连接就要特别注意context,否则信号会变成直连,跨线程直接调用槽函数,轻则界面卡顿,重则崩溃。这个细节在后续改造时是第一个容易踩的坑。
3. 核心模块拆解:连接建立、消息收发与界面刷新的时序
3.1 服务端监听:QTCPServer的监听与连接接入
服务端启动入口是TcpManager::startServer,代码如下:
// tcpmanager.cpp void TcpManager::startServer(quint16 port) { if (m_server) { m_server->close(); delete m_server; } m_server = new QTcpServer(this); // 有新连接时,QT会发出newConnection信号 connect(m_server, &QTcpServer::newConnection, this, &TcpManager::onNewConnection); if (!m_server->listen(QHostAddress::Any, port)) { emit logMessage(tr("[SRV] 监听失败: %1") .arg(m_server->errorString())); return; } emit logMessage(tr("[SRV] 监听端口 %1 成功").arg(port)); } void TcpManager::onNewConnection() { QTcpSocket *socket = m_server->nextPendingConnection(); if (!socket) return; m_clientSockets.insert(socket); connect(socket, &QTcpSocket::readyRead, this, &TcpManager::onReadyRead); connect(socket, &QTcpSocket::disconnected, this, &TcpManager::onDisconnected); emit logMessage(tr("[SRV] 新连接: %1") .arg(socket->peerAddress().toString())); }逻辑说明:listen的第二个参数是端口号,QHostAddress::Any表示监听所有网卡地址,这样局域网内其他机器也能连进来。如果只想本机测试,可以改成QHostAddress::LocalHost。nextPendingConnection是一次性取一个挂起连接的接口,必须在newConnection信号之后调用,否则连接会一直排队。我把socket存进一个QSet,后面多客户端遍历和清理时方便。
参数说明:端口号范围是0到65535,建议避开0和常用服务端口。listen失败最常见原因是端口被占用,此时errorString会给出具体描述,比如"The bound address is already in use"。做联调时不要两台设备都用同一个程序实例的服务端模式,那样会冲突。
3.2 客户端连接:QTcpSocket的connectToHost与状态机
客户端连接入口封装在TcpManager::connectToServer:
void TcpManager::connectToServer(const QHostAddress &addr, quint16 port) { if (m_clientSocket) { m_clientSocket->abort(); } else { m_clientSocket = new QTcpSocket(this); } connect(m_clientSocket, &QTcpSocket::connected, this, &TcpManager::onConnected); connect(m_clientSocket, &QTcpSocket::errorOccurred, this, &TcpManager::onSocketError); m_clientSocket->connectToHost(addr, port); } void TcpManager::onConnected() { emit logMessage(tr("[CLI] 已连接 %1:%2") .arg(m_clientSocket->peerAddress().toString()) .arg(m_clientSocket->peerPort())); }逻辑说明:connectToHost是异步连接,调用后立刻返回,连接结果通过connected或errorOccurred信号通知。界面上点击连接按钮后不会卡住等待,而是等信号到了再更新状态。abort会强制终止未完成的连接,用于重新连接前清空状态。
一个常见误用是connectToHost之后马上发消息,这时候连接还没建立,所有数据直接丢失。正确的做法是在connected槽里再发送第一条消息。还有一个坑是重复connect connected信号,如果把connect写在connectToHost之后的每一行,同一个连接会触发多次槽函数,导致日志刷屏。
3.3 数据收发与界面线程刷新:signal/slot与队列连接
收发核心是onReadyRead,它不区分服务端还是客户端,谁有数据谁来:
void TcpManager::onReadyRead() { QTcpSocket *s = qobject_cast<QTcpSocket *>(sender()); if (!s) return; QByteArray data = s->readAll(); emit dataReceived(s, data); } // mainwindow.cpp 连接信号 connect(m_tcpManager, &TcpManager::dataReceived, this, [this](QTcpSocket *s, const QByteArray &data) { QString text = QString::fromUtf8(data); ui->messageList->addItem(QString("%1: %2") .arg(s->peerAddress().toString()) .arg(text)); });逻辑说明:readyRead信号可能在一个事件循环迭代里触发很多次,所以不要在槽里假设一次readAll就是完整消息。这个Demo先把数据推给界面,粘包问题留给后面的协议模块。界面刷新用lambda连接,lambda默认捕获this,注意MainWindow析构之后不要再发信号就行。如果TcpManager被moveToThread到工作线程,这里的信号跨线程,QT会选择队列连接,槽里的UI操作会回到主线程执行,不需要额外加锁。
完整的时序是:点击监听按钮 → startServer监听端口 → 另一个实例点击连接 → connectToHost发出TCP握手 → QTCPServer收到后发出newConnection → onNewConnection里accept → 客户端connected信号触发 → 两个socket进入已连接状态。之后每一次write,数据进入系统缓冲区,到对端后触发readyRead,readAll拿出来交给界面。整个过程都是异步的,只要不在槽里做阻塞操作,UI就不会卡。
4. 编译、运行与避坑:从拿到代码到稳定跑通的几条血泪经验
4.1 在Qt Creator里完成编译与运行启动
拿到源码后,不要直接双击main.cpp。先打开qt_tcp_demo.pro,Qt Creator会自动读取配置。我习惯先选择Release模式,因为Debug模式下QT的网络信号断点很多,调试时容易进入QT内部,反而干扰业务逻辑分析。
运行步骤:
- 打开qt_tcp_demo.pro,等待Qt Creator解析项目。
- 确认.pro里有QT += core gui network,如果使用Widgets界面,还需要QT += widgets。
- 选择目标套件后构建。如果机器上同时装了MinGW和MSVC,建议选MinGW,日志信息更直观。
- 先运行一个实例,模式选服务端,点击监听。
- 再运行第二个实例,模式选客户端,地址填127.0.0.1,端口填服务端显示的端口,点击连接。
在同一台机器上跑两个实例是验证集成形态最快的方式。因为两个实例用同一个程序,界面上能清楚看到服务端日志以[SRV]开头,客户端日志以[CLI]开头。如果想在另一台机器上联调,把客户端地址换成服务端的局域网IP,同时保证防火墙放行了对应端口。
4.2 避坑:日志中文乱码、界面卡死、端口被占用
现象:界面日志区的中文变成乱码,服务端收到的中文消息变成问号。
原因:源文件保存编码和运行环境编码不一致,或者发送端QString在转字节流时用了本地编码。
解决:在main.cpp开头设置UTF-8编码,同时确保.cpp文件本身保存为UTF-8 without BOM。消息发送统一用QString::toUtf8,接收后用QString::fromUtf8还原,不要依赖系统locale。
现象:点击监听按钮后界面卡住几秒,期间无法移动窗口。
原因:在槽里同步执行了阻塞网络调用,比如用了waitForConnected或waitForReadyRead。这类wait函数会自己运行一个局部事件循环,期间界面完全无响应。
解决:全部改用异步信号槽,不要用waitFor系列函数。如果你要等连接成功,就在connected槽里做后续动作;如果要等数据,就在readyRead里做。
现象:第二次运行时监听失败,提示地址已被占用。
原因:上一个实例没有正常退出,端口仍处于TIME_WAIT状态。QTCPServer默认没有开启地址复用。
解决:在服务端析构函数里调用close和deleteLater。如果还是不行,可以在listen之前用RAII方式确保旧的监听对象释放。调试时也可以先用一个随机高位端口,比如48000以上,避开系统服务占用。
4.3 避坑:QTcpSocket对象生命周期与断开重连
现象:客户端断开后,服务端程序在下次接收数据时崩溃。
原因:在onNewConnection里把socket指针存进了QSet,但断开时没有从集合里移除,待删除的socket触发readyRead,导致悬空指针。
解决:在disconnected信号槽里先调用deleteLater,再从集合中移除。不能直接delete,因为disconnected分发时socket对象还在事件循环里。
现象:客户端主动断开后重连,服务端出现两个相同地址的连接,收数据重复。
原因:disconnected处理漏掉了m_clientSockets中对应的socket条目,旧指针没有清理干净。
解决:每次断开都打印一条日志,检查m_clientSockets的数量是否等于当前连接数。重连前在客户端也先abort旧socket,确保状态重置。习惯上,我会给每个socket打一个递增的ID标签,在日志里用ID追踪生命周期,排查这类问题会快很多。
5. 协议设计:解决TCP粘包、心跳和半包的关键实现
5.1 自定义消息帧:魔数+长度+JSON载荷
Demo的收发如果只是readAll,在本地回环通常没问题,但跨机器时TCP粘包会出现。比如连续发两次hello,接收方可能一次读到helloworld,也可能只读到半个字符。解决方式是在消息前加一个固定长度的帧头,定义如下:
// protocol.h #pragma pack(push, 1) struct MsgHeader { quint16 magic; // 固定0x5A5A,用于确认合法帧 quint16 payloadLen; // 载荷字节数,网络字节序 quint8 type; // 消息类型: 1-text, 2-ping, 3-pong, 4-file }; #pragma pack(pop)发送时把QString转成UTF-8字节流,拼上头部,整体写入socket。接收时先从缓冲区里读固定大小的头部,再用payloadLen确定后续数据边界。magic用来判断是不是一个有效帧,type字段为后续扩展预留,比如文件传输和心跳。
注意这里用了#pragma pack(push,1),让结构体按1字节对齐,避免因为编译器填充产生内部空洞。quint16在QT里是unsigned short,跨平台固定16位。用quint8而不是char,是为了明确无符号语义,避免符号扩展问题。
5.2 粘包与半包的缓冲处理
接收端需要一个按连接独立存储的缓冲区,不能全局共享。在TcpManager里给每个连接建立QByteArray缓冲:
// tcpmanager.cpp 核心片段 QByteArray m_recvBuffer; // 每个socket一个 void TcpManager::onReadyRead(QTcpSocket *s) { m_recvBuffer.append(s->readAll()); while (m_recvBuffer.size() >= (int)sizeof(MsgHeader)) { MsgHeader *h = reinterpret_cast<MsgHeader *>(m_recvBuffer.data()); if (h->magic != 0x5A5A) { m_recvBuffer.clear(); emit logMessage(tr("[SRV] 帧头错误")); return; } quint16 len = qFromBigEndian(h->payloadLen); if (m_recvBuffer.size() < (int)sizeof(MsgHeader) + len) return; // 半包,等待下一次readyRead QByteArray payload = m_recvBuffer.mid(sizeof(MsgHeader), len); m_recvBuffer.remove(0, sizeof(MsgHeader) + len); handlePayload(s, h->type, payload); } }逻辑说明:qFromBigEndian是因为TCP字节流按大端序在网络传输,而本机可能是小端。发送时用qToBigEndian转成网络字节序,这样不同平台的解析结果一致。半包处理的关键是:当缓冲区长度小于头部加payload的长度时,直接return,把不完整的帧留在缓冲区里,等下一次readyRead到达后继续判断。
粘包处理则靠while循环。如果一次读完缓冲区里有三个完整帧,循环会连续解析三个,直到剩余数据不够一帧。这种写法比每次只处理一帧更稳,不会把后面的帧滞留在缓冲区造成延迟。
多客户端时,这个m_recvBuffer必须按socket分开。服务端常用一个QHash<QTcpSocket*, QByteArray>来保存每个连接的接收缓冲。我在这上面吃过亏,一开始图省事用一个全局buffer,结果两个客户端同时发消息时串数据,排查了好半天。
5.3 心跳保活与断线检测
TCP断开时,正常四次挥手会触发disconnected信号,但设备断电或网线拔掉时,本端可能很久都感知不到。应用层心跳在局域网工具里很有必要。Demo里加了一个QTimer,每3秒发送一个type=2的ping帧:
void TcpManager::startHeartbeat(QTcpSocket *s) { if (m_heartbeatTimer) m_heartbeatTimer->stop(); m_heartbeatTimer = new QTimer(this); connect(m_heartbeatTimer, &QTimer::timeout, this, [this, s]() { if (s->state() == QAbstractSocket::ConnectedState) { sendFrame(s, MsgType::PING, QByteArray()); m_missedPong++; if (m_missedPong > 3) { s->abort(); emit logMessage(tr("心跳超时,断开")); } } }); m_heartbeatTimer->start(3000); }收到pong时把m_missedPong重置为0。参数说明:3秒一次、连续丢3次就断开,等于10秒左右判定死链,适合内网工具。如果走公网,可能要放宽到6秒一次、连续5次,否则网络稍有波动就误断。心跳帧也走协议帧,所以发送端和接收端逻辑对称,接收端收到PING后直接回PONG。
定时器生命周期要小心。如果socket已经断开,但定时器还在跑,就会在s指针上调用state(),此时指针可能已经悬空。我一般会检查sender是否等于当前socket,或者在disconnected时stop心跳定时器。从这开始,这个Demo就不再是只管收发的玩具,而是一个具备基本生产工具形态的通信框架。
6. 二次开发:从Demo改成局域网聊天工具的三个改造点
6.1 扩展一:用户列表与私聊路由
把服务端收到的每个连接的ip和端口注册到用户列表,界面加一个QListWidget。消息帧type增加一个用户列表同步类型,服务端广播用户变更,客户端收到后刷新列表。私聊时帧头再加一个目标ID字段,服务端做一次路由转发。注意QListWidget刷新要走主线程,跨线程时用信号槽投递。
6.2 扩展二:文件传输通道
文件帧和心跳帧混用会互相阻塞。我会单独用一条QTCPsocket传输文件,避免粘包处理影响文本消息。文件发送走分块逻辑,每帧带上总大小和偏移量,接收端用QFile写入。改造时注意readyRead里的读取速度,文件块比较大时不要在槽里做太多处理,最好把写磁盘操作放到工作线程。
6.3 扩展三:启动参数与日志落盘
给每个实例增加启动参数,比如-mod server -port 9090,让程序可以脱离界面直接启动。这样方便在服务器上做后台测试。日志统一写文件,用qInstallMessageHandler接住qDebug输出,按天切分文件名。日志里用[SRV]和[CLI]前缀区分角色,排查联调问题会快很多。
以前做这个Demo时,我图省事把接收缓冲做成全局的,结果两台机器联调时一传文件就乱码,排查了半天才发现是多个连接共用一个缓冲。后来我每加一个连接都强制检查一次:这个成员变量是每个连接独享还是全局共享。从那以后,我每次改网络模块都会先过一遍这个检查清单。希望帮到你。
本文还有配套的精品资源,点击获取