简介:面向Qt5及以上版本开发者的QFtp移植与测试示例,针对新版Qt不再提供QFtp类的现实问题,将Qt4.8.7中的QFtp源码抽取为两个核心文件并配套可运行的FTP客户端工程,便于理解旧模块迁移、接口封装与工程适配思路。压缩包共17个文件、约35KB,包含五个C++源文件、四个头文件、三个界面文件、两个图标图片、一个工程配置、一个资源清单和一个用户配置,覆盖核心逻辑、界面布局、资源组织与构建配置,目录紧凑,适合按模块对照阅读。已有1320人学习,说明其在Qt网络编程与FTP功能集成方面具有一定参考价值。示例实现登录FTP服务器、上传文件、删除文件、创建文件、重命名文件等常用操作,可帮助读者梳理QFtp的调用方式、信号槽处理与界面交互组织,也可作为在Qt5项目中复用QFtp的实践参考。
1. 别急着换库:Qt5 之后重新捡回 QFtp,到底图什么
在 Qt5 的安装目录里搜qftp.h,多半一无所获——这个在 Qt4 时代开箱即用的类,从 Qt5 起就被挪出了 QtNetwork 主干。可做上位机、工控配置工具、老设备固件搬运的团队,恰恰还在天天用 FTP:设备端只认 21 端口,TFTP 传不了大文件,SFTP 又要多带一层依赖。所以「Qt 实现 QFtp」本质上不是重造轮子,而是把官方当年拆出去的那套源码重新接回自己的工程,让它在 Qt5、Qt6 上继续跑。
这份资源给的就是这样一个可直接编译的 QFtp 模块:qftp.cpp、qurlinfo.cpp两对文件,加上把命令队列、异步状态机、数据回调串起来的完整逻辑。接进工程后,connectToHost、login、list、get、put这一整套 FTP 客户端能力就能用上。适合手里有存量 Qt 项目、不想为一次上传去引一个第三方大库的人,也适合要维护老代码、必须保持 API 不变的场景。
2. 把 QFtp 源码接进工程:四个文件、两种构建系统与一次编译验证
拿到源码后第一件事不是急着写业务代码,而是先让它在你的 Qt 版本下编过、跑通一次空连接。这一步翻车的人最多,原因往往不在代码本身,而是对 Qt5 之后模块边界变化没心里有数。
2.1 QFtp 为什么被摘出去,留下来的又是什么
Qt4 里 QFtp 挂在 QtNetwork 下,属于「能用但不推荐」的边缘组件。FTP 协议本身老,明文传输、被动模式端口随机、目录列表格式各家服务器还不统一,官方要维护它就得跟着各种服务端实现打补丁,成本高收益低。于是 Qt5 把它移成了独立模块,不随官方二进制包发布,需要自己拿源码编。
这意味着你手上的这份源码定位很明确:功能完整、逻辑可用、不再演进。它的价值在于「行为稳定、API 熟悉」,而不是「支持新特性」。明白这一点很重要,后面遇到 FTPS、主动模式这些需求时,你就不会指望改几行参数解决,而是直接判断该不该换方案。
从工程角度看,它只依赖 QtCore 和 QtNetwork 两个模块,不牵扯 GUI,天然适合做成静态库挂到任何 Qt 工程里,包括无界面的后台服务。
2.2 源码结构:qftp 与 qurlinfo 两对文件各自的职责
四个文件,职责切得很干净:
| 文件 | 核心类 | 职责 |
|---|---|---|
qftp.h/qftp.cpp | QFtp | 连接管理、命令队列、控制通道读写、状态机、进度信号 |
qurlinfo.h/qurlinfo.cpp | QUrlInfo | 解析 LIST 返回的单行记录,拆出文件名、大小、权限、时间、是否目录 |
QFtp内部维护一个命令队列,控制通道是 QTcpSocket,数据通道在被动模式下每次传输临时建立。QUrlInfo早期是 QtCore 的公开类,Qt5 之后同样被移除,源码里这份是自带实现,别再去#include <QUrlInfo>找官方版本。
提示:这两对文件必须成对引入。只加
qftp.cpp不加qurlinfo.cpp,链接期才会报未定义符号,报错位置还离真实原因很远,容易白折腾半天。
2.3 qmake 接法:拆成 pri 文件,主工程一行 include
最省事的做法是把四个文件放到ftp/子目录,写一个.pri,主工程 include 进去。这样多个子项目复用同一份源码时,改一处全生效。
# ftp/ftp.pri QT += core network HEADERS += $$PWD/qftp.h \ $$PWD/qurlinfo.h SOURCES += $$PWD/qftp.cpp \ $$PWD/qurlinfo.cpp # Qt6 下 QTextCodec 已被移到 core5compat 模块 greaterThan(QT_MAJOR_VERSION, 5): QT += core5compat主工程只需要一行:
QT += core gui network widgets TEMPLATE = app TARGET = ftpclient include(ftp/ftp.pri)逻辑说明:$$PWD是 pri 文件所在目录,用绝对化路径避免子目录 include 时相对路径解析错位;greaterThan判断是为了同时兼容 Qt5 和 Qt6,Qt5 下不会多链接一个不存在的模块。参数上唯一要留意的是QT += core5compat,它提供QTextCodec,源码里处理文件名编码时会用到,漏掉这个在 Qt6 下必然编译失败。
编译验证的方式很简单:先不写任何业务代码,只#include "qftp.h"并在 main 里QFtp ftp;声明一个栈对象,能编过、能运行退出,说明源码接入没问题。别小看这一步,很多人直接上业务代码,结果把编译错误和逻辑错误混在一起排查。
2.4 CMake 接法:独立静态库 + PUBLIC 传递依赖
新工程基本都走 CMake 了。推荐把 QFtp 编成一个独立静态库,依赖用PUBLIC传递,上层 target 只 link 库名即可。
# ftp/CMakeLists.txt add_library(qftp STATIC qftp.cpp qftp.h qurlinfo.cpp qurlinfo.h ) target_link_libraries(qftp PUBLIC Qt::Core Qt::Network) if(QT_VERSION_MAJOR GREATER_EQUAL 6) target_link_libraries(qftp PUBLIC Qt::Core5Compat) endif() target_include_directories(qftp PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})上层:
find_package(Qt6 COMPONENTS Core Network Widgets Core5Compat REQUIRED) add_subdirectory(ftp) add_executable(ftpclient main.cpp ftpclient.cpp) target_link_libraries(ftpclient PRIVATE qftp Qt::Widgets)逻辑说明:PUBLIC关键字让头文件搜索路径和依赖模块自动传递到链接方,ftpclient只写qftp一个名字就够了。如果用的是 Qt5,把find_package换成Qt5,Qt::前缀保持不变,Core5Compat那段条件判断会自动跳过。
参数上要注意编译标准。QFtp 源码是老代码,Qt6 默认 C++17,一般没问题;但如果你的工程强制-Werror,源码里一些隐式类型转换会直接报错,稳妥做法是给这个库单独设target_compile_options,把-Werror摘掉,别为了一个第三方源码去改自己主工程的编译策略。
3. QFtp 的异步状态机:信号顺序、命令队列与数据读取的三条主线
接入编过只是起点,真正让人翻车的是「以为它是同步的」。QFtp 所有操作都是异步下发、信号回调,写错顺序就表现为「命令没反应」或者「数据只收到一半」,而且不报错,只能靠日志一点点对。
3.1 从 connectToHost 到 commandFinished 的完整信号链
一次典型的连接加登录,信号顺序是固定可预期的。理解这条链,才能知道在哪个回调里做下一步。
// ftpclient.cpp 片段 m_ftp = new QFtp(this); connect(m_ftp, &QFtp::stateChanged, this, [](int state) { // state 取值见 QFtp::State 枚举:Unconnected/HostLookup/Connecting/ // Connected/LoggedIn/Closing qDebug() << "state ->" << state; }); connect(m_ftp, &QFtp::commandStarted, this, [](int id) { qDebug() << "cmd start" << id; }); connect(m_ftp, &QFtp::commandFinished, this, [this](int id, bool error) { if (error) { qWarning() << "cmd" << id << "failed:" << m_ftp->errorString(); return; } if (id == QFtp::ConnectToHost) { m_ftp->login("user", "pass"); // 入队,不是立即发送 } else if (id == QFtp::Login) { m_ftp->list("/"); // 同样入队 } }); m_ftp->connectToHost("127.0.0.1", 2121);逻辑说明:connectToHost返回的 int 是命令 id,值等于QFtp::ConnectToHost这个枚举常量。别用返回值去和「第几次调用」对应,它代表的是命令类型。参数上connectToHost只有主机和端口,超时控制不在这个 API 里。
信号顺序上,stateChanged会先走HostLookup、Connecting,连上后是Connected;login成功后变LoggedIn。commandFinished在每条命令结束时发,error为 true 时命令失败,此时要立刻查errorString(),别继续往队列里塞命令。
注意:
commandFinished里不要再调m_ftp->connectToHost之类的重连,容易在状态机内部重入。重连放到done(bool)信号之后用定时器延迟做。
3.2 命令队列:为什么连发五条命令不会乱序
QFtp 内部维护一个 FIFO 队列。login、list、get、put这些调用只是把命令塞进队列,真正的网络等待由内部状态机驱动。所以「在 connect 之后立刻调用 login 和 list」是完全合法的,不用担心连接还没建立。
这个设计带来两个后果。第一,错误会阻塞整个队列。某条命令失败后,后续命令不会自动清空,它们还挂在队列里,每一条都会以 failed 的形式回调,日志会刷出一串错误,看着吓人但其实是同一个根因。第二,队列没有取消单个命令的 API,只有abort()清空全部,所以批量任务要自己控制节奏。
实战里我一般这样控制:维护一个QQueue<Task>业务队列,每次只在commandFinished成功回调里取下一个任务下发,保证任何时刻 QFtp 内部队列不超过两三条命令。这样出问题时日志干净,重试逻辑也好写。参数上不需要额外配置,纯粹是调用侧的节奏控制。
3.3 数据读取:readyRead 与 QIODevice 两种接法的取舍
get和list的数据回来了,怎么拿?有两条路。
第一条是信号方式,get(file)不传设备指针,数据通过readyRead信号通知,你在槽里调readAll()或readData。适合小文件、内存处理。
第二条是设备方式,get(file, &file)把QIODevice*直接交给 QFtp,数据写进设备,你不碰readyRead。适合大文件。
// 设备方式:大文件下载 m_downloadFile = new QFile(localPath, this); if (!m_downloadFile->open(QIODevice::WriteOnly)) { qWarning() << "open local file failed"; return; } m_ftp->get(remotePath, m_downloadFile, QFtp::Binary); connect(m_ftp, &QFtp::dataTransferProgress, this, [this](qint64 done, qint64 total) { if (total > 0) emit progress(int(done * 100 / total)); });逻辑说明:传了QIODevice之后,QFtp 在传输过程中持续往这个设备写,dataTransferProgress同步发出已传字节和总量。total在某些服务器上会返回 0(服务端不支持 SIZE 命令),进度条要按「不确定进度」处理,别直接除零。
参数上QFtp::Binary和QFtp::Ascii要选对。传二进制文件必须 Binary,否则换行符会被转换,文件大小对不上。传文本配置倒是可以 Ascii,但现在绝大多数场景直接 Binary 更省心。
提示:
QFile的生命周期要覆盖整个传输过程。用成员指针或者new出来挂 parent,别在槽函数里用栈对象,回调回来时栈早没了。
3.4 用枚举收敛状态:把信号回调写成可控的状态机
信号是散的,业务是有阶段的。不收敛状态,代码很快就会变成一堆 if 嵌套。我的习惯是定一个自己的枚举,在每次下发命令时记录「当前在等什么」。
enum class Stage { Idle, Connecting, LoggingIn, Listing, Downloading, Uploading }; void FtpClient::onCommandFinished(int id, bool error) { if (error) { fail(m_ftp->errorString()); return; } switch (m_stage) { case Stage::Connecting: m_stage = Stage::LoggingIn; m_ftp->login(m_user, m_pwd); break; case Stage::LoggingIn: m_stage = Stage::Listing; m_ftp->list(m_rootPath); break; case Stage::Downloading: case Stage::Uploading: closeLocalFile(); m_stage = Stage::Idle; emit finished(); break; default: break; } }逻辑说明:用自己维护的m_stage而不是 QFtp 的 state 枚举来驱动业务,是因为 QFtp 的 state 只反映连接层,不反映你在做列表还是传输。参数上id用来做二次校验,比如Stage::Listing阶段收到QFtp::Get的完成回调,说明逻辑串了,可以断言出来。
这套写法的好处是失败路径统一:任何命令失败都走fail(),重试次数、错误提示、状态复位在一个地方收口,不会出现「某个分支忘了复位状态导致后续全部卡死」。
4. 一个能跑的最小客户端:连接、列目录、上传、下载四段落地代码
原理清楚了,落到一个能复制粘贴跑起来的完整流程。下面四段代码按依赖顺序给,拼起来就是一个最小可用客户端。测试服务端我一般用 pyftpdlib 本地起一个,不依赖外网。
pip install pyftpdlib mkdir -p ./ftp_root python -m pyftpdlib -p 2121 -d ./ftp_root -w-p指定控制端口,-d是根目录,-w开放写权限。本地跑通再去连真实设备,能排掉一大半网络因素。
4.1 连接与登录:超时、错误码与重连策略
QFtp 本身没有超时参数,连接超时只能靠外部定时器兜底。
void FtpClient::start(const QString &host, quint16 port, const QString &user, const QString &pwd) { m_user = user; m_pwd = pwd; m_retry = 0; m_stage = Stage::Connecting; m_ftp->connectToHost(host, port); // 10 秒兜底超时,QFtp 自身不提供该参数 m_timeoutTimer.start(10000); } void FtpClient::onTimeout() { if (m_stage != Stage::Connecting && m_stage != Stage::LoggingIn) return; m_ftp->abort(); m_stage = Stage::Idle; if (++m_retry <= 3) QTimer::singleShot(2000, this, [this]{ /* 重新 start */ }); else emit failed("connect timeout"); }逻辑说明:abort()会清空命令队列并断开连接,用于从卡死状态里逃出来。参数上重试次数给 3 次、间隔 2 秒,是考虑到现场网络抖动通常几秒内恢复;如果是设备刚上电,间隔拉到 5 秒更稳。
错误码方面,m_ftp->error()返回QFtp::Error枚举,errorString()给可读文本。常见的是HostNotFound和ConnectionRefused,前者 DNS 或 IP 错,后者端口不通或被防火墙拦。这两类不要重试,直接报错给用户,重试只会让等待时间变长。
4.2 列目录:LIST 与 MLSD 的差异和 QUrlInfo 解析
list()走的是 FTP 的 LIST 命令,返回格式由服务端决定,Unix 风格和 Windows 风格不一样。QUrlInfo内部做了解析,但只覆盖主流格式。
// 连接 listInfo 信号,逐条收 connect(m_ftp, &QFtp::listInfo, this, [this](const QUrlInfo &info) { if (info.name() == "." || info.name() == "..") return; DirEntry e; e.name = info.name(); e.size = info.size(); e.isDir = info.isDir(); e.mtime = info.lastModified(); m_entries.append(e); }); // 下发列表命令 m_stage = Stage::Listing; m_ftp->list(remoteDir);逻辑说明:listInfo是逐条发出的,等commandFinished且 id 为QFtp::List时,才代表整个目录列完,此时再刷新 UI。参数上list可以传目录路径,也可以传-al之类的参数拼在路径前,具体取决于服务端是否接受。
如果服务端支持 MLSD(RFC 3659),返回格式更规整,但 QFtp 没有内置 MLSD 支持,只能用rawCommand("MLSD")拿原始文本自己解析。常见做法是优先 LIST,解析失败再考虑 MLSD,别一上来就上 MLSD,老设备不一定认。
注意:
QUrlInfo::lastModified()返回的是服务器时区的时间,别直接当本地时间用,展示层要做转换。
4.3 上传下载:QIODevice 接法与进度信号
上传和下载对称,都走设备方式最稳。
// 下载 void FtpClient::download(const QString &remote, const QString &local) { m_localFile = new QFile(local, this); if (!m_localFile->open(QIODevice::WriteOnly)) { fail("open fail"); return; } m_stage = Stage::Downloading; m_ftp->get(remote, m_localFile, QFtp::Binary); } // 上传 void FtpClient::upload(const QString &local, const QString &remote) { m_localFile = new QFile(local, this); if (!m_localFile->open(QIODevice::ReadOnly)) { fail("open fail"); return; } m_stage = Stage::Uploading; m_ftp->put(m_localFile, remote, QFtp::Binary); }逻辑说明:两段都是先把本地文件打开,再把设备指针交给 QFtp,传输结束在commandFinished里统一close()并deleteLater()。参数上第三个参数QFtp::Binary对二进制文件是必须的,尤其是 exe、bin、压缩包这类,用 Ascii 会把\r\n转换坏掉,文件大小对不上还很难查。
进度信号dataTransferProgress(qint64 done, qint64 total)在传输过程中持续发。如果total是 0,说明服务端没回 SIZE,只能显示已传字节数,做不了百分比。这一点在 UI 上要提前设计好,别到现场才发现进度条永远停在 0。
4.4 传输模式:为什么 QFtp 只有被动模式
这是最容易误解的一点。QFtp 的数据连接固定走被动模式(PASV),源码里没有提供切换主动模式的 API,也没有对应的 setter。所以「服务端要求主动模式」这种场景,用 QFtp 是绕不过去的,只能改服务端配置或者换实现。
被动模式的工作方式是:控制连接上发 PASV,服务端返回一个 IP 和端口,客户端主动去连这个端口传数据。麻烦在于这个端口是随机的,服务端在 NAT 或防火墙后面时,返回的可能是内网 IP,客户端连不上。
常见做法是在服务端固定被动端口范围,比如 50000-50100,然后在防火墙上放行这段。QFtp 侧不用改任何代码,因为它只是被动接受服务端返回的端口号。如果你的设备端改不了配置,那就得在客户端做端口映射或者走内网直连,别在 QFtp 的 API 上找开关,那里没有。
5. 踩坑排查:QFtp 在真实网络里的五类常见翻车
下面五条都是实际项目里撞出来的,按「现象 → 原因 → 解决」记,遇到时对号入座比翻文档快。
5.1 中文文件名乱码
现象:本地叫「配置备份.bin」的文件,上传后服务端显示成乱码,下载回来本地文件名也不对。
原因:QFtp 解析 LIST 返回时用固定编码转换,服务端如果返回 UTF-8 而客户端按 Latin-1 解,中文必然花。加上不同服务端的编码策略不一致,没有统一答案。
解决:连接成功后先发m_ftp->rawCommand("OPTS UTF8 ON"),让服务端切到 UTF-8。还不行就只能自己用rawCommand("LIST")拿原始字节,用QTextDecoder按实际编码解。文件名的编码问题没有银弹,只能在部署时用真实设备验证一遍。
5.2 commandFinished 里删对象导致崩溃
现象:传输完成后在commandFinished槽里delete m_ftp,程序偶发崩溃,堆栈落在 QFtp 内部。
原因:commandFinished是在 QFtp 状态机内部同步发出的,槽返回后 QFtp 还要继续访问成员变量。这时候对象已经被析构,就是野指针。
解决:一律用deleteLater(),让删除排到事件循环下一轮。同理,信号槽里不要对发送者做同步 delete,这是 Qt 的通用规则,但在 QFtp 这种内部状态复杂的类上尤其致命。
5.3 大文件传输进度回调拖慢界面
现象:传几百 MB 的文件时界面明显卡顿,进度条一跳一跳。
原因:dataTransferProgress发送频率很高,每收到一块数据就发一次,槽里如果直接刷 UI 或者写日志,主线程被占满。
解决:槽里只更新一个成员变量,用 100ms 的定时器统一刷 UI;日志改成只在百分比变化超过 1% 时打一条。另外确认没有在readyRead里做同步磁盘 IO,大文件走设备方式让 QFtp 自己写。
5.4 被动模式端口被防火墙拦
现象:能登录、能列目录,但一传数据就卡住,最后超时。
原因:控制连接走 21 端口通了,数据连接用的被动端口被防火墙挡了。PASV 返回的端口每次不一样,防火墙规则没覆盖。
解决:服务端固定被动端口范围并在防火墙放行,这是最干净的做法。如果服务端动不了,就在客户端抓包看 PASV 返回的具体端口,确认是不是内网 IP;是的话说明服务端在 NAT 后面,需要服务端配外部地址,客户端侧无解。
5.5 FTPS 与超时:QFtp 不支持的边界
现象:对接要求加密的 FTP 服务,连上去直接被拒或者握手失败。
原因:QFtp 基于QTcpSocket明文实现,不支持 AUTH TLS,也不支持隐式 FTPS。这不是配置问题,是能力缺失。
解决:这类需求别在 QFtp 上打补丁,直接换方案。要么用QSslSocket自己实现控制通道和数据通道,要么引入支持 FTPS 的第三方库。判断标准很简单:服务端要求 TLS 就换,别花时间研究 QFtp 的源码。另外 QFtp 没有传输超时参数,大文件传输中途网络断了,可能长时间无响应,得靠外部定时器监控dataTransferProgress,超过 N 秒没更新就abort()重连。
6. 进阶:用 rawCommand 拼出断点续传与一套回归验证
断点续传是 QFtp 最常被问的需求,它没有现成 API,但可以用rawCommand拼出来。FTP 协议里续传靠 REST 命令指定偏移量,然后紧跟 RETR。QFtp 的命令队列保证顺序,所以这样写是可行的:
qint64 offset = QFileInfo(localPath).size(); m_ftp->rawCommand(QString("REST %1").arg(offset)); // 先告诉服务端从哪开始 m_localFile = new QFile(localPath, this); m_localFile->open(QIODevice::Append); // 本地以追加方式打开 m_ftp->get(remotePath, m_localFile, QFtp::Binary); // 紧跟 RETR要点在本地文件必须用Append而不是WriteOnly,否则前面的数据会被覆盖。另外REST的回复通过rawCommandReply信号拿,正常是 350,看到 350 才能确认偏移被接受;有些服务端不支持 REST,会回 500,这时候降级成整文件重下。这条路径我踩过不止一次,血泪经验是:续传逻辑一定要在本地测试服务端和真实设备上各跑一遍,两边对 REST 的支持差异很大。
验证环节我一般写一个小脚本做回归,用 pyftpdlib 起服务端,跑一遍「连接 → 列目录 → 上传 → 下载 → 校验 md5」,对比本地和远端的哈希值。这套流程固定下来之后,每次改 QFtp 源码或者换 Qt 版本,先跑一遍,比人肉点界面靠谱得多。
还有个小技巧:把所有 QFtp 的信号统一接到一个日志槽里,打印命令 id、耗时、错误码,日志按会话分文件存。现场问题十有八九是网络和服务端配置,没有这层日志,只能靠猜。从那以后我每次接 FTP 相关需求,都强制先把这层信号日志和本地回归脚本走一遍,再动业务代码。希望帮到你。
本文还有配套的精品资源,点击获取