
简介针对Qt环境下FTP文件操作的常见痛点这份代码资源实现了指定路径文件夹与文件的下载、删除尤其解决了目标机路径含中文时的转码乱码问题并内置FTP连接保活机制避免挂载超5分钟自动断开导致的任务中断。资源基于Qt 5.7编写以C源码形式提供包含6个头文件、6个实现文件及1个UI文件共13个文件压缩包仅60KB结构精简适合需要快速集成FTP管理能力的中高级Qt开发者。需要说明的是资源并非独立可运行程序需嵌入自身工程并调整类接口同时未包含上传功能及目录进入退出逻辑但下载/删除核心流程完整可在此基础上扩展。已有444人学习参考代码注释与博客说明结合能帮助理解QFTP对文件夹递归操作、中文编码转换及FTP会话维持的关键实现。1. 为什么在 Qt 里做 FTP 还会卡在“中文路径”和“递归删除”上用 Qt 写 FTP 客户端功能表上无非是下载、上传、删除、列目录但真正把本地和远端都换成中文路径时问题才开始出现。常见的情况是在 Windows 内存里看到 QString 是对的服务器端 ls 也是对的偏偏 QFtp 返回的目录名是乱码rmdir 一直 550get 下来的文件是 0 字节。标题里这个需求还覆盖了另一个常见版本目标机路径是中文也要能智能识别转换说的是服务器返回的文件名编码不确定可能是 UTF-8、可能是 GBK客户端不能写死一种解码方式。这篇文章按我自己做 Qt 客户端时的顺序把目录列表解析、递归下载、递归删除、编码探测这四个环节串起来最后落到一个可以抄走的编码识别方案上。2. Qt 5/6 里 FTP 选型与目录列表解析先弄清 QFtp 的边界2.1 QFtp 在 Qt 5 之后的处境与三种补法Qt 5 把 QFtp 从 QtNetwork 里移了出去Qt 5.15、Qt 6 都没有内置的 QFtp 类网上最常见的报错就是“unknown module in qt: serialport”那一类连带问题。要做 FTP 客户端常见做法有三个方案能做的事中文风险维护成本把 qtftp 源码加入工程一起编译列目录、上下传、删除、重命名列目录返回的 QUrlInfo 需要自行重解码中一次编译后续省心使用 QNetworkAccessManager 的 ftp:// 协议只支持下载/上传不提供删除和目录递归依赖 QUrl 编码路径解析不可控低但功能缺失用 QTcpSocket 自己实现 FTP 命令交互全部可控编码完全自管写起来最重高适合特殊场景我一般用第一种因为删除和递归列举是 QNetworkAccessManager 做不了的而自己实现全套 FTP 协议对大多数业务来说不值得。qtftp 源码不大放进工程里编译一次后面就当作普通模块用。注意 Qt 6 与 Qt 5 的构建系统不同Qt 6 下需要把源码加入 CMake 工程而不是依赖 qmake 的 subdirs这一点在编译时最容易卡住。2.2 QFtp 的命令模型与回调信号QFtp 是异步模型每个方法都会返回一个命令 id命令完成后发出 commandFinished(id, error)传输进度走 dataTransferProgress。把方法名和 FTP 命令对应起来后面写状态机才不会乱QFtp 方法对应 FTP 命令实际用途connectToHostCONNECT建立控制连接loginUSER/PASS登录cdCWD切换远端目录listLIST列目录触发 listInfo 信号getRETR下载文件putSTOR上传文件removeDELE删除文件rmdirRMD删除目录非空会失败renameRNFR/RNTO重命名提示QFtp 的 setTransferMode(QFtp::Passive) 必须在登录后设置。否则在 NAT 或防火墙环境下会看到 “500 Illegal PORT command” 或 “425 Use PORT or PASV first”尤其是 Windows 自带的 IIS FTP 默认行为不一致时报错特别频繁。2.3 LIST 解析不要直接迷信 listInfo 里的 nameQFtp 的 list() 会把 LIST 响应解析成 QUrlInfo 并通过 listInfo 信号发出来。QUrlInfo 里有 name、size、isDir、isFile、lastModified 这些字段看起来很方便但中文乱码问题恰恰出在这里QFtp 内部对 LIST 字节的解码方式在不同版本里并不一致尤其遇到 GBK 编码的中文时listInfo 出来的 name 已经是错的 QString原始字节丢了一部分。可靠的做法是拿到 QUrlInfo.name 后先按字节还原再重解码。QFtp 的历史版本大多把响应字节按单字节字符集解释也就是说你可以把乱码的 name 用 toLatin1() 还原成原始字节再用 UTF-8 或 GBK 重新解码。这一步就是标题里“中文转码”的关键位置后面第 5 章专门给探测逻辑。3. 文件与文件夹下载递归取列表、按相对路径落盘、中文名还原3.1 FtpClient 类的最小结构下载文件夹本质上就是先递归拿到远端相对路径集合再逐个 get 到本地对应路径。先有一个 FtpClient 的骨架// ftpclient.h #include QFtp #include QFile #include QUrlInfo class FtpClient : public QObject { Q_OBJECT public: explicit FtpClient(QObject *parent nullptr); void connectServer(const QString host, quint16 port, const QString user, const QString pass); void downloadFile(const QString remotePath, const QString localPath); void downloadDir(const QString remotePath, const QString localPath); void deleteFile(const QString remotePath); void deleteDir(const QString remotePath); signals: void finished(bool ok, const QString msg); void progress(qint64 done, qint64 total); private slots: void onCommandFinished(int id, bool error); void onListInfo(const QUrlInfo ui); private: QFtp *m_ftp nullptr; QFile *m_file nullptr; QByteArray m_pathEncoding; // 探测出的服务器路径编码第 5 章使用 QString m_currentRemote; QString m_currentLocal; QListQUrlInfo m_items; };connectServer 里要把被动模式固定下来这是跨网络环境稳定性的前提void FtpClient::connectServer(const QString host, quint16 port, const QString user, const QString pass) { m_ftp new QFtp(this); connect(m_ftp, QFtp::commandFinished, this, FtpClient::onCommandFinished); connect(m_ftp, QFtp::listInfo, this, FtpClient::onListInfo); connect(m_ftp, QFtp::dataTransferProgress, this, FtpClient::progress); m_ftp-connectToHost(host, port); m_ftp-login(user, pass); m_ftp-setTransferMode(QFtp::Passive); }命令 id 的关联用 QHashint, int 保存状态类型onCommandFinished 里读 code 判断当前阶段这是 QFtp 编程的常规套路避免在登录、列目录、下载、删除之间互相串台。状态值用枚举Login、List、Get、DeleteFile、DeleteTree。3.2 指定路径下载单个文件下载单文件时先建本地目录再用 QFtp::get 写入 QFile。难点在远端路径的编码文件名里只要有中文直接传给 get 很可能会在服务器端解析失败void FtpClient::downloadFile(const QString remotePath, const QString localPath) { QFileInfo localInfo(localPath); QDir().mkpath(localInfo.absolutePath()); m_file new QFile(localPath); if (!m_file-open(QIODevice::WriteOnly)) { emit finished(false, QStringLiteral(本地文件打开失败: %1).arg(localPath)); return; } // 中文路径按服务器编码处理见第 5 章的 encodePath QByteArray remote encodePath(remotePath); int id m_ftp-get(remote, m_file); m_jobType.insert(id, JobGet); }get 的第二个参数可以是 QIODevice*QFtp 会把 RETR 返回的数据流写入这个设备。QFile 必须全程保持打开commandFinished 收到 JobGet 完成后再 close。此时要注意FTP 的 RETR 是流式协议本地文件写完后要检查 m_file-bytesToWrite() 是否为 0Windows 下偶发 QFile 缓冲未刷完的情况稳妥做法是 close 前先 flush。3.3 文件夹下载递归队列与相对路径保存文件夹下载分两步先 cd 到远端目录list 拿全部条目再对目录继续递归对文件发起 get。这里的递归不能直接写成函数递归因为 QFtp 是异步的函数递归会把命令顺序搞乱。我一般用两个 QList 拼出待下载文件清单list 全部完成后统一开始下载void FtpClient::downloadDir(const QString remotePath, const QString localPath) { m_remoteBase remotePath; m_localBase localPath; m_pendingFiles.clear(); m_listDirs.clear(); m_listDirs.append(remotePath); // 先列最外层 // 触发第一次 listcommandFinished 里根据状态继续 int id m_ftp-cd(remotePath); m_jobType.insert(id, JobList); }onCommandFinished 里对 JobList 的处理void FtpClient::onCommandFinished(int id, bool error) { if (error) { emit finished(false, QStringLiteral(命令失败, id%1, msg%2) .arg(id).arg(m_ftp-errorString())); return; } int type m_jobType.value(id); if (type JobList) { // m_items 里是刚列出的条目 if (m_items.isEmpty()) { // 这个目录列完了开始下载文件 startBatchDownload(); return; } // 取出一个准备递归的子目录 QUrlInfo info m_items.takeFirst(); if (info.isDir()) { // 拼出远端相对路径和本地相对路径时保留中文 QString fixedName decodeFileName(info.name()); // 重新编码后 cd 进去继续列 int next m_ftp-cd(m_currentRemote / fixedName); m_jobType.insert(next, JobList); } else if (info.isFile()) { m_pendingFiles.append(info); // 队列里还有更多条目时先不下载继续列完当前目录即可 } } else if (type JobGet) { m_file-flush(); m_file-close(); m_file-deleteLater(); if (--m_remainingDownload 0) { emit finished(true, QStringLiteral(下载完成)); } } }list 递归和 get 下载如果在同一个调度循环里混着做很容易出现“文件还没下完就 cd 到别处”的 bug。避免的办法很简单第一轮只做 list把目录骨架全部展开到 m_pendingFiles 和本地目录结构第二轮统一下载。本地目录的创建放在读取到目录条目的时刻用 QDir().mkpath(m_localBase / 相对路径) 建出来这样 get 时 QFile 的父目录一定存在。4. 文件删除与文件夹删除先清空子项再 RMD 的队列状态机4.1 删除命令的输入校验与命令顺序deleteDir 不能直接用 rmdir非空目录会失败这是 FTP 服务端的硬性限制。所以删除文件夹的逻辑是先递归列出目标目录里全部子项然后把所有文件先 remove最后把目录按深度从下往上 rmdir。这里仍然不能用递归函数得把待删除目录组织成一个栈。void FtpClient::deleteDir(const QString remotePath) { m_deleteStack.clear(); m_deleteStack.push(remotePath); // 先列目标目录收集全部文件路径 int id m_ftp-cd(remotePath); m_jobType.insert(id, JobCollectDelete); }在 JobCollectDelete 完成时把 m_items 里的文件路径记进 m_deleteFiles把子目录路径 push 到 m_deleteStack然后继续对子目录发起 cdlist。这样一轮收集后m_deleteStack 保存的是所有已发现的目录m_deleteFiles 保存所有文件。收集完成进入 JobDeleteFile 阶段逐个 remove。4.2 递归删除的实现删除目录时四个命令的顺序是CWD 进目录 → LIST 拿子项 → DELE 删文件 → RMD 删空目录。用队列实现如下void FtpClient::processDeleteQueue() { // 先删文件文件删完再处理目录 while (!m_deleteFiles.isEmpty()) { QString remote m_deleteFiles.takeLast(); int id m_ftp-remove(encodePath(remote)); m_jobType.insert(id, JobDeleteFile); return; // 一次队列推进只发一个命令等 finished 后继续 } while (!m_deleteStack.isEmpty()) { QString dir m_deleteStack.pop(); int id m_ftp-rmdir(encodePath(dir)); m_jobType.insert(id, JobDeleteDir); return; } emit finished(true, QStringLiteral(删除完成)); }每轮命令完成后再调用 processDeleteQueue可以保证不会在 rmdir 前还有文件未被 DELE。仔细看这里有个细节目录从栈里弹出时要先确保所有文件都已 remove。如果服务器上目录嵌套层级很深文件先删完再自底向上 rmdir 是安全的因为栈的特性天然保证子目录排在父目录前面。注意如果目标目录里还有子目录收集阶段就必须递归列出来否则 rmdir 会失败。常见的 550 错误里相当一部分不是权限问题而是目录下有隐藏文件比如 macOS 的 .DS_Store、Windows 的 desktop.ini这些都会被 LIST 列出来但容易被客户端过滤掉。处理办法不要按名字过滤以“.”开头的文件。4.3 两个常见的删除失败场景第一个是路径编码不对导致 CWD 进不去RMD 返回 550。Windows IIS 的 FTP 默认用 GBK 保存中文路径Linux vsftpd 默认 UTF-8。客户端如果拿 UTF-8 编码去删 IIS 上的中文目录服务端解析字节失败直接报 550。处理办法是“目标机路径是中文也可以智能识别转换”也就是第 5 章的探测逻辑。第二个是删除时把命令 send 得太快。QFtp 是异步排队但 remove 一个不存在的文件也会触发 commandFinished(false)如果代码里不看 error 就继续把队列里下一个 rmdir 发出去会导致逻辑错乱。每个删除步骤完成时都要先检查当前 error 标志失败就停下并把错误信息抛出来不要自动跳过。5. 中文路径智能识别候选编码探测与重试机制5.1 编码识别在哪个环节介入编码探测有三个介入点按优先级排列第一是路径编码发送前。客户端无法预知服务端是 GBK 还是 UTF-8 时用枚举法试。过程很简单把 QString 分别用 UTF-8、GBK 编码得到两组字节先用第一组执行 CWD 或 SIZE 探路返回 550 就换第二组成功就把候选编码缓存起来后续所有命令都用这个编码。QByteArray FtpClient::encodePath(const QString path) { if (!m_pathEncoding.isEmpty()) { // 已经探测成功直接使用 return m_pathEncoding GBK ? path.toLocal8Bit() : path.toUtf8(); } // 先按 UTF-8 构造字节流并 URL 编码 QByteArray utf8 QUrl::toPercentEncoding(path, /, ); int sizeId m_ftp-size(utf8); // SIZE 命令探测 if (m_ftp-currentCommand() QFtp::None) m_probeCache.insert(sizeId, UTF-8); // SIZE 失败后在 onCommandFinished 里判断 error // 换 GBK 重新建一条命令 return utf8; }SIZE 对文件有效对目录不返回大小。所以探路更通用的做法是 CWD先 cd 到中文父目录的上一级再 cd 到中文子目录成功即认定编码正确。CWD 失败后回退另一个编码重试整个函数写在 commandFinished 的失败分支里编码确定后缓存为 QByteArray后续 list、get、remove、rmdir 全部走 encodePath。5.2 基于 listInfo 乱码名的还原策略LIST 返回的中文名在 QUrlInfo 里可能是乱码解码思路是先把乱码 QString 还原成原始字节再按候选编码重解。核心逻辑如下QString FtpClient::decodeFileName(const QString rawName) { // 先尝试还原原始字节 QByteArray bytes rawName.toLatin1(); if (bytes.isEmpty()) { return rawName; } // UTF-8 严格模式能成功说明名单本身是 UTF-8 QTextCodec *utf8 QTextCodec::codecForName(UTF-8); QTextCodec::ConverterState state; QString result utf8-toUnicode(bytes.constData(), bytes.size(), state); if (state.invalidChars 0) { return result; } // 失败则回退本地编码Windows 中文环境默认 GBK return QTextCodec::codecForLocale()-toUnicode(bytes); }这里有个细节UFT-8 解码要传 ConverterState 并且检查 invalidChars只靠 toUnicode 无法判断是否乱码。GBK 的容错性很强几乎任何字节都能解出汉字所以 GBK 作为兜底是安全的。实际工程里我还加过一层校验如果解出的字符串里出现大量 UFFFD 或控制字符判定为解码失败回退 Latin1 按原始字节处理。5.3 用一个自检脚本验证识别逻辑搭一个典型的混合环境验证最直接Windows 本机开一个 FTP 站点目录名叫“项目资料-测试”IIS 站点默认 GBK再在 Linux 上起一个 vsftpd目录名相同。同一套 FtpClient 代码分别连两个服务器跑下载和删除控制台打印每一步的编码探测结果# 观察登录后发出的 CWD 命令字节 # Windows IIS 上应该是 %CF%EE%C4%BF 这类 GBK 百分号编码 # vsftpd 上应该是 %E9%A1%B9%E7%9B%AE 这类 UTF-8 百分号编码验证时手动改 m_pathEncoding 的值分别固定为 “GBK” 和 “UTF-8” 连同一个服务器对比 decodeFileName 输出的目录名。如果固定 UTF-8 后显示正常而固定 GBK 后乱码说明探测顺序正确。最后把 SIZE 失败后重试 CWD 的次数打进日志里线上出问题时能快速定位是哪一层编码没有匹配上。本文还有配套的精品资源点击获取