ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Qt 文本文件读写全解析:编码、性能与原子落盘

Qt 文本文件读写全解析:编码、性能与原子落盘 做 Qt 开发这些年见过太多人在Qt 文本文件读写这件小事上翻车。面试时问 QFile 怎么用几乎所有人都能答上来 open、readAll、close 三步走可真到项目里让他写个落盘配置、导出一份 CSV、追加大文件日志出来的代码十有八九会在中文乱码、路径找不到、写完没生效这几个地方趴窝。原因很简单Qt 的文本文件读写表面上是一层薄薄的封装底下却叠着编码转换、换行符翻译、缓冲区管理、原子写入四套机制任何一层没照顾到结果就是代码能跑数据不对。这篇内容我打算把 QFile、QTextStream、QStringConverter 这一整套东西按真实项目里的使用顺序拆开讲从选型边界讲到编码坑从大文件读法讲到排错链路最后给一套可以直接抄进项目的封装。适合刚上手 Qt 的朋友也适合写了几年但一直靠试错解决乱码的老手。1. 从 QFile 说起三层抽象各自负责什么1.1 QFile、QTextStream、QDataStream 的职责边界很多教程一上来就给代码跳过了为什么要有三个类这个问题导致后面选型全靠感觉。我按我的理解捋一遍。QFile继承自QIODevice它是字节层。你调open()拿到的是一个可以按字节读写的通道read()返回QByteArraywrite()接受QByteArray。它不关心你写进去的是 UTF-8 还是 GBK也不关心里面有没有换行——对它来说全是 0x00 到 0xFF 的序列。QFile真正管的是打开模式、文件权限、错误码、位置指针。它的error()和errorString()是排查问题的第一手资料后面第 6 节会反复用到。QTextStream是文本层。它包在QIODevice外面负责三件事把字节按某种编码解释成QString、把QString按某种编码写回字节、处理平台换行符差异。同时它提供了和运算符能直接格式化读写整数和浮点数这是纯QFile做不到的。QDataStream则是二进制序列化层。它输出的文件是给程序读的不是给人看的带类型标记和字节序控制。拿它存配置文件的后果是用户拿记事本打开看到一堆乱码——所以除了真正需要结构化二进制存储的场景日常文本处理根本用不上它。类层级输入输出类型典型用途QFile字节层QByteArray拷贝文件、读二进制、自己处理编码QTextStream文本层QString配置文件、日志、CSV、逐行解析QDataStream序列化层各种 Qt 类型缓存文件、网络协议包、二进制存档这张表看着简单但它决定了你后面所有的写法。一个原则只要数据是给人看的中间就绕不开 QTextStream 或手写编码转换。1.2 什么时候该上 QTextStream什么时候 readAll 就够我在项目里见过一种过度设计读一个 200 字节的 ini 文件先建 QFile、再套 QTextStream、再循环 readLine、再拼成一个 QString。三步操作换来的是一个比readAll()慢好几倍的函数。所以选型要看文件规模和使用意图。第一种情况小文件整体处理。配置文件、模板文件、几 KB 的 JSON直接用QFile::readAll()拿QByteArray再一次性转成QString。理由是既然最终要把整份内容放在内存里逐行拼装只是白白多做几十次内存分配。代码短、出错点少。第二种情况大文件逐行扫描。日志分析、数据导入导出这类场景文件可能有几百 MB全读进内存会直接把进程撑爆。这时候必须逐行读处理完一行就丢掉一行。第三种情况需要格式化数值。比如导出报表要控制浮点精度QTextStream的setRealNumberPrecision()和setNumberFlags()能省掉一堆QString::number()的手工格式化。这时候即便文件很小套一层 QTextStream 也是划算的。提示不要因为QTextStream 更高级就默认使用它。它的编码状态、缓冲状态、换行状态都是有成本的小文件场景下readAll反而更不容易出错。还有一个容易被忽略的点QTextStream的operator是按空白字符分词读的遇到中文、全角空格、连续多个空格时行为和readLine()差别很大。如果你要做读一行、按固定分隔符切分的解析用readLine()拿到整行再自己split逻辑比operator清晰得多也更可控。2. 编码这道坎中文乱码到底出在哪一层2.1 Qt5 和 Qt6 的默认编码差异这是我这几年踩得最狠的一个坑也是最容易被忽略的。Qt5 里QTextStream默认使用QTextCodec::codecForLocale()决定的编码在中文 Windows 上就是 GBK 系Qt6 里默认编码改成了 UTF-8。后果是什么同一份代码用 Qt5.15 编译出来的程序写出去的配置文件是 GBK 的升级到 Qt6 重新编译写出去变成 UTF-8 了。如果这个文件还要被别的程序读或者用户在两个版本之间来回切就会出问题——旧文件用新程序读是乱码新文件用旧程序读也是乱码。而排查时你会很困惑因为代码一行没改。我的做法是任何一处文件读写都显式指定编码绝不依赖默认值。多写一行代码换掉一整类跨版本问题这笔账怎么算都值。QFile file(path); if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { return false; } QTextStream in(file); #if QT_VERSION QT_VERSION_CHECK(6, 0, 0) in.setEncoding(QStringConverter::Utf8); #else in.setCodec(UTF-8); #endif这段#if是 Qt5/Qt6 双版本兼容的标准写法建议直接抄。QStringConverter是 Qt6 引入的Qt5 上没有这个类所以条件编译躲不开。2.2 UTF-8 BOM、GBK 文件的实际处理方式带 BOM 的 UTF-8 文件是最常遇到的。Windows 记事本另存为默认就会加 BOM也就是文件头三个字节EF BB BF。Qt5 的QTextStream有个setAutoDetectUnicode(true)但它只识别 UTF-16 和 UTF-32 的 BOM不处理 UTF-8 BOM。结果是你用 UTF-8 编码读一个带 BOM 的文件第一行开头会莫名其妙多出一个不可见字符接着所有字符串比较、正则匹配、JSON 解析全部失败。处理方式是自己判断文件头QFile file(path); if (!file.open(QIODevice::ReadOnly)) return false; QByteArray raw file.readAll(); if (raw.startsWith(\xEF\xBB\xBF)) { raw.remove(0, 3); // 去掉 UTF-8 BOM } QString text QString::fromUtf8(raw);反过来如果你需要写出带 BOM 的 UTF-8 文件给某些老工具用那就先写三个字节再写正文QFile out(path); if (!out.open(QIODevice::WriteOnly | QIODevice::Truncate)) return false; out.write(\xEF\xBB\xBF); out.write(text.toUtf8()); out.close();GBK 文件的处理要分版本。Qt5 下直接用QTextCodecin.setCodec(QTextCodec::codecForName(GBK)); out.setCodec(QTextCodec::codecForName(GBK));Qt6 下QTextCodec被移到了 Qt5Compat 模块需要先在.pro里加QT core5compatCMake 里是Qt6::Core5Compat然后#include QTextCodec才能用。如果你不想引入这个依赖另一条路是用QStringDecoder配合系统编码但QStringConverter支持的编码集合只有 UTF-8、UTF-16、UTF-32、Latin-1 和 System并不包含 GBK所以严格的 GBK 场景还是得靠 Qt5Compat 或者第三方库。这个限制值得提前知道免得设计方案做到一半才发现走不通。2.3 QStringConverter 的正确用法Qt6 里字节和字符串之间的转换除了QTextStream的setEncoding还有更底层的QStringDecoder和QStringEncoder。它们适合我手里已经有一块QByteArray想转成QString这种没有流对象参与的场景。QByteArray data file.readAll(); // 解码字节 - 字符串 QStringDecoder decoder(QStringConverter::Utf8); QString text decoder.decode(data); // 编码字符串 - 字节 QStringEncoder encoder(QStringConverter::Utf8); QByteArray bytes encoder.encode(text);有一个细节值得注意QStringDecoder在遇到非法字节序列时默认行为是插入替换字符而不是中断。也就是说乱码不会抛错只会静默产生UFFFD。如果你做的是严格校验场景比如解析协议文件必须用decoder.hasError()检查不然错误会被吃掉。3. 逐行读、分块读还是一次读光性能怎么取舍3.1 三种读法的横向对比我把实际项目中最常用的三种读法列一下方便按场景对号入座。读法内存占用相对耗时适用场景QFile::readAll()等于文件大小最快小于几十 MB需要整体处理QFile::readLine()循环单行大小快大文件、只看需要的行QTextStream::readLine()循环单行大小 转换开销中等大文件且需要按编码解码QFile::read()自定缓冲缓冲区大小快二进制为主的混合解析关键差异在第二和第三行。QFile::readLine()返回QByteArray不做任何编码转换QTextStream::readLine()返回QString每一行都要过一遍解码器。文件越大、行数越多这个差距越明显。3.2 百万行日志该怎么读假设你要扫一个 200 万行的日志文件只挑出包含某个关键字的行。直觉写法是QTextStream in(file); while (!in.atEnd()) { QString line in.readLine(); if (line.contains(keyword)) result.append(line); }这个写法功能没问题但有两个成本每行都构造一个QString每行都做一次 UTF-8 解码。两百万次下来耗时会明显高于预期。我的优化思路是先按字节匹配再做解码。日志里要筛的关键字如果是纯 ASCII那直接在QByteArray层面判断就够了QFile file(path); if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) return; const QByteArray needle keyword.toUtf8(); while (!file.atEnd()) { QByteArray line file.readLine(); if (line.contains(needle)) { // 只有命中的行才做解码比例通常不到 1% result.append(QString::fromUtf8(line)); } }这样解码次数从两百万次降到几万次。另外result别用QStringList一路堆到底如果命中行很多内存照样会爆。稳妥做法是每积累一千行就写一次输出文件或者发一次信号给界面把内存峰值压住。还有一个反直觉的结论逐行读不总是比整块读快。readLine()内部也在做逐字节扫描找换行符调用次数多的时候函数调用开销会累积。对于几十 MB 的中等文件readAll()之后用QByteArray::split(\n)反而可能更快。所以别迷信某种写法文件规模在 10 MB 到 100 MB 之间时两种方式都值得实测一下。3.3 写文件的缓冲区与 flush 时机写这块的坑比读更隐蔽。QTextStream内部是有缓冲的你调operator的时候数据先进缓冲区不是说立刻落到磁盘。真正触发落盘的是缓冲区满、显式flush()、或者对象析构。于是就有了那个经典问题QTextStream 和 QFile 的析构顺序。void badWrite() { QFile file(a.txt); file.open(QIODevice::WriteOnly); QTextStream out(file); // 声明在 file 之后 out hello; // 函数结束先析构 outflush 到 file再析构 file真正落盘。正确。 } void alsoBadWrite() { QFile file(a.txt); file.open(QIODevice::WriteOnly); QTextStream* out new QTextStream(file); *out hello; // 忘记 delete out缓冲区内容可能永远不落盘 }C 的析构顺序是后声明先析构所以只要QTextStream声明在QFile之后函数退出时它会先 flush再轮到QFile关闭顺序天然是对的。真正出事的是两种情况一是用new创建QTextStream却忘了 delete二是手动提前调了file.close()这时候QTextStream还持有引用后续的 flush 直接失败。我的习惯是写完就显式 flush函数结束前显式 close。多两行代码换掉一整类程序跑完文件是空的的诡异 bug。out content; out.flush(); file.close();另外提一句endl和\n的区别。QTextStream::endl会强制 flush 一次\n只写换行符。在循环里写几千行还用endl等于做了几千次系统调用性能会非常难看。日常换行统一用\n需要立刻落盘的场景才用endl或者flush()。4. 路径、换行、追加不影响编译但影响结果的细节4.1 相对路径到底相对于谁新手最常见的困惑是我明明写了open(config.txt)为什么 IDE 里能跑双击 exe 就不行。答案是相对路径相对于进程的当前工作目录而当前工作目录不是 exe 所在目录。在 IDE 里启动程序工作目录通常被设成了项目目录双击 exe 启动工作目录可能是 exe 所在目录也可能是C:\Windows\System32从某些位置启动时。同一个相对路径两种启动方式指向完全不同的文件。可靠的做法是两种要么用QCoreApplication::applicationDirPath()拿到 exe 目录再拼路径QString path QCoreApplication::applicationDirPath() /config/settings.ini;要么用QStandardPaths拿系统标准目录把用户数据放到该放的地方QString dir QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); QDir().mkpath(dir); // 目录可能不存在先创建 QString path dir /settings.ini;第二种更规范因为程序安装目录在多数系统上是不该写数据的。mkpath()那一步经常被忘掉——第一次运行时目录不存在open(WriteOnly)会直接失败而且失败原因看起来像是权限问题其实是路径不存在。中文路径在 Qt 里通常不用特别处理QFile内部会把QString转成平台原生编码。但如果你的路径要传给外部命令行工具、或者通过QProcess调用别的程序编码问题就会重新出现这时候得按对方程序期望的编码去转换。4.2 QIODevice::Text 标志的真实作用这个标志看起来不起眼但它直接决定了你读到的字符串里有没有\r。QIODevice::Text的作用是读的时候把平台换行符统一翻译成\n写的时候把\n翻译成平台换行符。在 Windows 上也就是\r\n和\n之间的双向转换。不加这个标志会怎样QFile::readLine()读到 Windows 文件的每行末尾都会带一个\r。这个字符不可见但会让line abc判断失败、line.trimmed()才有救、正则匹配莫名其妙不中。我调试过一个案例同事在字符串比较上折腾了两个小时最后发现是行尾多了个\r。所以我的建议是文本文件读写open()时一律带上QIODevice::Text。file.open(QIODevice::ReadOnly | QIODevice::Text);反过来说处理二进制文件的时候绝对不能加这个标志否则文件里的0x0D 0x0A字节对会被莫名改写文件就损坏了。判断标准很简单这份文件你能用记事本打开读就是文本加标志打不开或者打开是乱码就是二进制不加。4.3 追加写入与 QSaveFile 原子落盘追加日志的标准写法是QIODevice::AppendQFile log(app.log); if (log.open(QIODevice::WriteOnly | QIODevice::Append | QIODevice::Text)) { QTextStream out(log); out QDateTime::currentDateTime().toString(yyyy-MM-dd hh:mm:ss) message \n; out.flush(); log.close(); }注意Append已经隐含了从末尾写不需要再seek到文件尾。另外Append模式下不要同时用Truncate两者语义冲突。另一个更重要的场景是覆盖写入配置文件。如果直接用WriteOnly | Truncate程序在写到一半时崩溃或者断电原文件就已经被清空了用户配置全丢。QSaveFile就是为这个场景设计的它先写一个临时文件只有你调commit()时才原子性地替换原文件。QSaveFile file(configPath); if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { return false; } QTextStream out(file); out content; out.flush(); if (!file.commit()) { // 这一步才真正替换原文件 return false; }commit()返回 false 就说明替换失败原文件保持不动这是它比QFile强的地方。写用户配置、写索引文件这类不能丢的场景我都改用QSaveFile了。唯一的代价是磁盘上会短暂存在一个临时文件以及写大文件时多一次数据拷贝这个成本换取数据安全我觉得完全值得。5. 一套能直接用的读写封装5.1 接口设计为什么返回 bool 而不是抛异常Qt 本身的风格是不用异常open()返回 bool错误信息放在errorString()里。顺着这个风格走封装的接口应该是这样的class TextFileHelper { public: static bool readAll(const QString path, QString out, QString* err nullptr); static bool writeAll(const QString path, const QString text, QString* err nullptr, bool atomic true); static bool readLines(const QString path, const std::functionbool(const QString, int) handler, QString* err nullptr); };三个设计考虑。第一错误信息用出参QString*而不是返回值这样调用方可以选择不关心传nullptr也可以选择弹个对话框给用户看。第二逐行读用回调而不是返回列表避免调用方被迫把整个文件装进内存。第三回调返回 bool 表示是否继续这样调用方可以在找到目标后提前中止不用把剩余行也跑一遍。5.2 核心实现编码嗅探加逐行回调bool TextFileHelper::readLines( const QString path, const std::functionbool(const QString, int) handler, QString* err) { QFile file(path); if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { if (err) *err QString(无法打开文件 %1: %2) .arg(path, file.errorString()); return false; } QTextStream in(file); // 编码选择优先 UTF-8兼容 Qt5/Qt6 #if QT_VERSION QT_VERSION_CHECK(6, 0, 0) in.setEncoding(QStringConverter::Utf8); #else in.setCodec(UTF-8); #endif int lineNo 0; while (!in.atEnd()) { QString line in.readLine(); lineNo; // 处理 UTF-8 BOM 落在首行的情况 if (lineNo 1 line.startsWith(QChar(0xFEFF))) { line.remove(0, 1); } if (!handler(line, lineNo)) { break; } } if (in.status() ! QTextStream::Ok) { if (err) *err 读取过程中出现错误; return false; } return true; }这里有两个细节值得说。第一首行 BOM 的处理。就算你指定了 UTF-8 编码BOM 字节也可能被解码成UFEFF字符留在第一个QString里所以要在第一行主动剥掉。QChar(0xFEFF)就是那个零宽不换行空格。第二in.status()的检查。atEnd()返回 true 不代表读成功了也可能是读失败了。读取中途磁盘出错、文件被其他进程截断都会让流进入ReadPastEnd或者ReadCorruptData状态。检查一下这个状态能帮你区分文件真的读完了和读到一半挂了。写入侧的实现bool TextFileHelper::writeAll(const QString path, const QString text, QString* err, bool atomic) { QByteArray payload text.toUtf8(); payload.prepend(\xEF\xBB\xBF); // 按需决定是否加 BOM if (atomic) { QSaveFile file(path); if (!file.open(QIODevice::WriteOnly)) { if (err) *err file.errorString(); return false; } if (file.write(payload) ! payload.size()) { if (err) *err file.errorString(); file.cancelWriting(); return false; } if (!file.commit()) { if (err) *err file.errorString(); return false; } } else { QFile file(path); if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) { if (err) *err file.errorString(); return false; } if (file.write(payload) ! payload.size()) { if (err) *err file.errorString(); return false; } file.close(); } return true; }注意file.write()的返回值检查。write返回实际写入的字节数磁盘满的时候它会小于你传入的大小而不是返回 -1。不检查这个返回值就会出现程序说保存成功文件其实是残缺的这种最恶心的问题。另外失败路径上要调cancelWriting()否则临时文件会残留在磁盘上。5.3 键值对配置文件的读写封装实际项目里最常写的文本格式就是keyvalue。解析逻辑不复杂但有三个地方容易漏。bool parseIniLike(const QString text, QHashQString, QString out) { const QStringList lines text.split(\n); for (const QString rawLine : lines) { QString line rawLine.trimmed(); if (line.isEmpty() || line.startsWith(#) || line.startsWith(;)) continue; // 跳过空行和注释 int pos line.indexOf(); if (pos 0) continue; // 没有等号或等号在第一位 QString key line.left(pos).trimmed(); QString value line.mid(pos 1).trimmed(); // 去掉可能存在的成对引号 if (value.size() 2 value.startsWith() value.endsWith()) value value.mid(1, value.size() - 2); out.insert(key, value); } return true; }第一注释识别要放在切分等号之前否则# ab这种注释行会被当成合法配置项。第二pos 0同时挡住了两种非法情况没有等号以及等号出现在开头说明 key 是空的。第三值里的前后空格要trimmed但值内部不能动因为路径里带空格是很常见的。写回的时候我倾向于按 key 排序输出这样生成的文件 diff 起来干净用版本管理工具追踪配置变更时不会满屏乱跳。这个小习惯在多人协作的项目里能省不少沟通成本。6. 出问题时按这个顺序排查6.1 文件打不开先看 errorString打不开是最高频的问题而九成的人第一反应是加qDebug()打印路径而不是看错误信息。其实 Qt 已经把原因写好了if (!file.open(QIODevice::ReadOnly)) { qDebug() 路径: file.fileName(); qDebug() 存在: QFileInfo::exists(file.fileName()); qDebug() 错误: file.errorString(); qDebug() 错误码: file.error(); }errorString()通常会直接告诉你是Permission denied还是No such file or directory。配合几个辅助判断能快速定位错误信息关键词常见原因处理方式No such file or directory路径拼错、工作目录不对、目录未创建打印绝对路径核对写之前 mkpathPermission denied文件只读、被占用、写在系统目录改路径到 AppDataLocation检查文件属性Too many open files句柄泄漏循环里 open 没 close检查循环内的 QFile 生命周期无错误信息但 open 返回 false目录而不是文件、路径过长用 QFileInfo::isFile() 判断还有一个隐蔽情况路径指向的其实是个目录。QFile::open对一个目录返回 false但错误信息可能不太直观。加一句QFileInfo(path).isDir()判断就能确认。6.2 内容不对乱码、截断、空行丢失乱码基本只有一个原因读写两端编码不一致。排查顺序是先用十六进制工具看一眼文件真实字节有没有 BOM、中文是几个字节再确认读的时候指定的编码最后确认写的时候指定的编码。三者对上就不会乱。内容截断通常发生在读入大文件时。如果代码里用了QString::fromLocal8Bit(data.constData())这种 C 风格接口遇到文件里包含\0字节就会被提前截断——constData()转成const char*后长度信息丢失全靠\0判断结尾。正确写法是用带长度的重载QString::fromUtf8(data.constData(), data.size())或者直接用QString::fromUtf8(data)。涉及中文的文本文件通常不会有\0但二进制混合文本的场景下这是真会发生的。空行丢失多数和换行符处理有关。如果文件是\r\n而你按\n去 split每行末尾会多出一个\r如果做了trimmed()那看起来空的只含\r的行就会被当成空行处理掉。要精确保留原结构处理过程中别随手 trim。6.3 写完了但文件没变三种典型情况第一种没 flush 也没 close。前面讲过QTextStream的缓冲机制程序在循环里写了几万行然后被强制结束缓冲区里剩下的内容就全丢了。解决办法是只在关键节点用缓冲或者干脆每次写完显式 flush。第二种析构顺序反了。典型写法是QFile声明在函数内部、QTextStream的指针被存到了成员变量里函数返回后文件关了流还在后续写入全部失败但流本身不会报错。第三种写到了另一个位置。程序被安装到Program Files下运行时写当前目录会被系统的重定向机制悄悄挪到用户目录你在预期位置自然找不到文件。这类情况用QFileInfo(file).absoluteFilePath()打印真实路径就能确认然后再决定是不是该改用QStandardPaths。我在实际项目里的做法是所有涉及文件写入的函数入口处统一打印一行绝对路径的调试信息出问题的时候一眼就能看出程序到底动了哪个文件。这个习惯帮我省掉的排查时间比它增加的日志量多得多。
返回列表