QT5.14.2自带Examples里面的JSON Save Game这个示例应该是很多Qt初学者最早接触到的和本地存档相关的Demo之一。我当年就是照着这个示例把QJsonDocument的读写逻辑跑通以后才真正理解了数据序列化到底是怎么一回事。如果你最近也在玩Qt想知道官方示例怎么把游戏状态保存成JSON文件、为什么游戏存档要选JSON而不选别的格式那这篇文章基本就是给你写的。这个示例本身不大但它背后牵扯到的知识点挺密JSON对象和数组的构造、QJsonValue的类型判断、文件读写、以及一个非常朴素但重要的思想——把游戏里的一堆分散状态玩家名字、分数、位置、关卡进度整理成一个有结构的文档存盘是结构化导出读盘是结构化还原。我下面会从示例的用途、运行方法、核心代码拆解再到JSON格式的取舍和实际落地的坑一条线给你讲透。1. 这个官方示例到底想教我们什么先说结论JSON Save Game的定位是教学示例核心就三件事——用QJsonObject构造嵌套数据、用QJsonDocument把数据写成JSON文本文件、再从JSON文件读取并还原成游戏对象。它的目的不是给你一个可以直接上线的存档系统而是让你看清Qt里JSON模块的基本用法以及存档这件事本质上是内存对象和磁盘文本之间的双向翻译。示例的场景设计得刻意简单一个可视化的游戏窗口里有块可移动的板或者棋子你可以把它拖到任何位置然后点保存程序会把你当前的分数、玩家名、棋子的坐标等状态打包进一个JSON文件。等你关闭程序再打开点加载它就从这个JSON文件里把状态掏出来把棋子放回原来的位置。整个过程看起来没什么科技含量但它把游戏存档的两个核心环节做全了写出去和读回来。为什么这个示例值得拆因为它强迫你在动手之前想清楚一个问题——游戏里的一个角色、一段进度在内存里表现为各种各样的C对象类成员、指针、容器但文件是平面文本这两者之间根本没法直接画等号。JSON在这里充当的就是一个中间表示格式对象有键值对数组有顺序列表嵌套结构也能自然表达。于是你把一个GameState对象映射成一个JSON对象把内部成员逐个塞到键下面读的时候反过来按键取值再赋值回对象属性一个能够持久化的存档模型就成型了。这个示例适合的人群很明确刚开始学Qt、搞不清楚QVariant和QJsonObject到底什么关系的初学者以及想做一个简单本地存档但不想引入数据库或复杂配置系统的开发者。如果你已经是在做大型在线游戏那套服务端存档这个示例确实简单得不够看但理解它的思维方式对你以后做客户端本地缓存、导入导出配置、甚至写自动化测试的测试夹具都有帮助。2. 先跑起来环境准备与运行示例如果你已经装好了Qt 5.14.2操作很快。打开Qt Creator在欢迎页面左侧的示例Examples搜索框里输入JSON Save Game回车就能看到入口。点进去会打开一个包含工程文件的目录正常情况下列表里会有一个.pro工程文件你用它打开然后选择对应的构建套件比如你装的MinGW或MSVC版本编译运行。第一次跑起来你会看到一个简洁窗口上面有保存和加载按钮中间是棋盘或者可拖拽区域。有几个环节我建议你留意不然容易卡住。第一确认你选的是Qt Widgets还是Qt Quick的版本。同一个示例名在官方包里出现过不同形式老版本里Widgets版本居多窗口里直接用QWidget做棋子位置代码里主要操作QJsonObject有些新一点的示例目录里还会有QML版本逻辑差不多但写法换成了QML里嵌JavaScript操作JSON。如果你跟着博客或网上的教程先看清楚对方切的是哪个版本否则对不上代码很影响心情。本篇我讲的是Widgets版也就是最常见的那版。第二运行时注意JSON文件写到哪了。示例代码里通常用相对路径或者直接写一个固定文件名比如save_game.json这样它会被写到程序当前工作目录。你在Qt Creator里按运行按钮时工作目录默认是构建目录下的子目录所以在源码目录搜不到存档文件别慌去构建目录里找。我见过好几个新手在这卡住以为程序没保存成功其实是文件写到了别的地方。最简单的方法是在程序里加一行qDebug() QDir::currentPath()或者干脆用绝对路径验证。第三别在旧版本和新版本之间反复横跳。Qt 5.15和6.x虽然也带这个示例但编码风格调整过比如QJsonDocument::fromJson的返回值处理、错误字符串的获取方式都略有变化。既然标题说5.14.2那就用5.14.2的环境跑避免遇到为什么这段代码编译不过的版本问题。跑起来以后别急着点保存先拖几下棋子设置一个名字随便加点分数再保存然后用文本编辑器打开生成的文件看看里面的结构。这一步非常重要因为你会直观看到对象序列化成了文本长什么样。打开文件以后你会发现QJsonDocument默认格式化之后的可读性还是可以的缩进清晰键名是你代码里指定的字符串值就是数字和文本。我已经让很多同学干过这个事——自己亲手打开那个JSON文件胜过听我讲十遍理论。3. 核心代码拆解状态对象、序列化与反序列化跑通示例之后就要看代码了。我建议你按数据模型 - 序列化 - 文件读写的顺序去读而不是从头到尾一屏一屏扫。下面这段是我根据示例逻辑提炼出的核心结构具体写法不同版本会有差异但思路是一致的。假设你有一个游戏状态类成员大概长这样class GameState { public: int score; QString playerName; QPointF position; QVariantMap customData; };序列化的任务就是把这样一个对象变成QJsonObject然后交给QJsonDocument去写文件。示例里你大概率会看到类似这样的函数QJsonObject GameState::toJson() const { QJsonObject json; json[score] score; json[playerName] playerName; json[posX] position.x(); json[posY] position.y(); json[customData] QJsonObject::fromVariantMap(customData); return json; }这里面的关键有两点。第一QJsonObject的下标操作符可以直接接受基本类型赋值比如int、double、QString、bool、甚至QJsonValue它会帮你转成对应的JSON类型但像QPointF这种类型就不能直接塞进去得把它拆成两个键存x和y坐标。这个拆开再合并的过程就是序列化里最常见的思路——不是所有C类型都有天然的JSON对应关系你得决定怎么映射。第二QVariantMap可以直接通过QJsonObject::fromVariantMap()转成QJsonObject反过来用toVariantMap()还原这是Qt提供的便利桥梁适合快速处理自定义的、结构动态的键值对但代价是类型信息会弱化所以示例里对明确的成员变量还是逐个手动赋值。反序列化是反过来的过程从QJsonObject里取值赋回给对象属性void GameState::fromJson(const QJsonObject json) { score json[score].toInt(); playerName json[playerName].toString(); position.setX(json[posX].toDouble()); position.setY(json[posY].toDouble()); customData json[customData].toObject().toVariantMap(); }注意这里几乎每个取值都要调用toInt、toString、toDouble这样的转换函数因为QJsonValue是一个变体类型它自己不知道自己底层存的是什么布尔、数字还是字符串只有通过转换函数才能取出明确类型。这一步也是新手最容易翻车的地方如果JSON里某个键缺失调用toInt()不会崩溃但会返回0如果键存在但类型是字符串toInt()也会返回0并且没有错误提示。所以示例代码很乐观它假设存档文件格式是它自己写出去的类型不会错。文件层面的读写示例里一般会用QJsonDocument包装QJsonObject然后save或load文件。相似逻辑浓缩成两段// 保存 QFile file(savePath); if (file.open(QIODevice::WriteOnly)) { QJsonDocument doc(toJson()); file.write(doc.toJson()); } // 加载 QFile file(savePath); if (file.open(QIODevice::ReadOnly)) { QJsonParseError err; QJsonDocument doc QJsonDocument::fromJson(file.readAll(), err); if (err.error QJsonParseError::NoError) { fromJson(doc.object()); } }保存时用doc.toJson()把文档序列化成紧凑的字节数组如果你想要那成可读的格式化文本可以传QJsonDocument::Indented作为参数写出来的文档就是有缩进的。加载时必须带上QJsonParseError否则一旦文件被手残编辑坏了或者编码不对你是不知道在哪一步崩的。拆到这里我想多说一句这个示例最大的教学价值在于它会让你用数据模型 - 序列化 - 文件的链条思考问题而不是到处散布存档逻辑。刚入门的时候我会图省事直接在窗口类的保存按钮槽里写一堆QJsonObject操作最后的结果是代码全搅在一起改个字段要搜半天。正确姿势永远是先定义清楚状态类把JSON转换逻辑收拢到类的toJson和fromJson里界面只负责调用这样无论是加新属性还是换存储格式改动范围都被框在很小的范围里。4. 为什么官方示例选JSON文本存档的取舍你大概会好奇Qt查遍文档并不缺配置文件和序列化手段QSettings背后还支持注册表和INIQDataStream可以做原生二进制序列化为什么官方偏偏挑了一个JSON示例来做存档教学答案不是只有一个。JSON的核心优势是人类可读。这在游戏开发里是隐藏的debug神器。玩家反馈存档坏了你的支持者可以在文本编辑器里打开存档直接看到score是多少、position在哪问题往往一眼就能定位。换成二进制QDataStream写出来的东西打开全是乱码想排查都没法下手。对于开发日志、测试夹具、简单工具软件这种自描述性带来的调试便利是压倒性的。第二个理由是和Qt的接触面小。官方教程喜欢让新手少碰奇技淫巧QJsonDocument这套API已经能覆盖绝大多数存档需求QJsonArray表达物品背包、QJsonObject表达属性字典、嵌套结构表达复杂的游戏世界。写起来直观读起来也直白。你不需要理解二进制对齐、字节序、序列化版本这些偏底层细节自然就能把精力集中在业务逻辑上。但是这不等于JSON在所有存档场景里都无敌。我动手拆一个对比表你能看得更清楚格式可读性体积读写性能跨语言/跨版本兼容适用场景JSON高中中好游戏存档、配置文件、交换数据INI中小中一般嵌套结构弱简单程序配置XML中大中好文档型数据但冗长QDataStream二进制差小高依赖Qt版本和布局性能敏感、体积敏感的内部缓存SQLite不可直接读中高复杂查询好需要吞大量数据或记录式数据JSON的劣势也恰恰来自文本和人类可读。同样的信息量它比二进制编码大不少因为小数点、括号、引号都要占字符。解析性能比直接内存拷贝慢一个量级不过对于普通游戏存档几十KB到几MB可以忽略不计。还有一条很多人想不到的理由JSON的模式演化能力比二进制好。你在存档里加一个新字段旧存档加载进来时这个字段默认赋0或空值最多是功能缺失不至于崩。二进制序列化一旦改了字段顺序或者类型旧存档基本就废了。这个特性对游戏特别重要——你不可能要求所有玩家都同步升级存档格式向后兼容是底线。在这个前提下JSON容错性高的优点就被放大了。那什么时候别选JSON如果你的存档动辄几百MB像大型开放世界地图缓存纯文本解析会拖慢读档速度这时候适合分块存盘、压缩、或者干脆上二进制流又比如需要存档防篡改你不是加密游戏内经济系统那活着的是加密签名而不是JSON本身。JSON是武器库里的好家伙但不是唯一家伙。我的结论是官方示例选JSON是因为它希望新手快速建立存档模型的直觉知道状态 - 文档 - 文件这个流程甚至可以直接照抄到自己的项目里。一旦你对这个示例熟练了以后哪怕要换二进制或数据库你都多了一份明确参照。5. 示例之外的雷区实际存档功能需要补的课示例代码在教学上没问题但如果你真的把它原封不动搬进自己的游戏用不了多久就会感觉别扭。我把自己实际项目里踩过的坑和补丁整理了一遍这些都是教程外的东西。第一个雷是文件路径。示例里如果直接写死相对路径你开发时能跑发布后用户从不同目录启动程序文件可能就找不到。稳妥做法是用标准路径接口把存档写到系统为当前应用分配的本地数据目录里。写完这一句你的存档就有了一个稳定的家起码不会因为工作目录变化而凭空消失。QString saveDir QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); QDir d(saveDir); if (!d.exists()) { d.mkpath(.); } QString savePath saveDir /save_game.json;第二个雷是写入崩溃。QFile在写入过程中程序突然退出或断电很可能留下一个半截的JSON文件下次加载直接解析失败。正确的做法是拆解成两步先写到一个临时文件成功后用rename改名覆盖旧存档。相当于没有让旧存档暴露在被破坏的窗口期里。Qt对这事有个现成类叫QSaveFile专门干原子替换。你只需把示例里的QFile换成QSaveFile写完后调用commit()它会处理好临时文件和覆盖的细节。强烈建议养成这个习惯。第三个雷是编码和乱码。JSON规范明确用UTF-8Qt里QFile默认读写字节什么时候会乱当你跨平台操作时注意别用本地编码保存统一走UTF-8。在代码里最好明确转换写入前用QByteArray读取时QJsonDocument::fromJson本身就会按UTF-8解码。唯一让我栽过跟头的是Windows某些编辑器默认用GBK打开UTF-8文件显示中文乱码但程序读进去其实没事。那个纯粹是编辑器锅不是程序问题。第四个雷是存档格式演化。你的游戏必会迭代后续版本会往GameState里加新字段。示例代码里fromJson假设所有字段都存在但玩家手里的旧存档里没有新字段你就会得到一个score被清成0的怪结果。手动处理的方式是先用contains()查一下键是否存在不存在就给一个合理的默认值。实战里我还会为存档写一个schemaVersion字段加载时检查版本号针对不同版本走不同的迁移逻辑。别觉得小题大做我见过太多项目上线后才开始补这份债代价大得多。第五个雷是嵌套太深或结构太大。示例里只有一个游戏状态对象几层JSON都无压力但真游戏里背包、任务、地图、NPC状态叠在一起代码里填JSON对象和数组容易手滑写错层级。我的习惯是分模块序列化比如背包单独写一个toJson函数返回QJsonArray再塞进总状态对象里每个模块自己负责fromJson的容错。这样还能配合单元测试单独验证某个模块。最后顺带说一个进阶选项压缩和加密。纯JSON文本玩家可以手改不少游戏把存档用base64、AES或者轻量压缩包起来既减小体积又防止小白改数值作弊。但压缩后人类可读的优势就没了而且一旦加密调试又得到处解密所以我只在需要防作弊或联机校验的时候才启用。如果你还在做单人休闲小游戏先保住可读性比防作弊更重要。用官方这个JSON Save Game示例练手然后照着上面几点改造一遍基本上就能把它从教学模板打磨成一个可用的存档模块。我个人实际项目里至今也保留着这套逻辑的变体只是把保存按钮改成自动存档、加了QSaveFile、和压缩层而已。框架没变变的都是防御细节。