UE5 的 JSON 相关类,光看名字容易绕晕。FJsonObject 是键值对的容器,FJsonValue 是值的抽象基类,这两个概念一旦分清,读写 JSON 的代码基本就不会写错了。这篇笔记是我把继承链彻底捋了一遍之后,再把讲师给的例题代码复现、扩展、踩坑的完整记录,适合刚开始在 UE5 里做存档、配置表、网络消息体解析的 C++ 开发者。
1. FJsonObject 的存储本质:不只是字符串,是一个键值对容器
1.1 对象的内部结构与 JSON 的映射关系
先记住一个结论:UE5 里的 FJsonObject 不是乱七八糟的字符串,也不是一个 key-value 的数组,它就是一个标准的“对象”容器,内部维护了一张TMap<FString, TSharedPtr<FJsonValue>>。这句话从类名上经常看不出来,很多人第一次接触时都会把它误当成“操作 JSON 字符串的工具类”,其实不对。
JSON 文件长这样:
{ "playerName": "Aria", "level": 12, "isVIP": true }对应到 UE 里:
TSharedPtr<FJsonObject> RootObj = MakeShareable(new FJsonObject()); RootObj->SetStringField(TEXT("playerName"), TEXT("Aria")); RootObj->SetNumberField(TEXT("level"), 12); RootObj->SetBoolField(TEXT("isVIP"), true);这里的 RootObj 就可以理解为,它自己就是上面那个大括号包起来的整体。它的每一个字段名是 FString,字段值则是一个 TSharedPtr,这个指针指向的是 FJsonValue 或其某个子类。
我建议你理解的时候跟生活里的“带标签的收纳盒”做类比。FJsonObject 就是那组收纳盒,盒子的标签是 FString,盒子里装的东西不一定是固定形态——可能是一个数字,也可能是一串文字,还可能又是一个收纳盒(嵌套对象)。正因为东西形态不确定,你不能用一个固定的 int 或者 FString 去存,必须用“值包装器”FJsonValue 来装。
1.2 为什么键的类型是 FString、值的类型是 TSharedPtr<FJsonValue>
键用 FString 很好理解,JSON 规范里对象 key 就是字符串,UE 内部统一收成 FString 也方便跟蓝图字符串互转。
麻烦的是 value 为什么要用 TSharedPtr,而不是直接写 FJsonValue。你不能直接持有 FJsonValue 本身,因为 FJsonValue 是一个抽象基类,里面一堆纯虚函数,压根不能实例化。JSON 的 value 可以是一个对象、数组、字符串、数字、布尔或者 null,实际运行时你必须持有它的某个具体子类对象,所以必须指针化。
指针也分原始指针和智能指针。如果只用原始指针 FJsonValue*,内存释放时机全靠手动控制,一旦对象赋值、数组嵌套、生命周期交错,很容易出现悬垂指针和内存泄漏。UE 里统一约定用 TSharedPtr,这是引用计数的智能指针,多个地方持有同一个 JSON 节点时,引用计数会自动加减,最后一个引用销毁时节点自动释放。这也是为什么你在代码里到处看到 TSharedPtr、ToSharedRef、MakeShareable,不是老师为了炫技,而是这套类库本身的设计就是围绕共享所有权展开的。
这里有个新手容易忽略的细节:FJsonObject 的 GetField 返回的也是 TSharedPtr<FJsonValue>,但拿到的可能是一个无效指针(字段不存在),也可能是真实指针。使用前必须先判空,不要直接解引用。
2. FJsonValue 全家桶:子类清单与继承链还原
2.1 六个直接子类分别能存什么
讲师例题里经常直接打印各种提取出来的值,但很少把六个子类逐个列给你看。我当时自己梳理了一遍,真正需要掌握的直接子类就这六个:
| JSON 类型 | FJsonValue 子类 | 内部存储成员 | 常用取值方法 |
|---|---|---|---|
| object | FJsonValueObject | TSharedPtr<FJsonObject> | AsObject() |
| array | FJsonValueArray | TArray<TSharedPtr<FJsonValue>> | AsArray() |
| string | FJsonValueString | FString | AsString() |
| number | FJsonValueNumber | double | AsNumber() |
| boolean | FJsonValueBoolean | bool | AsBool() |
| null | FJsonValueNull | 无 | Type() == EJson::Null |
每个子类的内部存储其实都不复杂。FJsonValueObject 的存储成员是 TSharedPtr<FJsonObject>,也就是说它内部又挂了一个对象容器,这正好实现嵌套。FJsonValueArray 的存储成员是 TArray<TSharedPtr<FJsonValue>>,数组元素又是值包装器,所以数组里面可以再放对象、数组、字符串。FJsonValueNumber 内部用的是 double,注意不是 float,也不是 int,这就决定了你在取整型数值时需要自己做一次截断或转换。FJsonValueNull 则什么都没有存,它只用来表达“这里有一个 null”。
2.2 继承链全貌与类型判断方法
把所有类串起来就是下面这条链,建议直接抄进自己的笔记:
FJsonValue(抽象基类) ├── FJsonValueNull ├── FJsonValueBoolean ├── FJsonValueNumber ├── FJsonValueString ├── FJsonValueArray └── FJsonValueObject需要补充一句,某些引擎版本内部还会出现 FJsonValueField 之类的辅助类,处理对象字段包装时可能会用到。日常解析、序列化 JSON 时基本碰不到它,看到不认识也不用慌,核心只需要记住上面六个。
FJsonValue 这个基类提供了一个非常重要的枚举接口 Type(),返回值是:
enum class EJson : uint8 { None, Null, String, Number, Boolean, Array, Object };实际操作中我用得最多的代码形态就是先判断类型再取值:
TSharedPtr<FJsonValue> Value = RootObj->GetField(TEXT("level")); if (Value.IsValid() && Value->Type == EJson::Number) { double Level = Value->AsNumber(); UE_LOG(LogTemp, Log, TEXT("Level = %f"), Level); }为什么要先判断再取值?因为 AsNumber 等方法的默认行为是:如果类型不匹配,它很大概率返回一个默认值(比如数字返回 0,字符串返回空串),并不会主动去“转型”。这跟 JSON 的动态特性有点反直觉,但源码里就是这么设计的。
2.3 As 系列取值函数的隐藏行为
继续挖细节。FJsonValue 基类声明了 AsNumber、AsString、AsBool、AsArray、AsObject 等接口,每个子类会根据自己的实际情况去覆盖。我整理过一张容易踩坑的行为表:
- AsNumber:对于 FJsonValueNumber 返回内部 double;对于 FJsonValueString 返回 0,不会自动把字符串
"123"解析成 123。 - AsString:对于 FJsonValueString 返回内部 FString;对于 FJsonValueNumber 返回
空字符串,不会自动把数字格式化。 - AsBool:对于 FJsonValueBoolean 返回 bool;其他情况返回 false。
- AsArray:只有 FJsonValueArray 才有意义;对象或字符串调用返回空数组。
- AsObject:只有 FJsonValueObject 才有意义;其他类型调用返回空 TSharedPtr。
看到这里你可能会问:那就不能拿到原始 JSON 字符串然后自己转吗?可以,但那样绕远了。更合理的做法是保证数据来源清晰,或者提前按 JSON 的类型分支处理。
3. 讲师例题代码复现:从解析到遍历再到序列化
3.1 解析 JSON 字符串这段标准骨架
讲师给的例题核心就是“把 JSON 文本变成可操作的对象树”。UE 里负责这项工作的是 FJsonSerializer 和 TJsonReader。代码骨架长这样:
FString JsonStr = TEXT("{\"playerName\":\"Aria\",\"level\":12,\"inventory\":[{\"name\":\"sword\",\"count\":1},{\"name\":\"potion\",\"count\":5}]}"); TSharedPtr<FJsonObject> RootObj = MakeShareable(new FJsonObject()); TSharedRef<TJsonReader<>> Reader = TJsonReaderFactory<>::Create(JsonStr); if (FJsonSerializer::Deserialize(Reader, RootObj)) { if (RootObj.IsValid()) { // 这里开始做业务处理 FString Name = RootObj->GetStringField(TEXT("playerName")); double Level = RootObj->GetNumberField(TEXT("level")); UE_LOG(LogTemp, Log, TEXT("playerName=%s, level=%f"), *Name, Level); } }这里需要重点解释两个地方。
第一个是TJsonReaderFactory<>::Create返回的是一个 TSharedRef,也就是说这种情况下不需要判空,可以直接使用。这个 reader 是一个迭代器式的读取器,它不会一次性把整棵树全部建好,而是边走边解析,配合 FJsonSerializer 把结果写进 RootObj。
第二个是 FJsonSerializer::Deserialize 成功后,RootObj 会持有完整的对象树。以后你直接操作 RootObj 就行,JsonStr 这个临时字符串可以在函数结束后随便释放,因为对象树是独立的内存结构,不依赖原来的文本。
3.2 遍历键值对、数组和嵌套对象
光取单个字段不够,实际业务里大部分时间都在遍历数组和嵌套对象。讲师例题会有一个典型的“背包清单”场景:inventory 是一个数组,数组里每个元素又是一个对象。完整代码我复现成下面这样:
TArray<TSharedPtr<FJsonValue>> Inventory = RootObj->GetArrayField(TEXT("inventory")); for (const TSharedPtr<FJsonValue>& ItemValue : Inventory) { TSharedPtr<FJsonObject> ItemObj = ItemValue->AsObject(); if (!ItemObj.IsValid()) { continue; } FString ItemName = ItemObj->GetStringField(TEXT("name")); int32 Count = ItemObj->GetIntegerField(TEXT("count")); UE_LOG(LogTemp, Log, TEXT("item=%s, count=%d"), *ItemName, Count); }遍历数组时有两个细节。
一是遍历的写法,我用的是const TSharedPtr<FJsonValue>&,也就是引用方式。如果你写成TSharedPtr<FJsonValue> ItemValue : Inventory,每次循环都会对元素做一次引用计数加减,数组一长性能就有损耗,而且语义上还会让人误以为拿到了独立副本。
二是AsObject()之后必须判空。inventory 数组在 JSON 文件里虽然是对象数组,但代码不能默认每一个元素都必然是对象。万一有人把某个元素改成了字符串或者数字,AsObject 就会返回空指针,直接解引用就崩了。实战中我会加一个完整判断:
if (ItemValue->Type == EJson::Object) { TSharedPtr<FJsonObject> ItemObj = ItemValue->AsObject(); // 继续处理 }直接用 Type 判断比看 AsObject 返回是否为空更早暴露格式问题,调试定位也更快。
3.3 反向序列化成字符串并写入文件
解析只是半个流程,“写回 JSON”同样高频。比如玩家退出游戏时把存档写进文件。讲师例题后半段就是教这个,核心代码:
RootObj->SetStringField(TEXT("saveTime"), TEXT("2025-01-15 20:30:00")); RootObj->SetNumberField(TEXT("playTimeMinutes"), 450); FString OutputString; TSharedRef<TJsonWriter<>> Writer = TJsonWriterFactory<>::Create(&OutputString); if (FJsonSerializer::Serialize(RootObj.ToSharedRef(), Writer)) { FString SavePath = FPaths::ProjectSavedDir() / TEXT("PlayerData.json"); FFileHelper::SaveStringToFile(OutputString, *SavePath, FFileHelper::EEncodingOptions::ForceUTF8); UE_LOG(LogTemp, Log, TEXT("Saved: %s"), *OutputString); }这里最容易被坑的是 Writer 的生命周期。TJsonWriter 要一直存活到 Serialize 完成,所以通常你在一个函数作用域里写成局部变量即可,不要 Serialize 完就立刻销毁。如果你把 Create 的返回值存在一个临时变量里,或者 Serialize 的调用点到 Writer 析构之间还插了别的逻辑,就可能出现写了一半、字符串为空的情况。
写入文件时,我用FFileHelper::EEncodingOptions::ForceUTF8,这样中文不会乱码。如果文件带 BOM,个别场景下用 LoadFileToString 再解析会多出不可见字符,ForceUTF8 反而能保证输出干净。
另外说一句 SetNumberField。讲师例题里这里用的是 double 语义,虽然传了一个整数 450,但内部统一存成 double。如果你后面用 GetIntegerField 取出来,会得到一个 int32,但那是经过一次 double 转 int32 的结果。小数位直接被截断,不是四舍五入。这跟 JSON 数字没有“整数”类型的特性有关,所有数字在 UE 侧都是 double。
4. 实战过程的问题排查与避坑心得
4.1 常见报错速查表
我前后调试过不少 JSON 解析的问题,最典型的现象和原因可以整理成一张速查表:
| 现象 | 常见原因 | 解决思路 |
|---|---|---|
| Deserialize 返回 false | 字符串不是合法 JSON,外层多了花括号或逗号 | 先用在线工具格式化原始文本 |
| 解析成功但字段全是默认值 | key 拼写错误,大小写不匹配,层级不对 | 先 GetField 看是否为空 |
| 数组元素取出来全是空 | 数组里实际是对象,但用 AsString 去取 | 逐个元素打印 Type |
| 中文输出乱码 | 文件编码不是 UTF-8 | 写入使用 ForceUTF8,读取统一 UTF-8 |
| Serialize 后字符串为空 | Writer 生命周期结束,或传入的 FString 指针失效 | 保证 TSharedRef<TJsonWriter> 作用域覆盖调用点 |
| 崩溃在 AsObject 之后继续解引用 | 没有判空,或该元素本来就不是对象 | 先 Type == EJson::Object 再处理 |
这张表里“解析成功但字段全是默认值”我遇到最多。有一次从服务器拿到的字段名是"playerName",我代码里写成了"playername",解析没报错,取出来永远是空字符串。查了半天才发现是大小写。所以我说排查第一步永远是 GetField 返回值的有效性,不要直接盯着 GetXXXField 的结果。
4.2 GetNumberField 与 TryGet 的区别:拿不到字段时的表现
讲师例题里经常出现这样一行:
double Level = RootObj->GetNumberField(TEXT("level"));如果 level 字段存在,这句话没问题。一旦字段不存在,GetNumberField 会返回默认值 0,但不会崩溃,也不会提示你。这就很容易形成“看起来正常,数据却是错的”的隐蔽 bug。
更严谨的写法是先用 TryGet 系列:
double Level = 0.0; if (RootObj->TryGetNumberField(TEXT("level"), Level)) { // 只有字段存在且类型为数字时才进入这里 } else { // 字段缺失或类型不匹配 UE_LOG(LogWarning, Log, TEXT("level field missing")); }TryGetNumberField、TryGetStringField、TryGetBoolField 都是同样的套路:多一个输出参数,返回值表示是否成功取到。它的好处是把“字段不存在”和“字段存在但解析失败”两种情况区分开。对于存档类数据,我建议直接用 TryGet,避免因为一段格式异常导致整个存档关键数据被静默覆盖成 0。
数组和对象字段也有对应安全写法,比如 TryGetArrayField、TryGetObjectField。但实际使用中,GetArrayField 也不崩,缺失时返回空数组。最麻烦的是 GetObjectField,拿嵌套对象时,如果字段不存在,它返回空 TSharedPtr,后续访问成员前必须先判空,否则触发访问空指针的崩溃。
4.3 TSharedPtr 相关的内存管理细节
最后聊一下内存这个话题。JSON 解析出来的整个对象树由智能指针管理,按理说不太容易内存泄漏,但写代码时还是有几个细节需要注意。
第一,不要无脑复制TArray<TSharedPtr<FJsonValue>>。数组字段取出来是一次 TArray 拷贝,数组内部的 TSharedPtr 元素本身是引用计数指向同一批数据,不会深拷贝。所以你可以放心遍历,但如果你频繁调用 GetArrayField 上百次,每次都会产生一次数组容器的拷贝,临时分配开销不建议忽视。正确做法是取一次存到局部变量,后续反复使用。
第二,构造 FJsonValue 子类时,通常用MakeShareable(new FJsonValueNumber(Level))。有些场景也可以直接用FJsonValueNumber(Level)临时对象,但一旦要放进 TArray 或作为 TSharedPtr 传入对象树,就必须显式 new 出来包成智能指针。我用过最简单的方式是把普通对象塞进 MakeShareable,它负责接管所有权。
第三,往数组里添加对象时要先构造好子对象:
TArray<TSharedPtr<FJsonValue>> ItemArray; TSharedPtr<FJsonObject> ItemObj = MakeShareable(new FJsonObject()); ItemObj->SetStringField(TEXT("name"), TEXT("shield")); ItemObj->SetNumberField(TEXT("count"), 2); ItemArray.Add(MakeShareable(new FJsonValueObject(ItemObj))); RootObj->SetArrayField(TEXT("items"), ItemArray);到这里其实就形成了一个完整的 JSON 树构造流程:先创建内部对象,包成 FJsonValueObject,放进数组,数组再 SetArrayField 挂到根对象。初学时容易漏掉“包成 FJsonValueObject”这一步,直接 Add 一个 FJsonObject 的 TSharedPtr,结果编译报错。这是因为数组容器只认 TSharedPtr<FJsonValue>,节点类型不匹配。
第四,如果你需要从现有对象树里删除字段,用 RemoveField 方法,比如RootObj->RemoveField(TEXT("level"))。不要在遍历 Values 的循环里直接删除,否则迭代器可能失效。真要边遍历边删,先收集 key 到独立数组,循环结束后再统一删。
5. 给新手的一条个人建议
讲完类继承链、解析和序列化的完整示例,最后再分享一个我自己的调试习惯。遇到任何 JSON 数据,我拿到手的第一件事不是写取值代码,而是先把它打印出类型结构:
for (const auto& Pair : RootObj->Values) { UE_LOG(LogTemp, Log, TEXT("key=%s, type=%d"), *Pair.Key, (int32)Pair.Value->Type); }这一步能让我在 30 秒内看清整个对象树有几层、每个字段到底是什么类型,比对着文档猜字段路径高效太多。你如果刚开始学 UE5 C++,建议把这篇笔记里的继承链和例题代码亲手敲一遍,再把自己的数据结构按同样方式解析一次,之后碰到 FJsonObject 就不会再发怵了。