
三种格式对应三种场景,选错代价惨重。存配置文件用QSetting,存文档用JSON,存大数据用SQLite。
一、一次事故
凌晨两点,产线停了。
客户传来的工艺参数文件,我们解析出来全是乱码。小数点后第三位莫名其妙变成了 0.10000000000000001。XML 的 BOM 头没处理,Qt 的 XML 解析器直接报错。
我蹲在车间地上,用手机查了半小时才找到原因:写入用 printf("%.2f") 格式化成字符串,但读的时候用的是 sscanf,本地化设置不同,逗号和点号搞混了。
你说这是序列化的问题吗?
是。也不是。
根子是:我们从一开始就没想清楚,到底该用哪种格式。
项目里 JSON、XML、ini、自定义二进制混着用,同一个配置有人用 QSetting 写,有人用 json 写,还有人直接 memcpy 结构体进文件。最后谁也不知道哪个文件是什么格式,改一个字段要翻三个文件。
如果你也在几个格式之间犹豫不决,这篇就是为你写的。
二、一张表说清三兄弟
先看最核心的比较。后面所有的选择都基于这张表。
| 维度 | JSON | XML | 二进制 |
|---|---|---|---|
| 可读性 | 高(人能看懂) | 中(标签太多) | 无(得用工具看) |
| 解析速度 | 快(~50MB/s) | 慢(~10MB/s) | 极快(~500MB/s+) |
| 体积 | 中(有冗余括号) | 大(重复标签) | 极小(紧凑二进制) |
| Schema 校验 | 无原生(靠外部) | XSD 完整校验 | 全靠代码控制 |
| 元数据支持 | 弱(无命名空间) | 强(命名空间+属性) | 需自建 |
| 流式解析 | 不支持(全量解析) | 支持(SAX) | 全量/流式均可 |
| Qt 原生支持 | QJsonDocument | QXmlStreamReader/Writer | QDataStream |
| 工业标准 | RFC 8259 | W3C 标准 | 无统一标准 |
| 版本兼容 | 好(字段可增删) | 好(XSD 版本化) | 最差(结构定死) |
读这张表的时候,别只看"快"和"小"。大多数项目死在可读性上。
你问我为什么?
因为你的程序不是只跑一次。它要被人维护 3 年 5 年。到那时候,一个你能肉眼直接读的配置文件,和一个必须用 Hex Viewer 才能打开的二进制文件,维护成本差 10 倍。
三、Qt 项目里到底该用哪个
Aether 项目里,三种格式全在用。不是乱用,是每种格式都有自己的地盘。
场景 1:应用配置(QSettings + TOML)
应用级的用户设置,比如窗口位置、上次打开的文件、主题偏好。这些东西的特点是:量小、改得频繁、需要跨平台一致性。
cpp
// QSettings: 不用自己写序列化
QSettings settings("Aether", "App");
settings.setValue("window/geometry", saveGeometry());
settings.setValue("theme", "dark");
// 下次启动
restoreGeometry(settings.value("window/geometry").toByteArray());
QString theme = settings.value("theme", "light").toString();
QSettings 的好处是跨平台自动处理好 Registry(Windows)/ plist(macOS)/ ini(Linux)的差异。你不关心存哪,Qt 帮你管。
但对于结构化配置,比如设备参数、相机曝光时间、IO 通道映射,QSettings 的扁平的键值对就不够用了。Aether 用的是 TOML:
toml
# config/app.toml
[device]
camera_exposure_ms = 30.5
io_trigger_channel = 3
enable_autofocus = true
[network]
mqtt_broker = "192.168.1.100"
mqtt_port = 1883
TOML 比 JSON 更清晰,写注释方便,解析库 toml11 是 header-only,直接包含就能用。Aether 的 config 模块就基于 toml11 做的。
场景 2:文档数据(nlohmann/json + Qt JSON)
什么叫"文档数据"?工艺参数模板、检测方案配置、多工位流程定义。这些东西的特点是:结构复杂、嵌套深、需要人手动编辑或用工具生成。
Aether 的 scheduler 引擎就用 JSON 来定义流程:
json
{
"flow_id": "inspection_001",
"name": "标准检测流程",
"version": "2.1.0",
"steps": [
{"type": "load", "station": "infeed", "timeout_s": 10},
{"type": "inspect", "camera": "cam_01", "algo": "edge_detect"},
{"type": "judge", "threshold": 0.85},
{"type": "unload", "station": "outfeed"}
]
}
用 nlohmann/json 解析非常直接:
cpp
#include <nlohmann/json.hpp>
using json = nlohmann::json;
// 解析
json config;
std::ifstream file("flow_inspection.json");
file >> config;
std::string name = config["name"];
double threshold = config["steps"][2]["threshold"];
// 序列化
json output;
output["result"] = "pass";
output["timestamp"] = QDateTime::currentDateTime()
.toString(Qt::ISODate).toStdString();
std::ofstream out("result.json");
out << output.dump(4); // 缩进4空格,人类友好
Aether 在 common/utility/json_tool/ 下封装了 json_utils.h,提供统一的文件读写接口,处理了文件不存在、目录不存在等边缘情况。还提供了 qt_json_adapters.h,让 QString、QDateTime、QPointF 能和 nlohmann/json 互转。
场景 3:大数据 + 高性能(QDataStream + 自定义二进制)
什么数据走二进制?传感器采集数据、相机图像帧、日志缓存快照。 这些的特点是:量巨大、对实时性有要求、不需要人直接读。
cpp
// QDataStream 序列化结构体
struct SensorFrame {
quint32 timestamp;
float temperature;
float pressure;
quint16 status_flags;
};
QByteArray serializeFrame(const SensorFrame& frame) {
QByteArray data;
QDataStream stream(&data, QIODevice::WriteOnly);
stream.setByteOrder(QDataStream::LittleEndian);
stream << frame.timestamp << frame.temperature
<< frame.pressure << frame.status_flags;
return data;
}
二进制格式的坑在后面讲。这里只记住一条:如果你不确定要不要用二进制,那就别用。
四、Aether 的序列化架构
Aether 没有搞一个"万能序列化引擎"。它的做法是分层管理,每一层选最适合的格式。
应用配置 ─→ QSettings(ini/reg/plist)
↓
结构化配置 ─→ TOML(toml11)
↓
流程定义 ─→ JSON(nlohmann/json)
↓
运行时快照 ─→ JSON + 二进制混合
↓
传感器数据 ─→ QDataStream(二进制)
每一层的接口是抽象的,底层实现可以随时换。
比如持久化层定义了 IFlowPersistence:
cpp
class IFlowPersistence {
public:
virtual ~IFlowPersistence() = default;
virtual void saveInstance(const FlowInstanceSnapshot& snap) = 0;
virtual std::vector<FlowInstanceSnapshot> loadAllRunning() const = 0;
};
当前默认实现是 JsonFilePersistence(就是 JSON 文件)。如果哪天觉得 JSON 太慢,可以换 SqlitePersistence 或 BinaryPersistence,插件式替换,调用方不用改一行。
这就是面向接口编程的实战用法。不是炫技,是给你留条后路。
五、版本兼容设计
序列化最头疼的问题:你今天存的文件,明天的新版本还能读吗?
Aether 的做法分三层。
5.1 文件格式版本号
每个 JSON 结构文件的第一行,永远是一个 version 字段:
json
{
"format_version": "2.1.0",
"flow_id": "..."
}
读文件时,第一件事检查版本:
cpp
json root = json::parse(fileContent);
std::string ver = root.value("format_version", "0.0.0");
if (ver < "2.0.0") {
// 走旧版迁移逻辑
root = migrateV1toV2(root);
}
5.2 向前兼容(Forward Compatibility)
旧版程序能读新版本文件,但会忽略不认识的字段。关键是宽容读取:
cpp
// Aether 的 json_utils 设计
struct JsonParseFileOptions {
bool allow_missing_file = false;
bool normalize_to_object = false;
};
// 读取时,不认识的字段自动忽略
// 新增字段不要影响旧字段的解析
约定:新增字段必须有默认值,不能破坏旧字段的位置。
5.3 向后兼容(Backward Compatibility)
新版程序能读旧版文件。做法是用默认值补缺失的字段:
cpp
// 读取字段,不给就返回默认值
template <typename T>
T json_getValue(const json& data, const string& key_path,
const T& default_val = T());
// 使用
double threshold = json_getValue(step, "threshold", 0.8);
新版本加了 timeout_s 字段,旧文件没有,自动用默认值 30 秒。不会 crash。
5.4 二进制版本的噩梦
二进制格式的版本兼容是最难的。结构体大小一变,所有旧文件作废。
cpp
// 别这么做
struct FrameData {
quint32 timestamp;
float temperature;
// 新版本加了这个:
// float humidity; // ← 加了4字节,所有旧文件偏移错位
};
二进制格式一定要带魔数和版本头:
cpp
struct BinaryHeader {
quint32 magic = 0xA5A5A5A5; // 文件标识
quint32 version = 1; // 格式版本
quint32 data_size; // 数据体大小
quint32 checksum; // 校验和
};
读文件时先验魔数,再验版本,最后才读数据体。版本不匹配就走迁移逻辑。
六、三个致命陷阱(附带代码)
陷阱 1:浮点精度
cpp
// 写入
double value = 0.1;
json["value"] = value; // 序列化成 0.1
// ???读出来变成了 0.10000000000000001
二进制表示上,0.1 本身就没法精确表示。JSON 本身没有这个问题(它存的是文本),但 JSON 解析器把字符串转 double 的时候,精度丢失就来了。
nlohmann/json 默认保留全精度。如果你用 dump(4),它会输出 0.1,但在某些编译器上 JSON 解析器可能会引入微小的精度漂移。
解决办法:比较时用容差,不用 ==:
cpp
bool approxEqual(double a, double b, double eps = 1e-9) {
return std::fabs(a - b) < eps;
}
如果是货币或者关键参数,存整数(毫/微米)而不是小数(毫米)。 比如存 100 表示 100um,而不是 0.1mm。
陷阱 2:大小端
QDataStream 默认是大端(Big Endian,Qt 5 之前),但 x86 是小端(Little Endian)。你不设置字节序,在不同平台上写入和读取就可能翻车。
cpp
QDataStream stream(&data, QIODevice::WriteOnly);
stream.setByteOrder(QDataStream::LittleEndian); // 强制小端
// 这样在 ARM 板子和 x86 之间传输就不会有问题
显式设置字节序,永远不依赖默认值。
陷阱 3:BOM
Windows 下记事本保存 UTF-8 文件,会在文件头加 3 个字节的 BOM(Byte Order Mark):EF BB BF。
Qt 的 XML 解析器遇到 BOM 直接歇菜:
cpp
// ❌ 带 BOM 的文件,Qt XML 解析器报错
QFile file("config.xml");
file.open(QIODevice::ReadOnly);
QXmlStreamReader reader(&file);
reader.readNext(); // 错误!前三个字节是 BOM
解决办法:读文件后跳过 BOM:
cpp
QByteArray data = file.readAll();
if (data.startsWith("\xEF\xBB\xBF")) {
data.remove(0, 3); // 砍掉 BOM
}
QXmlStreamReader reader(data);
nlohmann/json 不介意 BOM,但 QJsonDocument 也不处理。统一去掉最保险。
七、中场回顾(第 5-9 篇的干货浓缩)
这篇是第 10 篇,正好过半。如果你是从头跟过来的,帮你捋一下前 10 篇的完整脉络。
第 1 篇:认知建立
60 万行 C++ 项目的真实崩溃经历。建立了"工业级不等于语法难,等于架构思维"的认知框架。项目三层结构:应用层、插件层、公共基础层。
第 2 篇:插件架构选型
3 种方案对比:编译期静态链接、运行时动态加载(Aether的选择)、混合模式。Aether 选运行时动态加载的理由:团队迭代效率最高。
第 3 篇:ExtensionSystem 拆解
插件生命周期状态机(Invalid -> Read -> Resolved -> Loaded -> Initialized -> Running -> Stopped -> Deleted),依赖解析的拓扑排序,对象池机制。
第 4 篇:IoC 容器
Laravel 风格的依赖注入容器。type_index + shared_ptr<void> 实现类型擦除。三种绑定生命周期(Transient / Singleton / Instance)。ServiceProvider 两阶段引导。
第 5 篇(DataBinding 数据绑定):
ViewModel 属性变更 -> DataBinding -> UI 控件更新的完整链路。Qt 信号槽做不了 MVVM 的深层原因(它不知道"哪个属性变了")。
第 6 篇(中间件管道):
洋葱模型的两条管道:属性变更中间件(日志、校验、审计)和命令执行中间件(权限、事务、异常处理)。
第 7 篇(主题系统):
亮色/暗色一键切换的设计。全局 QSS + 动态属性选择器。颜色 token 化,不写死任何色值。
第 8 篇(权限管理):
基于角色的访问控制(RBAC)。导航权限 / 子页面权限 / 操作权限三级粒度。登录后动态刷新的 grants 机制。
第 9 篇(国际化):
TR() 宏 + ts 文件 + QM 文件的标准 Qt 路径。运行时语言切换而不重启。字符串提取和翻译工作流。
到现在为止,你看到的是一条渐进式路线:
插件解耦(2-3) → IoC 管理(4) → 数据绑定(5)
→ 切面拦截(6) → UI 基础设施(7-9) → 序列化(10→当前)
所有模块最终都依赖"持久化": 数据怎么进来、怎么出去、怎么存。序列化不是独立的功能,是贯穿整个框架的基础设施。
八、互动 + 下期预告
本期互动
翻翻你的项目,回答三个问题:
- 你的序列化格式选型有书面标准吗,还是各自"想用啥用啥"?
- 你上次因为精度问题排查了多久?
- 你项目的配置文件,3 年前的旧版本还能读吗?
评论区说说你的答案,我挑最"惨"的送一次免费序列化架构咨询。
下期预告
Phase 3 正式开启。
从第 11 篇开始,进入C++硬核阶段。先放一张路线图:
| 序号 | 标题 |
|---|---|
| 11 | 类型擦除与模板技巧 |
| 12 | 对象生命周期管理 |
| 13 | Qt 并发与线程安全 |
| 14 | 信号槽深度剖析 |
| 15 | 跨模块通信设计 |
下一期,第 11 篇:类型擦除。
从 shared_ptr<void> 到 std::function 再到虚函数表, 没有反射的 C++ 是怎么做到"持有任意类型"的?我们把 Aether 容器里的模板魔法摊在桌上拆给你看。
硬核模式,准备启动。
💬 评论区聊聊:
你的项目序列化踩过什么坑?二进制文件版本不兼容?JSON 精度翻车?还是 XML 配置写到想哭?评论区见。
🔄 觉得有用?
收藏这篇,下次项目定序列化方案时翻出来对着表选。也转给你团队里还在争论"到底用 JSON 还是 XML"的同事。只看那张表就够了。