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或者double,Qt会以双精度浮点形式输出;如果写的是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()为true,toInt()会返回0,toQString()会返回空字符串。换句话说,默认行为是“缺失字段静默变成零值或空值”,这通常不是我们想要的效果。
我常用的容错写法是这样:先判断类型,再取数据,取不到就回退到默认值,并打印一条警告日志。
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,还是用QSaveFile?QFile的写法是大家最容易想到的:
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文件时会在开头加一个BOM(Byte 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\n,Linux和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()查找,那确实会有酸爽的耗时。优化的办法有几种:其一,尽量用QHash<QString, QJsonValue>的构造方式,减少键查找的开销;其二,把JSON数组批量转成QStringList或者QVector时,用QJsonArray::toVariantList()配合QVariant的便捷转换;其三,真正性能敏感的场景,干脆不用JSON,改用CBOR或者自定义紧凑格式。但正常游戏存档,这些优化听起来高大上,实际意义不大,我更建议把代码可读性放在第一位。
5.2 文本JSON和CBOR:什么时候不该用JSON
JSON虽然方便,但它不是万能的。如果你的存档结构里充满了大量重复的数据,比如每个实体的字段名都一样,JSON的字段名重复会让文件体积膨胀得很厉害。另一种情况是,你需要保存的数据包含二进制内容,比如技能图标、键鼠映射的原始字节流,这时候把二进制塞进JSON只能靠Base64编码,体积会膨胀约33%。
Qt提供了QJsonDocument::toBinaryData()和fromBinaryData(),这个二进制格式本质上就是Qt对JSON树的一种紧凑序列化,它不包含字段名信息,体积小、解析快,但不跨语言。如果只有Qt客户端,既想保留“分层树”的便利性,又不想文本体积太大,toBinaryData()是一个折中方案。如果还想跨语言、跨平台,那可以考虑CBOR,Qt 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才发现格式不对;二是未来要切换格式时,有魔数加持,新旧格式可以共存一段时间。这个小技巧看起来很土,但防呆效果一流。