用 Qt 的 JSON 与 CBOR 完成游戏状态存取
原作:The Qt Company 与文档贡献者;原文:Saving and Loading a Game。中文翻译与技术编校:未完纪。本稿依据 Qt 6.11.2 版本化教程(2026-10-09 复核);同一正文在当前 Qt 6.12.0 页面也一致。以下代码仅经静态审查,未编译、未运行。
许多游戏都提供存档功能,让玩家在以后恢复进度。保存游戏通常意味着把每个游戏对象的成员变量序列化到文件中。JSON 是可选的格式之一;Qt 的 JSON 类与 CBOR 类还能配合使用,在同一份对象转换逻辑上输出二进制格式。CBOR 往往更紧凑,也不像 JSON 那样能直接当普通文本阅读,但它仍然能够被解析,不能作为加密或防篡改机制。
这个示例依次处理角色、关卡和游戏对象,再把文档写入文件、从文件恢复。它展示状态序列化,并不包含实际可玩的游戏。

角色:把默认值留在一个地方
Character 保存角色的名称、等级和职业类型,既用于玩家,也用于 NPC。它通过静态 fromJson() 从 JSON 创建值对象,通过实例方法 toJson() 输出 JSON。
class Character
{
Q_GADGET
public:
enum ClassType { Warrior, Mage, Archer };
Q_ENUM(ClassType)
Character();
Character(const QString &name, int level, ClassType classType);
QString name() const;
void setName(const QString &name);
int level() const;
void setLevel(int level);
ClassType classType() const;
void setClassType(ClassType classType);
static Character fromJson(const QJsonObject &json);
QJsonObject toJson() const;
void print(QTextStream &s, int indentation = 0) const;
private:
QString mName;
int mLevel = 0;
ClassType mClassType = Warrior;
};
这种模式成立有两个前提:QJsonObject 可以独立于所属的 QJsonDocument 构造;这里序列化的数据类型都是可复制的值类型。如果换成需要传入文档或数据流对象的 XML、QDataStream,或对象身份很重要,例如 QObject 子类,就可能需要其他接口形式。原文给出的延伸例子是 DOM Bookmarks,以及 QListWidgetItem::read() 和 write()。本例的 print() 也可以视为向 QTextStream 序列化,只是没有反向读取部分。
Character Character::fromJson(const QJsonObject &json)
{
Character result;
if (const QJsonValue v = json["name"]; v.isString())
result.mName = v.toString();
if (const QJsonValue v = json["level"]; v.isDouble())
result.mLevel = v.toInt();
if (const QJsonValue v = json["classType"]; v.isDouble())
result.mClassType = ClassType(v.toInt());
return result;
}
fromJson() 先构造局部变量 result,再按字段更新成员。对常量 JSON 对象使用 operator[]() 或 value() 取值,键不存在时都会得到 QJsonValue::Undefined。isString()、isDouble() 等类型判断对 Undefined 返回 false,所以一次查找就能兼顾“有没有这个键”和“类型是否符合要求”。
缺失或类型不符的字段不会覆盖 result 的成员,默认构造时设置的值得以保留。默认值只在构造过程或成员初始化处定义一次,不需要在序列化代码中重复。代码使用 C++17 带初始化语句的 if,让变量 v 的作用域只覆盖这一判断分支。
对比下面的写法:它先检查存在,再检查类型,最后取值,对同一个键查找三次,也重复写了三次键名。
if (json.contains("name") && json["name"].isString())
result.mName = json["name"].toString();
编校说明:原文把三次查找概括为“三倍慢”。这里保留三次查找这一事实,不把查找次数直接等同于未经测量的整段运行时间。
QJsonObject Character::toJson() const
{
QJsonObject json;
json["name"] = mName;
json["level"] = mLevel;
json["classType"] = mClassType;
return json;
}
toJson() 反过来把成员写进新建的对象并返回。设置键值可以使用 operator[]() 或 insert();两者都会覆盖该键原有的值。
关卡:用数组恢复角色集合
每个关卡有自己的名称和若干 NPC,因此 Level 持有 QList<Character>,并提供相同的转换接口。
class Level
{
public:
Level() = default;
explicit Level(const QString &name);
QString name() const;
QList<Character> npcs() const;
void setNpcs(const QList<Character> &npcs);
static Level fromJson(const QJsonObject &json);
QJsonObject toJson() const;
void print(QTextStream &s, int indentation = 0) const;
private:
QString mName;
QList<Character> mNpcs;
};
Level Level::fromJson(const QJsonObject &json)
{
Level result;
if (const QJsonValue v = json["name"]; v.isString())
result.mName = v.toString();
if (const QJsonValue v = json["npcs"]; v.isArray()) {
const QJsonArray npcs = v.toArray();
result.mNpcs.reserve(npcs.size());
for (const QJsonValue &npc : npcs)
result.mNpcs.append(Character::fromJson(npc.toObject()));
}
return result;
}
JSON 集合由 QJsonArray 表达。读取 npcs 时,先确认该字段是数组,再根据数组大小预留容量。遍历每个 QJsonValue,调用 toObject() 得到角色对象,交给 Character::fromJson(),最后加入 NPC 列表。
原文还提到关联容器:可以把键存入各个值对象中,整体仍写成普通对象数组,读取时再利用存下的键恢复关联关系。原文后半句使用了“每个元素的 index 作为键”的表述,容易与数组下标混淆;如果要保持原始关联容器的键,应使用对象中保存的键,不能无条件把数组序号当作原键。本例实际只使用顺序列表。
QJsonObject Level::toJson() const
{
QJsonObject json;
json["name"] = mName;
QJsonArray npcArray;
for (const Character &npc : mNpcs)
npcArray.append(npc.toJson());
json["npcs"] = npcArray;
return json;
}
写出流程与读取相反:逐个调用角色的 toJson(),将结果追加到数组,再放到关卡对象的 npcs 字段中。
游戏:保持对象身份,并替换旧集合
class Game
{
public:
enum SaveFormat { Json, Binary };
Character player() const;
QList<Level> levels() const;
void newGame();
bool loadGame(SaveFormat saveFormat);
bool saveGame(SaveFormat saveFormat) const;
void read(const QJsonObject &json);
QJsonObject toJson() const;
void print(QTextStream &s, int indentation = 0) const;
private:
Character mPlayer;
QList<Level> mLevels;
};
SaveFormat 枚举让调用者选择 JSON 或二进制格式。newGame() 构造初始状态;loadGame() 与 saveGame() 处理文件;它们通过 read() 和 toJson() 访问对象数据。
虽然 Game 也是值类,这里仍把它当作具有持续身份的对象,就像应用的主窗口一样。因此恢复状态使用现有对象的 read(),不另用静态 fromJson() 返回新实例。原文以下伪代码说明两种接口可以互相实现:
void read(const QJsonObject &json) { *this = fromJson(json); }
static Game fromObject(const QJsonObject &json) { Game g; g.read(json); return g; }
编校说明:这两行只是相互转换关系的示意,不能原封不动并入上面的类;第一行调用 fromJson,第二行却命名为 fromObject。如果要实现其中一种包装,需统一名称并确保另一端有独立实现,避免互相调用形成递归。
新游戏的角色和关卡初始化如下。随机等级的上界不包含在结果中,例如 bounded(15, 21) 产生 15 到 20 之间的整数。
void Game::newGame()
{
mPlayer = Character();
mPlayer.setName("Hero"_L1);
mPlayer.setClassType(Character::Archer);
mPlayer.setLevel(QRandomGenerator::global()->bounded(15, 21));
mLevels.clear();
mLevels.reserve(2);
Level village("Village"_L1);
QList<Character> villageNpcs;
villageNpcs.reserve(2);
villageNpcs.append(Character("Barry the Blacksmith"_L1,
QRandomGenerator::global()->bounded(8, 11), Character::Warrior));
villageNpcs.append(Character("Terry the Trader"_L1,
QRandomGenerator::global()->bounded(6, 8), Character::Warrior));
village.setNpcs(villageNpcs);
mLevels.append(village);
Level dungeon("Dungeon"_L1);
QList<Character> dungeonNpcs;
dungeonNpcs.reserve(3);
dungeonNpcs.append(Character("Eric the Evil"_L1,
QRandomGenerator::global()->bounded(18, 26), Character::Mage));
dungeonNpcs.append(Character("Eric's Left Minion"_L1,
QRandomGenerator::global()->bounded(5, 7), Character::Warrior));
dungeonNpcs.append(Character("Eric's Right Minion"_L1,
QRandomGenerator::global()->bounded(4, 9), Character::Warrior));
dungeon.setNpcs(dungeonNpcs);
mLevels.append(dungeon);
}
这个初始状态包含名为 Hero 的弓箭手玩家、Village 与 Dungeon 两个关卡,以及村庄中的两名 NPC 和地下城中的三名 NPC。
void Game::read(const QJsonObject &json)
{
if (const QJsonValue v = json["player"]; v.isObject())
mPlayer = Character::fromJson(v.toObject());
if (const QJsonValue v = json["levels"]; v.isArray()) {
const QJsonArray levels = v.toArray();
mLevels.clear();
mLevels.reserve(levels.size());
for (const QJsonValue &level : levels)
mLevels.append(Level::fromJson(level.toObject()));
}
}
读取玩家字段时,先确认其为对象,再把新建的角色赋给玩家成员。读取关卡字段时,先确认其为数组,然后清空旧列表、预留空间、逐个恢复。清空这一步防止在同一个 Game 实例上加载两次后仍保留旧关卡。
静态核对还需要注意:只有 levels 字段存在且为数组时,代码才会清空列表;字段缺失或类型错误会保留已有关卡。同理,无效的 player 字段会保留原玩家。这与从默认对象开始的 Character::fromJson() 语义不同。
QJsonObject Game::toJson() const
{
QJsonObject json;
json["player"] = mPlayer.toJson();
QJsonArray levels;
for (const Level &level : mLevels)
levels.append(level.toJson());
json["levels"] = levels;
return json;
}
游戏的写出方式与关卡一样:玩家转成一个对象,关卡逐一转成数组元素,再一起放进顶层 JSON 对象。
文件层:在 JSON 与 CBOR 之间切换
bool Game::loadGame(Game::SaveFormat saveFormat)
{
QFile loadFile(saveFormat == Json ? "save.json"_L1 : "save.dat"_L1);
if (!loadFile.open(QIODevice::ReadOnly)) {
qWarning("Couldn't open save file.");
return false;
}
QByteArray saveData = loadFile.readAll();
QJsonDocument loadDoc(saveFormat == Json
? QJsonDocument::fromJson(saveData)
: QJsonDocument(QCborValue::fromCbor(saveData).toMap().toJsonObject()));
read(loadDoc.object());
QTextStream(stdout) << "Loaded save for " << loadDoc["player"]["name"].toString()
<< " using " << (saveFormat != Json ? "CBOR" : "JSON") << "...\n";
return true;
}
loadGame() 根据格式选择 save.json 或 save.dat。只读打开失败时输出警告并返回 false。QJsonDocument::fromJson() 和 QCborValue::fromCbor() 都接收 QByteArray,因此示例统一用 readAll() 读取文件。
JSON 分支直接构造文档;CBOR 分支先解码 CBOR,取映射,再转成 JSON 对象。随后调用游戏的 read() 恢复状态,输出玩家名称与所用格式,并返回 true。
bool Game::saveGame(Game::SaveFormat saveFormat) const
{
QFile saveFile(saveFormat == Json ? "save.json"_L1 : "save.dat"_L1);
if (!saveFile.open(QIODevice::WriteOnly)) {
qWarning("Couldn't open save file.");
return false;
}
QJsonObject gameObject = toJson();
saveFile.write(saveFormat == Json ? QJsonDocument(gameObject).toJson()
: QCborValue::fromJsonValue(gameObject).toCbor());
return true;
}
saveGame() 同样按格式选择文件名,写入打开失败时返回 false。它先取得游戏的 JSON 对象,再按所选格式调用 QJsonDocument(...).toJson() 或 QCborValue::fromJsonValue(...).toCbor()。转换格式没有改变角色和关卡的序列化接口。
官方教程没有给出固定的 JSON 存档、CBOR 字节或完整终端输出。根据转换函数,顶层包含 player 对象与 levels 数组;角色对象包含字符串 name、数字 level 和枚举数值 classType,关卡对象包含 name 与 npcs 角色数组。newGame() 会随机生成等级,因此具体存档内容每次可能不同;CBOR 是同一对象的二进制转换。本文未运行示例,不附虚构的转储或运行结果。
入口:新建、加载与保存
int main(int argc, char *argv[])
{
QCoreApplication app(argc, argv);
const QStringList args = QCoreApplication::arguments();
const bool newGame
= args.size() <= 1 || QString::compare(args[1], "load"_L1, Qt::CaseInsensitive) != 0;
const bool json
= args.size() <= 2 || QString::compare(args[2], "binary"_L1, Qt::CaseInsensitive) != 0;
Game game;
if (newGame)
game.newGame();
else if (!game.loadGame(json ? Game::Json : Game::Binary))
return 1;
// Game is played; changes are made...
QTextStream s(stdout);
s << "Game ended in the following state:\n";
game.print(s);
if (!game.saveGame(json ? Game::Json : Game::Binary))
return 1;
return 0;
}
示例只需要 QCoreApplication,不创建图形界面,也不进入事件循环。第一个参数决定新建还是加载:不提供参数时按新建处理,只有忽略大小写后等于 load 才会加载。第二个参数决定格式:默认是 JSON,只有等于 binary 才会选择 CBOR。其他字符串在这段示例中分别落入“新建”和“JSON”,没有独立的非法参数报错。
随后本应发生游戏过程并改变角色与关卡状态;本示例只保留注释。结束时先打印状态,再保存。原文建议查看生成的文件,或重新运行时指定 load 检查恢复结果。二进制存档以文本打开时出现不可读字符是正常现象。
编校说明:源文把文件位置说成“可执行文件所在目录”,但这里使用的是相对路径,实际解析到进程当前工作目录;只有两者相同时,文件才会出现在可执行文件旁。本文未执行这些运行步骤,因而没有提供虚构的运行输出。
把教学示例用于真实存档前,还要补哪些检查
上述代码保持官方示例的教学范围。静态审查没有发现嵌入的密码、令牌或命令注入调用;但“成功打开文件”不等于“完整、有效地恢复游戏”。loadGame() 没有接收并检查 JSON/CBOR 解析错误,没有验证顶层类型,也没有报告 readAll() 的读取错误。畸形输入可能产生空对象,最终仍然返回 true。
字段层面,isDouble() 只确认数字类型,没有验证等级的取值范围、整数要求或枚举是否为 Warrior、Mage、Archer;数组中的每个元素也没有先判断 isObject()。示例没有模式版本、迁移策略、文件大小限制或集合规模限制。面对来源不可信的存档,应先校验并构造临时状态,只有全部通过后再替换当前游戏,避免半更新。
保存端直接以 WriteOnly 打开目标文件,会涉及覆盖现有存档;代码没有检查 write() 返回的字节数,也没有原子提交或完整性校验。真实项目可另外设计临时文件与安全提交策略,例如评估 QSaveFile,并逐项处理失败。这里没有擅自修改示例代码,也没有声称已经实现或测试这些补强措施。
Qt JSON 类的便利之处在于:对象只需建立一次清晰的转换边界,就能获得便于人工检查的 JSON;需要二进制文件时,再在文件层使用 CBOR。对象身份、缺失字段、集合替换和错误处理仍然需要由应用明确规定。
来源、版权与版本
原文与关联资料:Qt 6.11.2 官方教程;当前 Qt 官方教程;Qt 6.11.2 官方示例项目;game.cpp;JSON Support in Qt;CBOR Support in Qt。
文档版权 © 2026 The Qt Company Ltd.,文档贡献归各贡献者所有;原页声明文档依据 GNU Free Documentation License 1.3 发布。Qt 与相关标识为 The Qt Company Ltd. 的商标,其他商标归各自所有者。本次核对的示例源文件注明 © 2016 The Qt Company Ltd.,SPDX 为 LicenseRef-Qt-Commercial OR BSD-3-Clause,不能把软件代码许可直接等同于全部文章或商标许可。
本译文与编校内容按 GNU Free Documentation License 1.3 提供,许可全文见LICENSE-GNU-FDL-1.3.txt;保留 The Qt Company 与文档贡献者的版权归属,并标明翻译和编校改动。嵌入的 Qt 示例代码片段依 Qt 官方说明按 BSD 3-Clause 条款提供,代码版权与许可全文见LICENSE-BSD-3-Clause.txt;作者与来源记录见ATTRIBUTION.txt。配图为本稿原创技术示意图,没有复用 Qt 官方标志、截图或界面图像;图中文字仅保留 Qt 技术名称。原文没有明确标出本篇首次发表日期,本稿未作推定。











暂无评论内容