资讯详情

资讯详情

Qt JSON读写实战:从解析到序列化,避开中文乱码与性能坑

做Qt开发的人绕不开JSON这个东西。不管是写配置文件、解析网络接口返回还是跟前端联调数据格式JSON基本已经成了默认的数据交换语言。QJsonDocument、QJsonObject、QJsonArray这几个类名你随便打开一个Qt工程都能看到。但真正用起来坑其实不少中文乱码、解析失败不报错、嵌套层级取不到值、大文件卡顿……这些我都踩过。这篇就把我实际项目里读写JSON的经验完整梳理一遍从原理到代码再到排查尽量一次说透。1. 先把Qt的JSON模块整体搞明白1.1 四个核心类各自管什么Qt的JSON支持集中在QtCore模块里核心就是四件套QJsonDocument、QJsonObject、QJsonArray、QJsonValue。很多人一开始会被这四个类搞晕其实用一句话就能说清楚它们的关系QJsonDocument是整个JSON文档的载体负责整体解析和序列化QJsonObject对应JSON里的花括号对象QJsonArray对应方括号数组QJsonValue是万能容器它可以装对象、数组、字符串、整数、浮点数、布尔值甚至空值。打个比方QJsonDocument就像一整本书QJsonObject是书里的章节QJsonArray是章节里的一组列表QJsonValue则是书页上的任意一块内容——可能是文字、可能是图表、也可能是另一个章节的引用。这四个类嵌套组合就能表达任意复杂的JSON结构。1.2 为什么是 Document/Value 这种分层设计如果你用过其它语言的JSON库可能会觉得Qt这套设计有点啰嗦——比如Python里json.loads直接返回字典访问起来很直接。Qt故意分成两层是有原因的。QJsonValue提供了类型的动态判别和隐式转换让上层代码可以写出很灵活的访问逻辑QJsonDocument则负责底层的内存管理和整体序列化比如把整个文档转成QByteArray、或者从QByteArray解析出来。这个分层最大的好处是你可以在不确定JSON内部结构的情况下先用QJsonValue去试探性地取值取不到也不会导致程序崩溃失败时返回的是一个默认构造的QJsonValue类型是Undefined或者Null。这种设计在实际开发中非常有用。服务端返回的数据结构偶尔会少一个字段、某个字段类型和文档写的不一致如果用强类型解析库轻则抛异常重则直接段错误。用Qt这套代码里只需要判一下类型再转整个容错逻辑能写得非常优雅。1.3 使用JSON模块前需要确认的工程配置先说一个容易被忽略的点。QJsonDocument这些类虽然藏在QtCore里但在老一代qmake工程中如果没在.pro文件里加QT core编译时会出现找不到头文件的错误。现在的CMake工程一般写find_package(Qt6 REQUIRED COMPONENTS Core)然后target_link_libraries里带上Qt6::Core就能解决。对于Qt 5和Qt 6头文件路径完全一致都是#include QJsonDocument所以代码迁移成本很低。如果你在用Qt 6注意QJsonValue的构造函数和类型判断的行为仍然兼容Qt 5但新项目建议直接用QJsonValue::fromVariant处理与QVariant的互转少做重复的类型判断代码。2. 读JSON从字符串到结构化数据的完整套路2.1 从文件读JSON的标准姿势实际项目里JSON数据绝大多数来自文件。我见过不少新手直接QFile file(config.json); file.open(...);然后QString data file.readAll();就去解析了结果中文全乱或者解析失败。问题往往不在解析而在文件打开模式和编码处理。正确的做法如下#include QCoreApplication #include QFile #include QJsonDocument #include QJsonObject #include QJsonArray #include QJsonValue #include QDebug bool readJsonFile(const QString filePath, QJsonDocument doc) { QFile file(filePath); if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { qWarning() 无法打开文件: file.errorString(); return false; } QByteArray data file.readAll(); file.close(); QJsonParseError parseError; doc QJsonDocument::fromJson(data, parseError); if (parseError.error ! QJsonParseError::NoError) { qWarning() JSON解析失败: parseError.errorString() 错误位置在第 parseError.offset 字节; return false; } return !doc.isNull(); }这段代码有三个关键点值得展开。QIODevice::Text模式在Windows上会把换行符做转换对JSON解析本身没太大影响但统一加上可以避免一些跨平台兼容性的隐性问题。QJsonParseError必须带上。很多初学者只用fromJson的返回值忽略错误信息一旦JSON格式有问题程序里拿到的就是一个空文档排查半天也找不到原因。parseError.errorString()会告诉你具体是什么错误比如Missing close brace、Illegal value、Premature end of dataparseError.offset则会告诉你在第几个字节出错。这是定位格式问题的第一手段。doc.isNull()这个判断不能省。即使parseError显示没有错误文档本身为空也可能是正常情况。比如一个空文件、或者内容是null解析出来的文档调用isNull()会返回true此时直接取对象会得到一个空的QJsonObject至少在逻辑上不会崩但如果不判断后续操作容易摸不着头脑。2.2 从字符串解析时的编码陷阱很多时候JSON的数据不是从文件来的而是从网络接口、数据库字段或者进程间通信里拿到的一个字符串。这时最容易出问题的就是编码。C的std::string本身不携带编码信息如果你拿到一个字节串先往QString里塞又转来转去编码很容易被破坏。推荐的做法是始终以QByteArray作为分界线。文件读取、网络socket读取拿到的原始字节都保存在QByteArray里直接丢给QJsonDocument::fromJson解析。只有在展示给用户或者需要QString操作的场景才把取出来的值转成QString。如果你手里的数据源是一个QString先把它的UTF-8字节提取出来QString jsonString ...; // 从某个地方拿到的字符串 QByteArray jsonBytes jsonString.toUtf8(); // 转成UTF-8字节序列 QJsonParseError err; QJsonDocument doc QJsonDocument::fromJson(jsonBytes, err);如果源字符串本身是GBK编码而你在界面上看到它是乱码的那问题在更上游的转换逻辑需要先把GBK转成UTF-8用QTextCodecQt 6建议用QStringConverter而不是在JSON解析这层凑合解决。2.3 核心读取技巧对象、数组与嵌套取值解析出一个QJsonDocument之后第一件事是判断顶层是对象还是数组QJsonObject rootObj doc.object(); // 顶层是对象 {} QJsonArray rootArr doc.array(); // 顶层是数组 []然后就是按key取值的常规操作。这里有一个我强烈推荐的写法不要一次写一大串obj[a].toObject()[b].toObject()[c].toString()这种地狱链既难读又难查错。而是先逐层拆出来每层都做一个isEmpty()或isXxx()的判断宁可代码多几行也要保证逻辑清晰、错误可回溯。举个实际的嵌套例子。假设服务器的接口返回长这样{ code: 0, message: success, data: { total: 36, items: [ {id: 1, name: 张三, score: 87.5}, {id: 2, name: 李四, score: 92.0} ] } }对应的读取代码QJsonObject root doc.object(); int code root.value(code).toInt(-1); // 默认值-1用于判断字段缺失 if (code ! 0) { qWarning() 业务错误: root.value(message).toString(); return; } QJsonObject dataObj root.value(data).toObject(); if (dataObj.isEmpty()) { qWarning() data字段缺失或不是对象; return; } int total dataObj.value(total).toInt(0); QJsonArray items dataObj.value(items).toArray(); for (const QJsonValue itemVal : items) { if (!itemVal.isObject()) { continue; // 数组中混入非对象元素跳过 } QJsonObject item itemVal.toObject(); int id item.value(id).toInt(); QString name item.value(name).toString(); double score item.value(score).toDouble(); // 处理每个条目 }这个写法有几个细节很多人会忽略。QJsonValue::toInt(int defaultValue)这个重载会返回默认值当key不存在或者类型不对时非常有用。QJsonValue::isObject()先判断再转换可以防止数组里混入数字或字符串时直接转换出空对象。跳过异常元素的策略比中断整个流程更合理——一个坏数据不应该让整个批量任务挂掉。2.4 一个实用的安全取值封装项目里JSON字段多的时候老是写判断和默认值也挺烦。我习惯封装一个小工具函数专门做安全取值QJsonValue safeValue(const QJsonObject obj, const QString key, QJsonValue::Type type) { QJsonValue v obj.value(key); if (v.type() type) { return v; } return QJsonValue(); }用的时候在业务代码里自己判断isUndefined()或者直接给默认值。这个小封装不解决所有问题但能统一一大半的取值逻辑。更彻底的做法是写一个模板函数自动做类型转换和默认值处理但那就有点过度设计了够用就好。3. 写JSON构造、序列化与落盘3.1 从零构造常见的JSON结构写JSON比读JSON直观一些创建QJsonObject往里insert键值对嵌套的时候再塞QJsonObject和QJsonArray最后放进QJsonDocument完事。但实际的代码水平和代码风格在“可维护性”上差别巨大。举一个构造配置文件的例子目标生成如下结构{ app: { name: demo, version: 1.0.0, window: { width: 1024, height: 768, fullscreen: false } }, modules: [network, database, ui] }先看比较差的写法QJsonObject config; config.insert(app, QJsonObject{ {name, demo}, {version, 1.0.0}, {window, QJsonObject{ {width, 1024}, {height, 768}, {fullscreen, false} }} }); config.insert(modules, QJsonArray{network, database, ui});这个写法简洁是简洁但如果你要动态决定是否包含某个模块比如根据用户勾选的选项生成modules列表这种一次性初始化的写法就没法处理。更推荐的写法是分步骤构造让每一步都清晰可控QJsonObject config; // 构建内部window对象 QJsonObject winObj; winObj.insert(width, 1024); winObj.insert(height, 768); winObj.insert(fullscreen, false); // 构建app对象 QJsonObject appObj; appObj.insert(name, demo); appObj.insert(version, 1.0.0); appObj.insert(window, winObj); // 构建modules数组 QJsonArray modules; modules.append(network); if (enableDatabase) { modules.append(database); } modules.append(ui); // 组装root config.insert(app, appObj); config.insert(modules, modules);分步构造的好处是在每个环节都能插入逻辑判断。实际项目里JSON结构往往要根据业务情况动态调整写死在一个初始化列表里改起来很痛苦。这个习惯我从早期项目吃了几次亏之后才彻底改过来。3.2 序列化紧凑格式与缩进格式的应用场景QJsonDocument转换成QByteArray有两种方式toJson()和toJson(QJsonDocument::Indented)。不带参数或者说Compact模式产出的是一行紧凑的JSON没有空格和换行。适合存文件、走网络、存数据库省空间。Indented模式带2个空格缩进和换行适合程序员自己查看、调试、人工维护的配置文件。QJsonDocument doc(config); QByteArray compactData doc.toJson(QJsonDocument::Compact); QByteArray indentedData doc.toJson(QJsonDocument::Indented);如果这个JSON要交给外部系统做diff、做版本管理建议用Indented如果主要是程序自己读用Compact就行。需要注意的另一点是toJson在内部会做一次完整的序列化往返如果JSON文档很大、并且你只是临时要看一下调试信息直接用qDebug() doc在Qt里已经能显示格式化后的JSON字符串了不必再显式toJson省得浪费内存。这里贴一个典型的内存排序QString的toUtf8()和toJson都会拷贝完整的数据。当JSON体量到几十MB级别时这种拷贝会带来明显延迟后面我会单独说性能问题。3.3 写文件时的编码处理这一步是中文乱码的重灾区。我见过无数人写完JSON文件用记事本或编辑器打开一看中文全乱。根本原因在于toJson()返回的QByteArray是UTF-8编码的但很多老代码习惯先转成QString再操作文件流结果在转码或流操作过程中被悄悄改了编码。最稳妥的写文件方式bool writeJsonToFile(const QJsonDocument doc, const QString filePath) { QFile file(filePath); if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) { qWarning() 无法写入文件: file.errorString(); return false; } QByteArray data doc.toJson(QJsonDocument::Indented); qint64 written file.write(data); file.close(); return written data.size(); }注意这里write接收的是QByteArray而不是QString。直接写入UTF-8字节就不会有任何编码转换问题。如果实在需要用QTextStream写务必显式设置编码QTextStream stream(file); #if QT_VERSION QT_VERSION_CHECK(6, 0, 0) stream.setEncoding(QStringConverter::Utf8); #else stream.setCodec(UTF-8); #endif stream doc.toJson(QJsonDocument::Indented).constData();不过我的经验是能用QFile::write(QByteArray)就别绕QTextStream这层。少一层就少一次出问题的机会。3.4 手工拼JSON字符串为什么万万不可有的老代码里会看到这种写法QString json {\name\:\ name \,\age\: QString::number(age) };如果只是本地自用这个写法还能跑通。但只要某个字段的值里带了个引号、换行或者反斜杠这个JSON就废了。更别提如果字段值本身是用户输入这就是一个妥妥的注入漏洞——往JSON里塞任意内容。Qt明明提供了完整的JSON构造类还自己拼字符串属于给自己挖坑。遇到这种老代码我建议尽快重写为QJsonObject构造。4. 一网打尽读写JSON的高频问题排查4.1 中文乱码到底是谁的问题按我的经验中文乱码95%出在“读写文件编码”这层而不是JSON解析本身。因为Qt的JSON解析器是直接把UTF-8字节序列解析成内部Unicode字符串的只要源数据是规范UTF-8解析出来就是正确的。乱码问题一般发生在这几个位置文件本身是GBK编码但解析时按UTF-8处理。症状是中文变成一串乱码字符甚至解析报错。解决先搞清楚源文件是什么编码用QTextCodec转码后再解析。写入时把QString转成了QString的toLocal8Bit()或者旧系统编码。解决一律toUtf8()。Windows下QFile没有以Text模式打开导致换行被处理而部分编辑器显示乱码。解决打开文件时加上QIODevice::Text。这类问题排查思路很简单先不看代码用记事本或VS Code打开源文件看编码格式和实际内容再在代码里把读出来的QByteArray打印成十六进制对比中文字符的UTF-8字节序列很快就能锁定问题在哪一层。4.2 解析失败但不知道错在哪QJsonParseError的errorString()在绝大多数情况下能告诉你具体问题但常见的情况是——它的提示比较底层比如Missing close brace你看着一长串JSON还是不知道哪里少了花括号。我常用的定位方法把parseError.offset对应的上下文打印出来。假设offset 12345把原始字节序列从11900截到12800前后各留一部分上下文基本就能看到问题点。if (parseError.error ! QJsonParseError::NoError) { int begin qMax(0, parseError.offset - 100); int end qMin(data.size(), parseError.offset 100); QByteArray context data.mid(begin, end - begin); qWarning() 错误附近内容: context; }这个方法帮我排查过好多次线上问题特别是那种数据来自第三方、格式随机的场景。4.3 字段类型和预期不一致怎么办服务端把数字返回成字符串、把空数组返回成null这类接口不规范问题在真实项目里非常常见。Qt的写法是QJsonValue::toInt()、toDouble()、toString()这些方法不会因为类型不对而崩溃而是返回默认值所以程序不会挂。但风险在于你判断字段是否存在时用的是contains()或者value()而类型不匹配的字段依然会返回true导致你拿到一个错误类型的值继续往下跑。建议的处理方式是在取值后立刻断言类型QJsonValue v obj.value(score); if (!v.isDouble() !v.isString()) { qWarning() score字段类型异常: v.type(); return; } double score v.toDouble();这里v.type()返回的是QJsonValue::Type枚举打了日志之后就能明确是哪个字段、声明是什么类型。日志里尽量带上字段名和当前JSON的上下文后续排查快很多。4.4 大JSON文档的性能处理思路如果一条JSON有几十上百MB直接fromJson然后反复value()、toObject()性能会很难看。原因在于Qt这套API在访问嵌套数据时会有对象拷贝和QVariant转换的开销而且QJsonDocument内部数据是扁平化存储的每次toObject()都可能生成一个新的临时对象。对于大JSON我有几个实际建议能用QJsonDocument::fromJson一次解析就避免重复解析同一份数据。不要循环里解析一遍循环外用一遍。能直接对QJsonArray做范围遍历的就不要把它转成QVariantList再遍历。如果JSON数据来自网络且体量很大考虑用流式解析方案Qt有QJsonParseError的错误位置还不够说明它本身是整包解析的。真要追求流式就得引第三方库或者换语言。但在常规业务里先判断文档尺寸超过一定阈值就走二进制序列化方案比在JSON上硬优化更值。写文件时如果数据特别大可以在内存里先构造好QByteArray再一次性写避免多次小IO。4.5 一些容易忽略的行为细节有几个行为细节容易被忽略我单独提一下。QJsonObject的insert顺序不保证和写入顺序一致。在Qt 5里QJsonObject内部用一个哈希表来加速查找所以遍历时字段顺序可能是乱的。如果你需要严格的字段顺序比如某些接口对顺序敏感Qt 6里QJsonObject做了改进行为更接近std::map按key排序。如果顺序非常重要那要么改用数组存储有序键值对要么换用其它序列化格式。QJsonDocument::isNull()和isEmpty()的区别。isNull()表示文档没有内容isEmpty()反而可能表示它有内容但内容是空的例如空对象{}。别搞混否则判断逻辑会莫名失效。QJsonObject里的key重复问题。同一个QJsonObject里如果insert两个相同的key后面的值会覆盖前面的值不会报错。如果你不确定上游会不会给重复key自己做个检查逻辑避免数据被悄悄覆盖。5. 实战从配置到接口两个能直接改着用的场景5.1 场景一读写程序配置文件桌面程序常见的需求是启动时读配置、退出时写配置。配置项包括窗口大小、语言、主题、最近打开的文件列表。用JSON做配置文件比ini更灵活比XML更简洁。我习惯封装两个函数一个是加载配置合并到默认配置一个是保存当前配置到文件。关键点是加载时不要直接用磁盘的JSON覆盖所有默认值而是以默认配置为底用磁盘值逐项覆盖。这样未来版本新增配置项时老用户升级程序不会因为缺字段而崩。QJsonObject loadConfig(const QString path, const QJsonObject defaults) { QJsonDocument doc; if (!readJsonFile(path, doc) || doc.isNull()) { return defaults; // 读取失败用默认值 } QJsonObject diskObj doc.object(); QJsonObject merged defaults; for (auto it diskObj.begin(); it ! diskObj.end(); it) { merged.insert(it.key(), it.value()); // 磁盘值覆盖默认值 } return merged; }保存时注意原子性。直接写原文件万一写入一半程序崩溃配置文件就废了。稳妥的做法是先写临时文件再QFile::rename替换或者至少先备份原文件再写。5.2 场景二解析网络接口返回的列表数据客户端请求一个列表接口返回的分页数据大概是{ code: 200, data: { list: [ ... ], hasMore: true, nextCursor: abc123 } }处理逻辑里除了解析列表数据还要根据hasMore判断是否继续加载根据nextCursor拼下一页请求。这类代码我建议封装成一个结构体不要到处散落着解析逻辑struct PageResult { QVectorQJsonObject items; bool hasMore; QString nextCursor; }; PageResult parsePage(const QJsonObject obj) { PageResult result; QJsonObject dataObj obj.value(data).toObject(); QJsonArray list dataObj.value(list).toArray(); for (const QJsonValue v : list) { if (v.isObject()) { result.items.append(v.toObject()); } } result.hasMore dataObj.value(hasMore).toBool(false); result.nextCursor dataObj.value(nextCursor).toString(); return result; }这里把QJsonObject存起来而不是拆成各个字段是为了保留原始信息灵活性——列表项后续可能加字段上层再按需取值减少解析函数改动。如果你的项目对性能比较看重也可以在这里直接转成自己的数据模型结构体看取舍了。5.3 场景三Qt Quick/C混合架构中的JSON传递在QML和C交互时JSON也是个通用数据通道。QJsonDocument转QVariantMap后可以直接塞给QML的model反过来QML回传的数据通过QVariant转成QJsonObject再处理。QVariantMap toVariantMap(const QJsonObject obj) { QJsonDocument doc(obj); return doc.toVariant().toMap(); } QJsonObject toJsonObject(const QVariantMap map) { return QJsonObject::fromVariantMap(map); }这类互转场景里要小心QJsonValue::Undefined和QVariant中的无效值对应关系。QJsonObject::fromVariantMap会把某些不能映射的值置成空反向转换时也会丢失类型信息。不过做常规数据传递足够用了真遇到复杂嵌套类型还是直接用QJsonObject传递更稳。6. 调试工具链让JSON问题无处遁形6.1 用好Qt自带的Debug输出QJsonDocument重载了operator直接用qDebug()打印文档在以文本形式呈现时是带缩进的调试时比打印原始QByteArray清爽太多qDebug() doc; // 自动格式化对于单个QJsonObject和QJsonArray也一样不用手动转字符串再打。6.2 使用在线校验器和本地脚本辅助当JSON是手工编辑的配置文件时写之前先拿到一个校验器过一遍是个好习惯。本地的Python脚本一行就能搞定python3 -m json.tool config.json如果config.json里格式有错误这个命令会明确告诉你第几行第几列出了问题。比在Qt程序里跑一圈再让用户截图报错信息效率高得多。6.3 在代码里加一个“诊断模式”我在项目里习惯加一个隐藏开关环境变量或者配置项控制是否输出JSON的详细解析日志。开启后每个读写关键点都会输出解析错误信息、错误位置、数据尺寸和耗时。线上出问题时让用户打开这个开关日志一发回来问题通常几分钟就能定位。这个小投入在运维阶段特别值得。7. 避坑经验补充从崩溃到数据损坏的那些事7.1 崩溃类问题最常见的是拿着一个Undefined的QJsonValue继续调用toObject()或toArray()这在某些编译器优化下可能产生未定义行为但多数情况下Qt会返回空对象而不是崩溃。真正容易崩的是你用了裸指针或者自己管理的内存那已经不是JSON解析的问题了。另一种崩溃是QJsonArray在使用迭代器的过程中修改了数组本身。比如for (auto it arr.begin(); it ! arr.end(); it) { arr.removeAt(...); }这种操作在Qt里会导致迭代器失效。解决办法是先把要删除的索引收集起来遍历结束后统一删除。7.2 数据损坏类问题数据损坏最典型的场景就是我前面说的“手工拼字符串”。除了注入问题还有一个隐患浮点数精度。QString::number(score)默认保留6位小数如果你的分数是87.123456789拼出来的JSON里丢了精度。这种问题用QJsonObject构造就完全没有因为toJson时会按双精度完整序列化。损坏还容易出现在“反复读写不检查返回值”的代码里。磁盘满了、权限不对、文件被占用file.write可能返回部分写入或者失败。如果代码不看返回值用户看到的配置文件就会莫名其妙少了半截。我的习惯是写完后校验长度written data.size()并把失败信息打印到日志里。7.3 兼容性经验Qt 5和Qt 6在JSON这块有个细小的差异QJsonObject::value在Qt 5里返回的是QJsonValue不存在的key返回Undefined而QJsonObject本身在Qt 6里的遍历顺序是key排序的。写跨版本代码时不要依赖遍历顺序也不要假设Undefined和Null一定等价。测试时两个大版本都跑一遍最省心。8. 后续还能怎么扩展如果你觉得Qt这套JSON API在某些场景下不够顺手有几个方向可以继续挖一是封装成数据模型层。业务里常用的实体比如用户、订单都转成QJsonObject再写一套模型类管理序列化和反序列化代码会清爽很多。后续换ORM或者换数据源也能少改很多调用点。二是结合QDataStream做二进制性能优化。如果JSON数据量大、传输频繁JSON的文本膨胀和解析开销会积累。可以把频繁交互的结构体先序列化成JSON规范约定好在上层继续保留JSON文本做日志和兜底但内部传输走二进制流解决性能和带宽问题。三是引入Schema校验。复杂项目里接口的字段经常变Qt没有内置JSON Schema校验器如果有需要可以考虑集成第三方库做校验。这样接口给错了字段在入口就能拦下来而不是等业务代码跑一半才发现缺字段。我个人的习惯是JSON在Qt项目里充当的是“人和程序之间”的配置文件、或“程序与程序之间”的调试友好协议。它最大的优势是易读、易调试、工具链丰富。一旦发现JSON在某个环节成了瓶颈果断换掉不必死磕。毕竟工具是为人服务的多试几次就会找到自己最顺手的组合。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →