1. 先从自带示例说起JSON Save Game到底解决什么问题如果你是刚接触Qt没多久的开发者打开Qt 5.14.2的示例列表八成会在“Data Processing”或者“Core”分类下看到这个不起眼的例子——JSON Save Game。我第一次翻到它时心里想的是“这不就是个存档读写吗有什么好学的”但真把这个例子一行行看完、再照着改过一遍之后我改变了自己的看法。它其实是理解Qt JSON模块的最佳入门材料而且它把“对象怎么变成JSON”“JSON怎么变回对象”这条链路讲得清清楚楚。这个示例模拟了一个很典型的游戏存档场景。它定义了一个玩家Player对象里面有名字、等级、经验值还定义了一个关卡Level对象里面有关卡名称、敌人材料并且支持存档列表这种一对多的关系。核心界面就是几个输入框和按钮你填好玩家信息、关卡信息之后点一下Save按钮程序就会把这一堆东西写到一个JSON文件里。Load按钮则负责把文件读回来把界面上的控件重新填上。说白了这个例子的本质是一份内存里的结构化数据如何用一种人类可读、跨平台、跨语言的文本格式把它持久化到磁盘并且能在程序下一次启动时完整还原。它不涉及UI美化不涉及网络同步也不涉及数据库就是纯粹的数据序列化和反序列化。但恰恰是这种“干净”让它特别适合当作教学样板来拆解。那它到底解决了什么问题我觉得至少有三点。第一解决了“程序关闭以后数据去哪了”的问题这是所有需要持久化状态的软件都绕不开的。第二它示范了Qt官方推荐的JSON操作方法包括QJsonDocument、QJsonObject、QJsonArray、QJsonValue这些类的协作方式而不是让你去手动拼字符串或者用第三方库。第三它给你展示了一种“约定式”的字段映射思路对象里的成员变量对应JSON里的哪些键读取时怎么判断键是否存在、类型是否正确。这套思路放到任何需要对接接口、读取配置、保存状态的项目里都能复用。适合谁来读呢如果你有C基础知道Qt的控件、信号槽基本用法但对JSON操作还是一头雾水那这个例子就是为你准备的。如果你已经在用QSettings存配置、用QDataStream存二进制数据但对它们不满意想换成JSON格式这个例子也能给你一个迁移思路。甚至你做的是非游戏项目比如保存用户偏好、导入导出数据、缓存网络响应里面涉及的API和套路也完全通用。1.1 示例里的对象模型三种数据关系一目了然我们先把这个例子的数据模型理清楚。表面上它很简单实际上它刚好覆盖了JSON最常用的三种嵌套关系普通键值对、对象嵌套、对象数组嵌套。玩家对象Player通常会有这几个字段名字QString、等级int、经验值double或者qreal。保存的时候这些字段会变成JSON对象里的字符串和数字键值对。关卡对象Level呢除了一个名称QString还会有一个“敌人列表”QList 之类的结构保存时会变成JSON数组。一个游戏存档文件里既要有玩家信息又要有关卡信息于是最外层就需要一个包含两个子对象的JSON对象比如{ player: { name: Aragorn, level: 10, experience: 2500.5 }, level: { name: Moria, enemies: [Orc, Troll, Balrog] } }这就是一个标准的三层结构最外层是QJsonObject里面的“player”和“level”对应的值又是两个QJsonObject“enemies”对应的值是一个QJsonArray。Qt的JSON API就是围绕这三类容器设计的读和写都是同样的对称结构。我建议你在跑这个示例之前先手动写一个类似的JSON文件放到程序目录里再用示例里的Load按钮去加载它。这样可以直观地看到只要字段名对得上UI就会被填充如果字段名对不上程序会静默跳过或者报错。这种“先改文件、再点加载”的试错方式比直接看代码更容易建立直觉。1.2 为什么官方要选JSON而不是QSettings或QDataStream这个问题我当年疑惑了很久。Qt本身已经有QSettings跨平台存配置特别方便也有QDataStream存二进制数据性能很高。那JSON的价值到底在哪先说QSettings。它确实好用但它的设计目标是“配置”不是“数据交换”。QSettings采用平台相关的存储方式在Windows上是注册表在Linux上可能是INI文件在macOS上是plist。你生成的文件换一台机器、换一个平台未必还能直接读。而且QSettings的API偏向“键值对”虽然也能存嵌套结构但表达列表、对象层级的时候远不如JSON直观。更重要的是用户想手动修改存档、备份存档、或者用其他工具分析存档内容时QSettings文件的可读性很差。再说QDataStream。它的速度确实快序列化C对象几乎是直出二进制但它写出来的文件是Qt特有的格式版本不同很可能不兼容。有一个很常见的坑你用Qt 5.14的QDataStream写入的文件换到Qt 5.15或者Qt 6的环境去读如果流版本号处理不当轻则读到乱数据重则直接崩溃。而且二进制文件没法用文本编辑器打开检查出了问题只能靠调试器。JSON的优势恰恰在这里它是纯文本你可以用记事本打开看它的格式是一种标准任何语言都有解析库它的结构是自解释的键名就是字段名其他人读代码时不需要额外的文档就能理解存档内容。代价是体积会比二进制大一些解析速度慢一些但对于游戏存档这种低频操作来说这点性能损失完全不痛不痒。所以Qt官方用这个例子传达的意图很明确在“需要跨平台、可调试、可迁移”的场景里JSON优先。2. 拆解核心实现QJsonDocument、QJsonObject和QJsonValue的角色分配很多人初次接触Qt JSON模块会被一堆QJson开头的类搞晕。其实核心就四种QJsonDocument、QJsonObject、QJsonArray、QJsonValue。它们的关系我习惯用一个“容器套容器”的类比来理解。QJsonDocument是整个JSON文档的代表。一段完整的JSON文本要么由它创建要么从它解析出来。你可以把它想成一个“文件外壳”负责和字符串、字节数组互相转换。它不直接存储业务字段而是持有一个根对象或根数组。QJsonObject对应JSON里的“对象”也就是花括号包起来的那一段里面是一堆“键值对”。QJsonArray对应JSON里的“数组”也就是方括号包起来的那一段里面是一串有序的值。QJsonValue则是“值的通用包装器”它可能是字符串、整数、浮点数、布尔值、null、对象也可能是数组。正是这个QJsonValue的存在让嵌套变成了可能。写代码的时候你的工作流程一般是创建QJsonObject组装数据 - 创建QJsonDocument承载根对象 - 调用toJson()获得QByteArray - 写入文件。读文件的时候正好反过来读文件获得QByteArray - QJsonDocument::fromJson()解析 - 调用object()或者array()获取根节点 - 逐层取出字段。这里我想强调一个初学者最容易忽略的点QJsonObject和QJsonArray都是“值类型”不是指针类型。你在函数里把一个QJsonObject返回出去赋值给另一个变量内部会发生一次拷贝。这个拷贝是代偿的它的内部使用了隐式共享implicit sharing所以通常不会有明显的性能问题。但如果你在循环里反复拷贝一个大对象还是要稍微小心一点。更好的做法是尽量用const引用去传递例如把QJsonObject::const_iterator传来传去。2.1 写入端从游戏对象到JSON对象的序列化映射写入JSON的过程本质上就是“把C成员变量逐一塞进QJsonObject”。这个操作本身不复杂但有几个细节会直接影响后续读取的稳定性。第一步是创建最外层的QJsonObject。我用不了多久就会习惯给它取一个有意义的名字而不是千篇一律地叫obj或root。接下来把每一个成员变量插入进去插入时要显式指定键名。官方示例的写法通常是QJsonObject gameObj; QJsonObject playerObj; playerObj.insert(name, m_player-name()); playerObj.insert(level, m_player-level()); playerObj.insert(experience, m_player-experience()); QJsonObject levelObj; levelObj.insert(name, m_level-name()); levelObj.insert(enemies, QJsonArray::fromStringList(m_level-enemies())); gameObj.insert(player, playerObj); gameObj.insert(level, levelObj); QJsonDocument doc(gameObj); QByteArray bytes doc.toJson(QJsonDocument::Indented);这里面有几个关键点是我在实际项目中踩过坑之后才真正理解的第一键名的命名策略要统一。你既可以用小写加下划线player_name、level_count也可以用驼峰playerName、levelCount但必须全项目一致。因为JSON解析是大小写敏感的一旦保存时用的键名和读取时用的键名不一致字段就会静默丢失。我在一个项目里吃过这个亏保存端写的是playerName读取端写的是playername结果加载存档后玩家名字永远是空的查了一个下午才定位到。第二insert()时如果键名已经存在会覆盖旧值。这个行为在某些场景下是特性在另外一些场景下是隐患。比如你从旧的存档结构升级到新的存档结构想保留旧字段又不想丢失数据就需要先读取旧对象再在旧对象基础上insert新字段而不是从零创建一个新对象后直接覆盖整个文件。第三关于浮点数和整数的区分。JSON标准里数字只有一种概念但Qt在解析时会根据语法尝试把数字识别为int、double或者bool。保存的时候如果你写的是qreal或者doubleQt会以双精度浮点形式输出如果写的是int就以整数形式输出。读取时你调用toInt()或toDouble()时Qt会做类型转换但前提是内部类型能转得过去。比如一个值是字符串形式的123直接调用toInt()会得到0因为Qt不会自动解析带引号的数字。所以保存时千万别图方便把数字先转成字符串再放进去否则读取时要多做一层转换。2.2 读取端从JSON对象恢复游戏状态的容错策略读取JSON比写入JSON要麻烦得多因为写入端是你自己控制的你可以保证字段一定存在、类型一定正确。但读取端面对的是真实世界文件可能被用户手动改过可能是旧版本留下的可能被传输工具半截截断。所以读取端的核心课题是怎么尽量优雅地处理异常。Qt提供的API里QJsonObject有一个value()方法传入键名后返回一个QJsonValue。QJsonValue又有isUndefined()、isNull()、isString()、isDouble()、isArray()、isObject()等判断方法。官方示例通常的做法是直接取value()然后调用toInt()、toQString()。但如果键不存在value()返回的是默认构造的QJsonValue它的isUndefined()为truetoInt()会返回0toQString()会返回空字符串。换句话说默认行为是“缺失字段静默变成零值或空值”这通常不是我们想要的效果。我常用的容错写法是这样先判断类型再取数据取不到就回退到默认值并打印一条警告日志。const QJsonObject playerObj doc.object().value(player).toObject(); if (playerObj.isEmpty()) { qWarning() 存档缺少player字段使用默认值; } QString name playerObj.value(name).toString(defaultName); int level playerObj.value(level).toInt(1); double exp playerObj.value(experience).toDouble(0.0);注意我在toString()和toInt()里都传了默认参数。QJsonValueRef的这些toXxx方法恰好支持带默认值的重载这是很多教材没提到的。用默认值有一个额外好处即使字段缺失你的程序也能以“安全状态”启动而不是拿着一个半初始化的对象去渲染界面、继续游戏。还有一个容易忽视的坑当一个字段本应是一个对象但文件里写成了一个字符串时value(player).toObject()会返回一个空对象而不是报错。同理value(enemies).toArray()如果遇到的不是数组返回的也是一个空数组。这种“类型不匹配但不崩溃”的行为会让bug潜伏得很深。所以我建议在关键位置加上isEmpty()的判断哪怕只是记一条日志也比在屏幕上看半天“为什么列表是空的”要好。3. 实操中真正要留意的细节文件读写、编码与版本兼容很多人把JSON存档当成“把QJsonDocument转成字符串再写文件”这个理解过于粗糙了。文件I/O这块的坑比想象中要多得多。我在一个跨平台项目里就经历过Windows上一切正常换到Linux上一加载存档就乱码最后发现是换行符和编码策略不一致导致的。3.1 选择QFile还是QSaveFile原子写入的价值先看一个基础问题写文件时用QFile直接open还是用QSaveFileQFile的写法是大家最容易想到的QFile file(save.json); if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) { return false; } file.write(doc.toJson()); file.close();这个写法有一个隐患如果程序在写入过程中被强杀、断电、或者磁盘满了save.json可能只写了一半。下一次启动时程序读到一个损坏的JSON文件轻则丢掉存档重则解析崩溃。虽然游戏存档不是数据库事务但这种“半截文件”的出现概率并不低尤其当存档文件变大之后。QSaveFile就是为了解决这个场景准备的。它的工作方式很聪明先把数据写到一个同目录的临时文件里写完并成功commit()之后才原子性地替换原文件。这样即使中途崩溃原文件要么保持旧状态要么已经变成完整的新状态永远不会出现“半新不旧”的中间态。QSaveFile saveFile(save.json); if (!saveFile.open(QIODevice::WriteOnly)) { return false; } saveFile.write(doc.toJson()); if (!saveFile.commit()) { qWarning() 保存存档失败; return false; }这里有一个我自己反复踩的坑QSaveFile的commit()必须在文件仍然开着的时候调用而且commit之后不能再写入。如果你先调用了close()或者直接让QSaveFile对象出了作用域commit的行为就会变得不可预期。正确姿势是open - write - commit三步一气呵成不要把close()或析构放在中间。在实际项目里我还习惯每次覆盖保存之前先备份旧文件比如把save.json复制为save.json.bak这样一旦玩家发现新存档有问题还能手动退回上一步。这个习惯是从版本管理里偷师的但对存档类功能特别实用。3.2 UTF-8编码、BOM和换行符三个跨平台隐形杀手JSON标准明确要求使用UTF-8编码Qt的toJson()默认也输出UTF-8。这本来没什么好说的但一旦牵扯到Windows平台事情就复杂了。Windows记事本有一个历史遗留行为它保存UTF-8文件时会在开头加一个BOMByte Order Mark也就是EF BB BF这三个字节。Qt的fromJson()在解析带BOM的JSON时Qt 5.14.2这一代通常是能容忍的它会自动跳过BOM。但如果你在解析前自己做了字节流处理比如先校验前几个字节、或者把QByteArray转成QString再转回来BOM就可能变成第一个键名的前缀导致整个解析失败但报错信息却可能模棱两可。我的建议是如果文件来源不可控解析前统一去掉BOM最容易省事。QByteArray raw file.readAll(); if (raw.startsWith(\xEF\xBB\xBF)) { raw raw.mid(3); } QJsonParseError err; QJsonDocument doc QJsonDocument::fromJson(raw, err);换行符的问题更隐蔽。Windows默认换行是\r\nLinux和macOS默认是\n。QSaveFile在Windows上写入时如果你用的是文本模式默认Qt不会像C标准库那样自动转换换行符你写什么字节就是什么字节。由于toJson()输出的是\n最终存档文件在Windows记事本里可能显示成“所有内容挤在一行里”在Notepad里则正常。这不会影响解析因为JSON标准允许\r\n和\n都作为空白字符但“看起来不舒服”本身就是一种体验问题。如果你非常在意跨平台可读性也可以在写完doc.toJson()之后手动把\n替换成\r\n再写入但这会对性能有小幅影响通常没必要。路径问题也是跨平台大头。Windows路径分隔符是反斜杠Linux是正斜杠。写代码时千万不要硬编码“.../xxx/save.json”这种相对路径尽量用QStandardPaths或者QDir::currentPath()来拼路径。Qt的QDir会自动处理分隔符转换但如果你自己用字符串拼接很容易在Linux上写出一个包含“\save.json”的文件名。我见过有人因此debug了一整天最后发现文件名里真的带一个反斜杠字符。3.3 版本字段给未来的自己留一条退路存档格式最怕的是什么是“改动字段结构之后旧的存档全部作废”。只要游戏发过一版正式包玩家手里就有老存档你不可能要求他们删档重来。这时候版本字段就是救命稻草。官方示例里保存的是当前状态没有单独维护版本号。但你只要稍微想一想就知道一旦存档结构变化比如把player的experience字段拆成了attack和defense旧的experience该怎么处理没有版本号你会陷入“用新代码读老文件、字段对不上”的泥潭。我建议在存档最外层固定保留一个schemaVersion字段{ schemaVersion: 2, player: { ... }, level: { ... } }读取时先读出schemaVersion再根据版本号走不同的解析分支。比如版本1的存档没有defense字段读取时就给一个默认值版本2以后才有。这相当于给你的存档格式做了一个“可升级协议”。这个习惯一开始可能会觉得多余但只要你做过一次老存档兼容就会意识到它有多重要。更进一步的方案是每写一版存档时把Qt版本、程序版本也一并写进去排查问题时这些信息能帮你快速判断是“代码升级导致的字段漂移”还是“用户用旧版本程序覆盖了新版存档”。4. 常见问题与排查实录不管示例代码多简单实操阶段总会在各种奇怪的地方卡住。这一节把我见过的、以及在社区答疑时遇到的高频问题总结一下可以当成一份速查表用。4.1 解析失败但QJsonParseError没有报错这是我最常被问到的一类问题。表现是读文件成功fromJson也返回了有效的QJsonDocument但是object()里拿不到预期的字段所有value()返回的都是默认值。排查路径分三步。第一步把读到的QByteArray原样打印出来看看确认文件内容和你预期的一致。有时候文件路径指向了一个和你想的不一样的位置程序读的根本就是另一个旧文件。第二步检查键名是否大小写一致、有没有多空格。JSON样式的自动补全工具经常会把键名加空格比如player写成 player肉眼很难看出来。第三步检查是不是根节点类型弄错了。有人用document.object()取对象但文件最外层其实是个数组这时候object()会返回一个空对象。这种情况QJsonParseError不会报错因为文件本身是合法JSON。遇到这种“没报错但数据不对”的场景最笨也最有效的办法是把这个JSON文件拿来用类似JSONLint之类的工具格式化一遍再用十六进制模式看一眼开头几个字节。很多时候问题的根源就是BOM或者不可见字符。4.2 中文乱码和路径中的中文目录JSON文件的内容里如果有中文写入和读取一般不会乱码因为toJson()输出的就是UTF-8字节流。乱码往往发生在两个环节。第一个环节是直接用了QString::fromLocal8Bit()或者toLocal8Bit()来解释JSON字节流。Qt 5.14.2在非UTF-8的locale环境下用中文字面量时会出现源码编码和运行时解释不一致的问题。解决办法是不要手动转码全程用fromUtf8()或Qt的默认行为来处理JSON字节流。第二个环节是程序想把JSON里的中文内容显示到控件上但UI的编码设置不对。这种情况在Windows下偶尔发生多半不是JSON的问题而是和高层控件之间的转换没处理好。路径里有中文的问题本质上是编码和系统API的相互作用。Qt的QFile内部会自动处理系统编码所以“F:/存档/save.json”这种路径在Windows上通常没问题。但如果你把路径字符串先转换成char再用标准C的fopen去打开文件编码就会变成一场灾难。所以项目里只要涉及文件操作就坚持用QFile、QSaveFile别混用FILE和QFile否则迟早出乱子。4.3 编译遇到“无法打开包含文件QJsonDocument”或链接报错这是个环境问题出现得也很频繁。如果你用的是Qt 5.14.2的某个精简安装包或者手动抠过安装路径JSON模块的头文件可能不在include路径里。检查方式很简单在.pro文件里看有没有QT core在CMakeLists.txt里看有没有find_package(Qt5 COMPONENTS Core REQUIRED)。正常情况下QJsonDocument等类属于QtCore模块不需要额外加core5compat之类的东西。如果你的工程是从旧版本Qt迁过来的还有可能遇到另一个问题Qt 5.14里的QJsonObject::insert()和Qt 6里的同名方法行为差异。Qt 6在大量重构后某些API的返回类型和参数类型有变化代码可能在新版本Qt上编译不过。这时候最快的解决思路是把代码改成更通用的写法比如优先使用QJsonValue(type)构造然后用doc.object().value(key)去访问而不是依赖某个特定版本才有的便捷重载。链接报错的情况相对少见一旦出现多半是编译时QC flags不一致导致的undefined reference。我处理过一例项目里同时引入了Qt5Core.dll和Qt6Core.dll系统加载时选了Qt6的符号导致Qt5编译的模块链接到Qt6的符号上。这种问题靠“卸载其中一个版本”最省心。5. 在自带来例基础上的扩展思路示例本身的代码量不大数据结构也比较基础。但你别小看它把它当骨架可以撑起不少真实的业务需求。这一节分享几个我在实际项目里做过的扩展以及背后的一些权衡。5.1 存档加密、压缩与性能优化当存档涉及玩家隐私或者防止作弊时纯文本JSON就有点不够用了。我的做法是先把QJsonDocument转成QByteArray再做可逆变换最后写文件。最简单的方案是异或加密但强度太低只能防君子不防小人。如果真需要一点强度可以用Qt的QAesEncryption之类的第三方库或者调用系统提供的加解密接口。这里要提醒的是加密一定会增加调试难度一旦密文无法解密你有都没法用文本编辑器补救。压缩则是另一个方向。JSON的冗余度不小尤其是数组里存大量坐标点、道具ID的时候。你可以先用qCompress()压缩再写文件。qCompress()是Qt自带功能使用zlib算法不需要额外折腾第三方库。我实测过一个1.8MB的存档压缩后大约300多KB效果很明显。代价是加载时多花一点点CPU时间但对本地游戏几乎无感。性能优化方面最大的开销通常不在JSON解析本身而在“把JSON嵌套树转换成业务对象”的过程中。如果存档里面有几千个实体对象每个都要从QJsonObject里反复value()查找那确实会有酸爽的耗时。优化的办法有几种其一尽量用QHashQString, QJsonValue的构造方式减少键查找的开销其二把JSON数组批量转成QStringList或者QVector时用QJsonArray::toVariantList()配合QVariant的便捷转换其三真正性能敏感的场景干脆不用JSON改用CBOR或者自定义紧凑格式。但正常游戏存档这些优化听起来高大上实际意义不大我更建议把代码可读性放在第一位。5.2 文本JSON和CBOR什么时候不该用JSONJSON虽然方便但它不是万能的。如果你的存档结构里充满了大量重复的数据比如每个实体的字段名都一样JSON的字段名重复会让文件体积膨胀得很厉害。另一种情况是你需要保存的数据包含二进制内容比如技能图标、键鼠映射的原始字节流这时候把二进制塞进JSON只能靠Base64编码体积会膨胀约33%。Qt提供了QJsonDocument::toBinaryData()和fromBinaryData()这个二进制格式本质上就是Qt对JSON树的一种紧凑序列化它不包含字段名信息体积小、解析快但不跨语言。如果只有Qt客户端既想保留“分层树”的便利性又不想文本体积太大toBinaryData()是一个折中方案。如果还想跨语言、跨平台那可以考虑CBORQt 5.14已经内置了QCborValue、QCborArray、QCborMap。CBOR可以理解为“带类型的二进制JSON”体积和解析速度都要好于文本JSON但调试时没法直接用文本编辑器看内容了。我个人的选择标准是存档需要用户手动修改、社区分享、或者需要和其他语言的服务端交换就选文本JSON纯本地单机游戏、存档体积极大、并且不在乎调试工具不可用就选CBOR或者Qt二进制格式。没有银弹只有权衡。5.3 存档自动化测试为序列化代码加上安全带序列化和反序列化是典型的“对称逻辑”写入端和读取端必须严格一致一旦某一端改了字段名另一端就会悄悄失效。这种bug非常隐蔽因为它不会导致编译错误也不会在UI上立即暴露。所以我强烈建议给存档类加上自动化测试。测试思路不复杂构造一组业务对象调用保存函数生成JSON字节流再调用加载函数把字节流读回来最后逐字段对比。如果所有字段都一致说明这一轮序列化和反序列化是对称的。测试里还能加入一些畸形输入比如随机删掉一个键、把字符串变数字、把数组变对象然后确认程序不会崩溃而是按照预期回退到默认值。在Qt自带的Test框架里写这种测试非常顺手。我有一个习惯每次改动存档结构先跑一遍这组测试再提测。这让我避免了好几次“改了字段名但忘了改读取端”的尴尬。说实在的序列化代码是最不显眼、但一旦出错最折腾人的模块测试就是在给自己买保险。6. 一些实操心法从示例代码到生产级存档功能的距离跑通官方示例很容易但把存档功能真正做成“上线级”中间还差着不少细节。这一个部分我打算分享几条个人的实操心法不一定都来自官方文档但对踩坑很有帮助。第一把“保存成功”和“写入完成”区分开。程序返回给用户的提示应当基于QSaveFile::commit()的结果而不是基于“write没报错”。磁盘满、权限不足、文件被占用这些都会让commit失败。我的习惯是保存完成后弹出一个不经意的状态提示但失败时弹出醒目的错误对话框并把错误信息记录到日志文件。第二手动测试时别忘了“断电模拟”。在写保存逻辑的代码旁边放一个按钮专门用来瞬间写入大文件然后触发程序异常退出。这种极端的测试比做一百次正常保存更有可能发现文件损坏的问题。有人会觉得这多余但做联机游戏的时候玩家随时可能掉线、断电、杀进程存档损坏的后果远比正常场景严重。第三不要过分相信QJsonDocument::fromJson()的errorString()。它只能告诉你“在哪个位置语法错误”但不能告诉你“这个文件是不是新版本的”。所以我在加载流程里一定会做“字段存在性检查”如果player对象为空、level对象为空我宁可报错“存档版本不受支持”也不要静默加载成默认存档。静默加载是存档系统最危险的敌人它会掩盖一切问题让玩家误以为自己的进度丢了。第四存档目录不要随便放在程序可执行文件旁边。在Windows上这可能会触发UAC权限问题在macOS上应用沙盒会禁止你写入安装目录。正确姿势是使用QStandardPaths::writableLocation(QStandardPaths::AppDataLocation)Qt会自动按照各平台规范返回可用路径。路径里没有目录时记得用QDir().mkpath()先创建。第五存档写完后如果需要对外发布要做一次“最小化测试”找一台全新安装操作系统的机器装上程序走一遍“创建存档、退出、重新打开、加载存档”的流程。这一步能暴露出所有和路径、权限、编码相关的坑成本低收益高。我在实际项目里用过很多次这套基于JSON保存游戏状态的方案到后来已经形成了一种条件反射项目里涉及“状态持久化”第一反应就是用QJsonObject搭结构第二反应是加schemaVersion第三反应是写自动化测试。这个顺序几乎从没让我失望过。最后再分享一个小技巧给存档文件加上文件头识别。在我的项目里存档文件开头固定写一段魔数比如“GSV1”接着才是JSON内容。加载时先读文件头不是预期魔数就直接拒绝加载。好处有两个一是能快速判断文件类型不用解析整个JSON才发现格式不对二是未来要切换格式时有魔数加持新旧格式可以共存一段时间。这个小技巧看起来很土但防呆效果一流。