目标读者:已经会用 Qt 写常规桌面程序,开始需要对接后端 REST 接口、管理本地配置/存档文件,但对 JSON/XML 解析还停留在"能跑就行"阶段的 C++/Qt 开发者。
示例环境:Qt 5.12/5.15 + Qt Creator + CMake(你习惯 qmake 也完全没问题,改一下工程文件即可)。
一、问题背景:乱 parse 的代价,通常要到线上才看见
1. "能跑就行"的 JSON 解析,迟早出事
和后端约定好一个 JSON 接口之后,最常见的写法大概是下面这样:
cpp
QJsonDocument doc = QJsonDocument::fromJson(replyData);
QJsonObject obj = doc.object();
QString name = obj["name"].toString();
int age = obj["age"].toInt();
bool ok = obj["ok"].toBool();
刚开始看起来一切正常:
- 接口字段和类型都和文档一致;
- 测试环境数据完全受控。
但等项目跑到一定时间,接口字段被人悄悄加了一两个,或者某个字段类型从 int 改成了 string,或者上线环境返回了错误页 HTML,这种"啥检查都不做"的解析方式就会开始报错甚至崩溃:
- 字段缺失:
obj["age"]返回默认值 0,逻辑以为这是合法数据; - 类型错误:
obj["ok"].toBool()从"yes"变成 0,业务突然全失败; - 整个响应不是 JSON:
fromJson()解析失败,doc.isObject()是 false,你还在调用doc.object()。
这些问题的共同点其实只有一句话:没有把"网络数据永远不可信"当回事。
2. 配置文件一会儿 JSON,一会儿 XML,到底该用哪个?
配置/存档文件这块,更常见的场景是混用:
- UI 层小配置、偏好、窗口位置,用 INI(QSettings 默认格式);
- 业务配置有时是前端写的 JSON,有时是后端导出的 XML;
- 某些老项目甚至还在用自定义文本格式。
结果就是:配置读写逻辑散落在各处,哪里需要就地 new 一个 QSettings 或直接手撸文本解析,久而久之:
- 你自己都搞不清楚哪些配置在哪个文件里;
- 想统一迁移格式(比如从 INI 迁到 JSON)变成了噩梦;
- 稍微一升级版本,老配置立刻不兼容。
3. 大 JSON/XML 一次 readAll,内存曲线肉眼可见
还有一种"看不见"的坑是大文件处理:
cpp
QFile file(path);
if (file.open(QIODevice::ReadOnly)) {
QByteArray data = file.readAll();
QJsonDocument doc = QJsonDocument::fromJson(data);
// ...
}
对几 KB 的配置文件无所谓,但一旦你要处理的是:
- 几十 MB 的服务器返回 JSON;
- 上百 MB 的 XML 日志/报表;
一次性 readAll() + 构造整棵 JSON/XML 树,内存占用会极速飙升,甚至直接被系统干掉。
这一期我们就围绕这些真实问题,从 Qt 的 JSON/XML 能力 出发,搭一套"接口与配置中心"的小工程,顺手把健壮解析、流式读写和配置版本管理一起讲透。
二、核心知识点:Qt 里到底该怎么"对话" JSON/XML
1. QJsonDocument / QJsonObject / QJsonArray:JSON 三件套
1.1 基本结构和常用操作
整体关系可以简单理解为:
QJsonDocument:表示整个 JSON 文档,根可以是对象或数组;QJsonObject:键值对集合,对应{ ... };QJsonArray:有序数组,对应[ ... ]。
典型读写流程:
cpp
// 从字节数组解析
QByteArray raw = reply->readAll();
QJsonParseError err;
QJsonDocument doc = QJsonDocument::fromJson(raw, &err);
if (err.error != QJsonParseError::NoError) {
qWarning() << "JSON 解析失败:" << err.errorString();
return;
}
if (!doc.isObject()) {
qWarning() << "预期 JSON 对象,结果不是";
return;
}
QJsonObject obj = doc.object();
// 安全取字段
if (obj.contains("name") && obj["name"].isString()) {
QString name = obj["name"].toString();
}
构造一个 JSON 对象:
cpp
QJsonObject user;
user["id"] = 123;
user["name"] = "ZhangSan";
user["age"] = 25;
QJsonArray scores;
scores.append(90);
scores.append(85);
scores.append(95);
user["scores"] = scores;
QJsonDocument doc(user);
QByteArray jsonBytes = doc.toJson(QJsonDocument::Indented);
要点:
- 所有数字在 JSON 里都是"数值型",Qt 统一当
double处理,toInt()会做向下取整; contains()+isXxx()是稳解析的第一步;QJsonValue还能isNull()/isUndefined(),可用于区分"字段没给"和"显式给 null"。
1.2 面向对象封装:fromJson / toJson
不要在业务代码里到处写 obj["xxx"],更不要混杂在 UI 控件逻辑里。
合理的做法是,每个业务实体写一个小类,把 JSON 读写逻辑藏进去:
cpp
class User {
public:
int id = 0;
QString name;
int age = 0;
bool enabled = true;
QList<int> scores;
bool fromJson(const QJsonObject &obj);
QJsonObject toJson() const;
};
实现示例(简化):
cpp
bool User::fromJson(const QJsonObject &obj)
{
if (!obj.contains("id") || !obj["id"].isDouble())
return false;
if (!obj.contains("name") || !obj["name"].isString())
return false;
id = obj["id"].toInt();
name = obj["name"].toString();
age = obj.value("age").toInt(0); // 不存在就用默认 0
enabled = obj.value("enabled").toBool(true);
scores.clear();
auto arr = obj.value("scores").toArray();
for (const QJsonValue &v : arr) {
if (v.isDouble())
scores.append(v.toInt());
}
return true;
}
QJsonObject User::toJson() const
{
QJsonObject obj;
obj["id"] = id;
obj["name"] = name;
obj["age"] = age;
obj["enabled"] = enabled;
QJsonArray arr;
for (int s : scores) arr.append(s);
obj["scores"] = arr;
return obj;
}
之后 UI 代码只和 User 打交道,就不用管 JSON 字段怎么命名、类型怎么变了。
2. QXmlStreamReader / QXmlStreamWriter:大 XML 的正确打开方式
2.1 流式读取:QXmlStreamReader
流式读取的好处:
- 不会一次性把整棵 DOM 树放内存,适合大文件;
- 更接近"边读边处理"的数据管道;
典型模式:
cpp
QFile file("menu.xml");
if (!file.open(QIODevice::ReadOnly | QIODevice::Text))
return;
QXmlStreamReader reader(&file);
while (!reader.atEnd()) {
auto token = reader.readNext();
if (token == QXmlStreamReader::StartElement) {
if (reader.name() == "item") {
auto attrs = reader.attributes();
QString text = attrs.value("text").toString();
QString action = attrs.value("action").toString();
// ...
}
} else if (token == QXmlStreamReader::EndElement) {
// 根据 name 判定退出层级
}
}
if (reader.hasError()) {
qWarning() << "XML 解析错误:" << reader.errorString();
}
关键点:
- 用
readNext()驱动状态机,自己维护当前所在元素; attributes()只在 StartElement 里调用,读完一个元素后继续往下读;- 对于嵌套结构,常常需要写递归解析函数(如
parseMenu(reader)再内部读item/submenu)。
2.2 流式写入:QXmlStreamWriter
类似地,写入时也尽量留在流式层面:
cpp
QFile file("menu.xml");
if (!file.open(QIODevice::WriteOnly | QIODevice::Text))
return;
QXmlStreamWriter writer(&file);
writer.setAutoFormatting(true); // 便于人类阅读
writer.writeStartDocument("1.0", true);
writer.writeStartElement("menu");
writer.writeStartElement("item");
writer.writeAttribute("text", "新建");
writer.writeAttribute("shortcut", "Ctrl+N");
writer.writeAttribute("action", "new");
writer.writeEndElement(); // item
writer.writeEndElement(); // menu
writer.writeEndDocument();
写入大 XML 列表时,循环写入子元素,每一个条目处理完就扔掉中间状态,不做 DOM 累积。
3. 错误处理:给自己定一套"健壮 JSON 解析模板"
通用步骤:
-
解析阶段
fromJson(raw, &err)→ 检查err;- 确认
doc.isObject()或doc.isArray()。
-
结构检查阶段
- 对关键字段
contains(); - 类型
isString()/isDouble()/isArray()/isObject(); - 不要对
QJsonValue::type()做复杂判断,直接用isXxx()更直观。
- 对关键字段
-
业务校验阶段
- 数字范围(如年龄不能为负);
- 枚举取值(status 必须在"ok/pending/error"之内);
- 列表长度约束。
可以抽出一个小工具函数:
cpp
template<typename TFunc>
bool checkField(const QJsonObject &obj,
const QString &key,
QJsonValue::Type expectedType,
TFunc handler)
{
if (!obj.contains(key)) {
qWarning() << "JSON 缺少字段:" << key;
return false;
}
const QJsonValue v = obj.value(key);
if (v.type() != expectedType) {
qWarning() << "JSON 字段类型不符:" << key
<< "期望" << expectedType << "实际" << v.type();
return false;
}
handler(v);
return true;
}
使用示例:
cpp
User u;
checkField(obj, "id", QJsonValue::Double, [&](const QJsonValue &v){
u.id = v.toInt();
});
checkField(obj, "name", QJsonValue::String, [&](const QJsonValue &v){
u.name = v.toString();
});
4. JSON / XML / INI:各自的用武之地
简单总结一下:
| 需求场景 | JSON | XML | INI |
|---|---|---|---|
| REST 接口 | 强烈推荐 | 过得去 | 不适用 |
| 小型配置(偏好、窗口) | 可以 | 过度 | 非常合适 |
| 复杂结构配置(层级多) | 很合适 | 很合适 | 不合适 |
| 很大的结构化数据 | 一般(需流式) | 适合(流式好) | 不适合 |
| 人类可读 | 好 | 一般 | 很好 |
| 生态与文档 | 非常丰富 | 历史悠久 | 限于配置领域 |
粗暴一点的建议:
- 网络协议优先 JSON;
- 简单配置优先 INI(QSettings);
- 需要与其他语言/老系统兼容且有大文件处理需求时用 XML。
三、代码实战:接口模拟与配置中心(ProtocolDemo 工程)
下面这个工程叫 ProtocolDemo,它做三件事:
- 接口模拟 :用
ApiClient构造一段 JSON,按"网络响应"的逻辑解析为User对象列表,并展示到表格中; - 配置中心:当前主窗口的一些状态(比如配置格式选项)序列化到 JSON 或 INI;
- XML 菜单:从 XML 文件加载一组菜单项,动态生成菜单栏。
目录结构这次不写了,这里直接进入各模块设计和关键代码。完整工程代码可以找我要,后期会上传到资源。
1. User:面向对象 JSON 映射
上面已经展示了一个 User 的实现思路,这里重点强调几点实践要点:
- 所有字段默认值在类定义时给出,确保部分字段缺失时仍然有合理状态;
fromJson()返回bool,外部可以根据失败与否做回退;toJson()只负责数据结构转换,不做业务逻辑。
在工程中,我们会在 ApiClient 里组合使用它,把后端返回的数据"翻译"成 QList<User> 再给 UI 使用。
2. ApiClient:模拟 REST API + 健壮 JSON 解析
ApiClient 的职责:
- 提供
fetchUsers()接口,模拟发起一个 HTTP GET 请求; - 提供
loadExampleData(),直接使用本地构造的 JSON,方便离线测试; - 对响应数据做严格 JSON 检查,填充
QList<User>; - 通过
dataLoaded()、errorOccurred()信号把结果抛给 UI。
在真实项目中,把 createExampleJson() 换成网络请求即可。
你可以在 MainWindow 里这么用:
cpp
connect(fetchBtn, &QPushButton::clicked,
this, &MainWindow::onFetchUsers);
void MainWindow::onFetchUsers()
{
m_apiClient->loadExampleData(); // 或 m_apiClient->fetchUsers();
}
void MainWindow::onDataLoaded()
{
auto users = m_apiClient->users();
populateTable(users);
}
3. XmlDataParser:用流式解析搭建菜单栏
XmlDataParser 做的事情,是把一份类似下面的 XML:
cpp
<menu>
<item text="新建" shortcut="Ctrl+N" action="new"/>
<item text="打开" shortcut="Ctrl+O" action="open"/>
<separator/>
<submenu text="工具">
<item text="选项" action="options"/>
</submenu>
<separator/>
<item text="退出" shortcut="Ctrl+Q" action="quit"/>
</menu>
解析成 Qt 的 QMenu + QAction:
cpp
QMenu *menu = m_xmlParser->loadMenuFromXml("menu.xml");
menuBar()->addMenu(menu);
connect(menu, &QMenu::triggered, this, &MainWindow::onMenuTriggered);
优势:
- 菜单结构完全由 XML 描述,不需要在 C++ 里硬编码所有菜单项;
- 将来业务要支持"用户自定义快捷操作列表",只要出一份 XML 编辑器或者手动配文件即可。
4. ConfigManager:JSON / INI 配置统一入口
ConfigManager 的设计思想:
- 对外屏蔽具体存储格式,统一用
QVariantMap表达配置; saveConfig(section, map, path, format)/loadConfig(...)提供通用读写;- 自己内部决定用 JSON 还是 INI 或其他格式;
- 给某些常见场景提供快捷函数,比如"保存窗口状态/列宽/用户偏好"。
同时,它还负责写入版本号:
cpp
config["version"] = 1;
config["timestamp"] = QDateTime::currentDateTime().toString(Qt::ISODate);
以后版本升级时,只要检查这个 version 字段,就能做到兼容性判断。
5. MainWindow:把这些"协议能力"拼成一个可视小工具
MainWindow 做的事情:
- 顶部一块表格(
QTableWidget)显示用户列表; - 中间一排按钮:模拟接口请求、保存配置、加载配置;
- 底部一块日志窗口,打印解析过程中的错误和提示;
- 菜单栏用 XML 配置动态生成。
整体就像一个"协议实验台",你随时可以换掉 ApiClient 的实现、扩展 XmlDataParser 支持更多元素、给 ConfigManager 引入 YAML/TOML 等新格式,而主窗口基本不用动。
四、实战中的坑与优化:写给"已经踩过坑"的你
坑 1:obj["field"].toString() 不检查字段存在和类型
问题:
cpp
QString name = obj["name"].toString(); // name 不存在 → 返回 ""
int age = obj["age"].toInt(); // age 是 "abc" → 返回 0
你根本没法区分"字段缺失/类型错误"和"真的为 0 或空串"。
建议:
- 对于关键字段,务必
contains()+isXxx(); - 对于可选字段,用
value(key).toInt(default),并标清默认值含义; - 所有解析失败路径要有日志而不是静默吞掉。
坑 2:大 JSON/XML 一次性读入内存
症状:
- 程序处理一个 100MB 的日志 JSON 时,瞬间占用几百 MB 内存;
- Linux 低内存环境下直接被 OOM Killer 干掉。
应对:
- 大 XML:用
QXmlStreamReader/QXmlStreamWriter逐节点处理,而不是 DOM; - 大 JSON:如有可能,让后端改成分页/分块接口,前端只处理当前页;
如果必须处理大 JSON,可以考虑流式解析库(Qt 原生对流式 JSON 支持不强,实际中可以引入第三方),或者改用行分隔 JSON(NDJSON)。
坑 3:配置无版本号,升级时用户一片哀嚎
场景:
- V1.0 的配置里某个字段含义是"是否启用功能 A";
- V2.0 把该字段语义改成"启动模式",但文件名没变,字段名也没变;
- 老用户升级后,配置被照单全收,结果行为完全不符合预期。
解决方案:
- 每一个持久化配置文件必须写 version 字段;
- 每当你想改字段含义时,version += 1,并在加载时判断:
- 比较版本号,小版本按兼容策略迁移(加默认字段、调整值范围......);
- 高版本可提示"此配置由更高版本生成,可能不兼容"。
坑 4:到处 new QSettings / 手动 parse JSON,完全没有"配置中心"
问题:
- 今天在 A 模块里写了一份 JSON 配置,明天在 B 模块里又写了一份 INI;
- 谁都可以随时写文件、改字段,没人知道现在到底有多少种配置格式。
建议:
- 定义一个/少数几个"序列化/配置中心"类(例如本文的
ConfigManager); - 所有和配置相关的读写,都必须通过它们走;
- 禁止其他模块直接
QSettings("xxx.ini")或QFile("config.json"); - 字段名、默认值、版本号全部集中维护。
坑 5:在 paintEvent / QGraphicsItem::paint 里做 JSON/XML 解析
这个在前几期的图形视图/多媒体里也提过,再强调一次:绘制路径必须尽量轻量。
- JSON/XML 解析、文件 IO、网络请求属于"重操作",必须在单独逻辑里做完,把结果缓存到内存结构中;
- paint 只读内存结构直接画,最多做一点简单计算;
- 如果你发现图形界面一堆卡顿,就检查是不是不小心把解析或持久化逻辑放到了绘制路径上。
五、小结:给自己定一套"协议与配置"的工程规范
结合这一期和前几期的内容,最后给出一份可以直接落在项目里的"JSON/XML 协议与配置处理守则":
-
所有 JSON 解析必须经过三个步骤:
- 解析:检查
QJsonParseError; - 结构:确认根是 object/array,关键字段
contains+isXxx; - 业务:做范围校验、枚举取值校验。
- 解析:检查
-
业务代码只操作实体类,不直接碰 QJsonObject/QXmlStreamReader:
- 每个实体类提供
fromJson/toJson、fromXml/toXml(视项目需要); - 协议字段变化时,只要改一处映射代码。
- 每个实体类提供
-
大 XML 一律流式读写,禁止 DOM 方式处理大文件:
QXmlStreamReader/QXmlStreamWriter是大 XML 的首选;- 不要对动辄几十 MB 的文件做
readAll()+QDomDocument。
-
配置必须集中管理、必须有版本号:
- 通过统一的
ConfigManager/SettingsCenter类访问; - 配置文件里必须有 version 字段,升级前后要有迁移逻辑。
- 通过统一的
-
协议/配置 IO 从 UI 和绘制逻辑中剥离:
- API 调用、JSON/XML 解析、持久化统一放在"数据访问层"或"服务层";
- UI 通过信号槽拿到数据对象列表,专心刷新视图。
-
选择格式时优先考虑"团队能力 + 生态":
- REST 接口:默认 JSON;
- 小配置:优先 QSettings/INI,复杂才上 JSON;
- 必须兼容其他系统、对"结构验证"要求高时考虑 XML + XSD。
把这一套规范真正落在项目里之后,你会发现:
- 新增一个接口字段再也不是"惊心动魄的大手术",只是改几行映射代码;
- 配置文件不再散落各处,调试/排查都能一眼看懂;
- JSON/XML 不再是"玄学崩溃源",而是可控的协议边界。
最后,你可以把上面的 ProtocolDemo 工程当成一个"小骨架",直接放进自己的项目仓库,作为接口调试和配置管理的蓝本,后续只需要不断往里面填实体类和业务逻辑即可。