最近在某个项目的存档模块里折腾 JSON 读写,UE5 的 C++ 侧 API 绕来绕去,最后发现核心就两个类:FJsonObject和FJsonValue。如果你也在跟教程学 UE5 C++ 的 JSON 部分,到了第 61-3 节这里刚好进入最绕的一段——类继承链、各种 Value 子类、老师给的例题代码。我干脆把这块彻底理一遍,从数据结构到继承关系,再到可以直接抄的代码,一次说清楚。这份整理适合正在学FJsonObject系列类的同学,也适合做工具、配置、存档功能时被 JSON API 绕晕的人。
1. FJsonObject 与 FJsonValue:JSON 在 UE 中的两个主角
1.1 FJsonObject 到底存了什么
JSON 文件的内容本质上是键值对的集合,比如{"name": "Ava", "level": 12}。在 UE5 中,这个整体对象就是FJsonObject。一句话总结:FJsonObject存储了 json 文件里的键值对,它的内部结构可以理解为:
TMap<FString, TSharedPtr<FJsonValue>> Values;FString是键名TSharedPtr<FJsonValue>是值
这意味着FJsonObject本身不直接存字符串或数字,它存的是一层FJsonValue的智能指针。所有的实际值都被包在FJsonValue的各种子类里。这个设计跟你平时用第三方 JSON 库(比如 nlohmann/json)完全不同,后者一个json类型就能装所有类型,而 UE5 用的是“对象-值”分离的模式。
为什么要这么设计?因为 JSON 的类型系统比 C++ 松散得多。一个值可能是字符串、数字、布尔、数组、嵌套对象,甚至空值。C++ 一个成员变量只能有一个类型,没法表达这种“值类型不确定”的情况。FJsonValue作为所有具体值类型的基类,让FJsonObject能统一保存各种类型,读取时再通过AsString()、AsNumber()等接口按需取出。
类比来说,FJsonObject是一张表格,每行有“字段名”和“内容”;FJsonValue就是那个“内容”的通用包装盒,盒子打开后可能是苹果、书本或者另一张表格。
1.2 FJsonValue 的三个典型子类形态
FJsonValue不是一个能直接 new 的类,它面向多态使用。以常见的 JavaScrip t 对象字面量为例:
{ "playerName": "Ava", "level": 12, "isVIP": true }UE5 在解析时做的事是:
| JSON 原始值 | 对应的 FJsonValue 子类 | 说明 |
|---|---|---|
"Ava" | FJsonValueString | 字符串值 |
12 | FJsonValueNumber | 数值,内部存 double |
true | FJsonValueBoolean | 布尔值 |
[1,2,3] | FJsonValueArray | 数组,内部是TArray<TSharedPtr<FJsonValue>> |
{...} | FJsonValueObject | 嵌套对象,内部是TSharedPtr<FJsonObject> |
null | FJsonValueNull | 空值 |
从这张表能看出来,UE5 把 JSON 的六种类型对应到了 FJsonValue 的各个子类上。也就是说,一个 FJsonValue 的 TSharedPtr,拿到之后要判断它到底是什么子类,或者直接用各种 AsXXX 方法取用。
这里有个新手很容易搞混的点:FJsonValueArray和TArray<FJsonValue>不是一回事。FJsonValueArray是一个类,它内部再包一层TArray<TSharedPtr<FJsonValue>>。取数组时你要这样做:
TArray<TSharedPtr<FJsonValue>> Items = InObject->GetArrayField(TEXT("inventory"));拿到Items后,每个元素又是一个TSharedPtr<FJsonValue>,还得继续根据元素类型做判断。这种嵌套包装的方式,有人觉得麻烦,但好处是类型边界非常清晰,不会出现类型混用的问题。
2. 继承链拆解:从 FJsonValue 到 FJsonValueObject
2.1 一张表看懂子类
很多教程讲到这个地方就是罗列类名,如果自己不画一遍继承关系很容易忘。我根据 UE 源码整理了一份简明的“JSON 相关类族谱”:
FJsonValue ├── FJsonValueObject ├── FJsonValueString ├── FJsonValueNumber ├── FJsonValueBoolean ├── FJsonValueArray ├── FJsonValueNull └── (其他少见子类)对应的头文件分布:
Dom/JsonObject.h:FJsonObjectDom/JsonValue.h:FJsonValue及全部子类Serialization/JsonSerializer.h:FJsonSerializer,负责序列化与反序列化Serialization/JsonWriter.h:TJsonWriter,写字符串流Serialization/JsonReader.h:TJsonReader,读字符串流
整个体系不复杂,核心关系一句话:FJsonSerializer用TJsonReader读入原始字符串,调用Deserialize生成FJsonObject;反过来,FJsonSerializer用TJsonWriter将FJsonObject写成 JSON 字符串。FJsonObject内部再递归地引用FJsonValue子类。
源码里FJsonValue基类是一个纯虚类,接口大致包括:
enum class EJsonType { None, Null, String, Number, Boolean, Array, Object }; virtual EJsonType GetType() const = 0; virtual TSharedPtr<FJsonValue> Clone() const = 0; virtual bool TryGetString(FString& OutString) const; virtual bool TryGetNumber(double& OutNumber) const; virtual bool TryGetBool(bool& OutBool) const;基类提供类型枚举与各种转换尝试接口,子类重写其中对自己有意义的部分。比如FJsonValueString的TryGetString返回true,但TryGetNumber就返回false。这种设计在逻辑上保证类型安全性——你不会把"abc"悄无声息地当数字用。
2.2 为什么多态在这里被反复强调
刚才那种“基类指针指向子类”的设计,正是利用 C++ 多态。在FJsonObject看来,它只认FJsonValue,不需要知道具体是字符串还是嵌套对象。这样做的好处是什么呢?
举个例子。你的存档数据里有一个字段"lastLogin",之前一直是字符串,后来想改成数字时间戳。解析代码的目标是把这个字段取出来:
TSharedPtr<FJsonValue> Value = Root->TryGetField(TEXT("lastLogin"));从读取角度看,TryGetField返回的就是TSharedPtr<FJsonValue>,你不需要预先知道它是字符串还是数字,只需要在取用的时候选择AsString()或AsNumber()。更妙的是TryGetString和AsString这类接口有转换能力,比如某些情况下数字也能通过AsString()得到字符串形式,而FJsonValueString也能输出对应的数字形态,这在数据宽进宽出时非常灵活。
从写入角度看,你随时可以 new 一个FJsonValueString或FJsonValueNumber,然后放进FJsonObject::SetField。这套接口完全不需要上层感知具体子类类型,统一由FJsonValue这个抽象层兜底。
当初我第一次学这块时,也问过“为什么不能直接写一个TMap<FString, FString>”。后来遇到“数组里套对象,对象里再套数组”的三层嵌套结构,才明白FJsonValueObject作为递归节点有多关键。一个统一的基类让这种递归嵌套成为可能,FJsonValueArray里就可以存FJsonValueObject,FJsonValueObject内部又能再存一个FJsonValueArray。
3. 老师例题代码逐行解析:从文件到 FJsonObject
3.1 完整代码
课程例题的典型场景是读取一个config.json文件,然后从中取值。这里我给出一份完整可运行的版本,并在旁边备注解析思路:
// JsonLoadSample.h #pragma once #include "CoreMinimal.h" #include "Kismet/BlueprintFunctionLibrary.h" #include "JsonLoadSample.generated.h" UCLASS() class YOURPROJECT_API UJsonLoadSample : public UBlueprintFunctionLibrary { GENERATED_BODY() public: UFUNCTION(BlueprintCallable, Category = "JSON") static bool LoadJsonFromFile(const FString& FilePath, TSharedPtr<FJsonObject>& OutObject); UFUNCTION(BlueprintCallable, Category = "JSON") static bool ReadPlayerInfoFromJson(const FString& FilePath, FString& OutName, int32& OutLevel); };// JsonLoadSample.cpp #include "JsonLoadSample.h" #include "Misc/FileHelper.h" #include "Dom/JsonObject.h" #include "Dom/JsonValue.h" #include "Serialization/JsonReader.h" #include "Serialization/JsonSerializer.h" bool UJsonLoadSample::LoadJsonFromFile(const FString& FilePath, TSharedPtr<FJsonObject>& OutObject) { // 第 1 步:把 json 文件内容读到 FString FString FileContent; if (!FFileHelper::LoadFileToString(FileContent, *FilePath)) { UE_LOG(LogTemp, Error, TEXT("Failed to load json file: %s"), *FilePath); return false; } // 第 2 步:创建 JsonReader 来逐字解析字符串 TSharedRef<TJsonReader<>> Reader = TJsonReaderFactory<>::Create(FileContent); // 第 3 步:反序列化 // 注意:这里必须传 TSharedPtr<FJsonObject>&,函数内部会对 OutObject 赋值 if (FJsonSerializer::Deserialize(Reader, OutObject) && OutObject.IsValid()) { return true; } UE_LOG(LogTemp, Error, TEXT("Failed to deserialize json content")); return false; } bool UJsonLoadSample::ReadPlayerInfoFromJson(const FString& FilePath, FString& OutName, int32& OutLevel) { TSharedPtr<FJsonObject> RootObject; if (!LoadJsonFromFile(FilePath, RootObject)) { return false; } // 第 4 步:按字段名取值 OutName = RootObject->GetStringField(TEXT("playerName")); OutLevel = RootObject->GetNumberField(TEXT("level")); // 也可用更安全的 TryGet 版本 // RootObject->TryGetStringField(TEXT("playerName"), OutName); // RootObject->TryGetNumberField(TEXT("level"), OutLevel); return true; }3.2 拆解:FJsonSerializer 的读入与解析
这段例题代码里最关键的是FJsonSerializer::Deserialize那一行。我拆开细说。
TJsonReader<>::Create(FileContent)这一步创建了一个读取器。它不会立刻解析,而是把字符串包成流,按 JSON 语法逐字节读取。TJsonReader是模板类,模板参数是字符编码类型,默认不写就是TCHAR,也就是宽字符版。
然后是:
FJsonSerializer::Deserialize(Reader, OutObject)OutObject的类型必须是TSharedPtr<FJsonObject>&。函数内部会根据语法树构造一个FJsonObject(也可能是一整个以对象为根的结构),根对象里再递归构造各个FJsonValue子类。如果最外层是一个数组,用FJsonSerializer::Deserialize(Reader, OutArray)配合TArray<TSharedPtr<FJsonValue>>&也行,但文件里大多是对象,直接对根调用AsObject()更容易。
有几个容易出错的点:
第一,Deserialize返回true不代表取到的字段一定存在。比如 JSON 内容里根本没有playerName,反序列化照样成功,只是GetStringField会返回空字符串。如果你要在 Map 的 Blueprint 里显示读取结果,很容易被误导。所以建议在需要严谨校验的场合用TryGetXxxField版本。
第二,TSharedPtr和TSharedRef的区别。TJsonReaderFactory<>::Create返回TSharedRef,表示这个读取器一定有效,由工厂创建后就一直被引用。而OutObject是TSharedPtr,因为反序列化失败时它应该保持为空。给FJsonObject传引用时也常见TSharedPtr参数,这体现的是“对象可能不存在”的语义。
第三,文件的编码问题。FFileHelper::LoadFileToString默认按 UTF-8(不带 BOM)读,如果文件带 BOM 或者在 Windows 下另存为 ANSI,FJsonSerializer::Deserialize可能在第一个字符就失败。你可以在记事本另存时选 UTF-8 解决,也可以手动判空。
读入之后的取值环节,GetStringField内部其实会优先尝试找到指定键名对应的FJsonValue,然后调用它的AsString()。如果类型不匹配(比如字段值是数字),AsString()会尝试做转换,所以有时候你写错了字段类型,程序并不会立刻报错,只是结果跟你预期不一样。这也是为什么调 JSON 时要先打印一遍。
4. 写入与反向操作:FJsonObject → JSON 字符串
4.1 序列化写作的代码与细节
上面例题主要演示读取,实际开发中写出的场景同样常见。比如你要生成一个存档文件、导出一份配置表,就需要从FJsonObject写到 FString,再写到磁盘。
基础写法如下:
bool UJsonLoadSample::SaveJsonToFile(const FString& FilePath, const TSharedPtr<FJsonObject>& InObject) { if (!InObject.IsValid()) { return false; } FString OutputString; // 第 1 步:创建 Writer TSharedRef<TJsonWriter<>> Writer = TJsonWriterFactory<>::Create(&OutputString); // 第 2 步:序列化 if (!FJsonSerializer::Serialize(InObject.ToSharedRef(), Writer)) { UE_LOG(LogTemp, Error, TEXT("Failed to serialize json object")); return false; } // 第 3 步:写文件 Writer->Close(); return FFileHelper::SaveStringToFile(OutputString, *FilePath); }对应的构造对象代码:
TSharedPtr<FJsonObject> MakePlayerObject(const FString& Name, int32 Level, bool IsVIP) { TSharedPtr<FJsonObject> Root = MakeShared<FJsonObject>(); Root->SetStringField(TEXT("playerName"), Name); Root->SetNumberField(TEXT("level"), Level); Root->SetBoolField(TEXT("isVIP"), IsVIP); // 数组字段 TArray<TSharedPtr<FJsonValue>> Inventory; Inventory.Add(MakeShared<FJsonValueString>(TEXT("sword"))); Inventory.Add(MakeShared<FJsonValueString>(TEXT("shield"))); Root->SetArrayField(TEXT("inventory"), Inventory); // 嵌套对象 TSharedPtr<FJsonObject> Stats = MakeShared<FJsonObject>(); Stats->SetNumberField(TEXT("attack"), 88); Stats->SetNumberField(TEXT("defense"), 75); Root->SetObjectField(TEXT("stats"), Stats); return Root; }这里注意几个细节:
MakeShared<FJsonObject>()是 UE 推荐的创建共享指针方式,避免直接new后还要手动管理引用计数。SetArrayField需要的不是TArray<FString>,而是TArray<TSharedPtr<FJsonValue>>,所以每个元素都要包一层FJsonValueString。这个过程比较繁琐,想省事的可以用FJsonObjectConverter直接把 UStruct 转成FJsonObject,但那就绕开了例题想讲的类继承关系,适合后续进阶再看。- 写完
Serialize之后最好调用Writer->Close(),确保缓冲区内容被完全写出。尤其当 Writer 内部有格式化逻辑时,不 Close 可能导致最后一个}没写进字符串。
有同学问格式化输出怎么弄。TJsonWriterFactory::Create(&OutputString)默认输出是不带缩进的紧凑格式,阅读性差。想输出带缩进格式,需要指定EWJP_PrettyPrint枚举(也叫JsonWriterPrettyPrint):
TSharedRef<TJsonWriter<>> Writer = TJsonWriterFactory<>::Create(&OutputString, /*Indent=*/EWJP_PrettyPrint);这样输出的 JSON 会带换行缩进,方便人工检查。只是文件体积会大一些,但逻辑存档基本无感。
4.2 FJsonObjectConverter 与原生 FJsonObject 的取舍
写到这里必须提一个很容易在项目里遇到的岔路口:什么时候用原生FJsonObject,什么时候用FJsonObjectConverter。
FJsonObjectConverter是把 UStruct/UObject 与FJsonObject互相转换的辅助类。对简单结构体或配置类来说,用FJsonObjectConverter::UStructToJsonObject非常省事:
FPlayerInfo PlayerInfo; PlayerInfo.Name = TEXT("Ava"); PlayerInfo.Level = 12; TSharedPtr<FJsonObject> JsonObj = FJsonObjectConverter::UStructToJsonObject(PlayerInfo);不过这类转换依赖反射系统,要求成员变量必须是UPROPERTY,且对嵌套容器、动态数组的支持也有边界。例题里老师故意用原生FJsonObject+FJsonValue,就是要你先把最底层的数据组织结构搞明白。一旦你理解了FJsonValue子类的存在,再看FJsonObjectConverter,会发现它的原理无非是帮你遍历结构体成员,然后逐个 new 对应的FJsonValue子类填进去。
在项目选型时我的建议是:结构固定、层级简单、不想写一堆 SetField 代码 → 用 Converter;结构灵活、需要动态增删字段、嵌套层级深 → 老老实实用原生类。两者在序列化性能上差别不大,主要差在代码可维护性和类型安全性上。
5. 踩坑实录与常见问题速查
5.1 常见报错与排查思路
把项目里实际遇到过的 JSON 相关坑整理成一张速查表,你对照着找就行:
| 症状 | 原因 | 解决方案 |
|---|---|---|
Deserialize返回false | 文件编码非 UTF-8,或字符串开头有 BOM | 用无 BOM 的 UTF-8 另存,或读取后检查首字符是否为{ |
Deserialize返回true但OutObject为空 | 传入的TSharedPtr未初始化,或解析根节点不是对象 | 初始化RootObject = MakeShared<FJsonObject>()再传入 |
| 取值总是空/0/false | 字段名拼写错误,或键名大小写不匹配 | 打印RootObject->Values键名做对比 |
| 嵌套对象里取不到值 | 忘了一层AsObject() | 先GetObjectField(TEXT("stats"))拿到子对象,再取内层字段 |
| 数组元素取值报错 | 直接把FJsonValueArray当普通数组用 | 先用AsArray(),再逐个取元素 |
| 数字精度丢失 | FJsonValueNumber内部是 double,转 int32 可能截断 | 需要准确整数时用AsNumber()后自行强转判断边界 |
| 写入文件后中文乱码 | 编码问题,文件不是 UTF-8 | 使用FFileHelper::SaveStringToFile时传入FFileHelper::EEncodingOptions::ForceUTF8 |
一个小技巧:调试时在Deserialize返回false的地方打印Reader->GetErrorMessage()。很多时候错误信息会直接告诉你“第几行第几个字符有问题”,比肉眼盯大 JSON 文件快得多。
5.2 类型转换的隐藏坑位
FJsonValue的转换接口有时表现得很“宽容”,这既是优点也是坑。比如数字类型的字段,你用AsString()拿到的字符串格式可能跟你预期的不同。看一个实际例子:
{ "level": 12 }代码写成:
FString LevelStr = RootObject->GetStringField(TEXT("level"));GetStringField内部会找到FJsonValueNumber,然后调用它的AsString()。数字值小的时候,结果是"12",看着没啥问题。但如果值是12.50,得到的字符串可能是"12.5"或"12.50",取决于浮点数转字符串的格式。你要是拿这个字符串去拼路径或做比较,可能就踩坑了。所以取一个你明确知道是数字的字段,就用AsNumber()转int32或double,不要依赖隐式转换。
另一个坑是GetNumberField的行为。某个字段本来应该是数字,但 JSON 文件里被人手改成"12"(带引号的字符串),FJsonValueString::AsNumber其实会尝试解析字符串内容,多数情况下能转成功。但如果字符串是"12abc",解析失败时返回0,你的业务逻辑就可能被带偏。这点在设计配置校验时要特别小心,宁可自己加一个IsValid标记,也不要只依赖 API 的返回值。
5.3 极端数据结构与内存占用
最后捎带提一下性能相关的事。FJsonObject内部是一个TMap,每个键值对都对应一个FJsonValue子类实例。当 JSON 文件很大(比如几十 MB 的清单文件)时,内存占用会明显上升,因为每个值对象还有额外虚函数表开销。如果是做工具,这类规模问题不大。如果是游戏运行时频繁读取大配置,建议数据量超过几 MB 就考虑换二进制序列化(比如FArchive或FTableRowBase)。
反过来,如果你要处理的是那种“数组里套一万个对象”的极端 JSON,解析过程会非常吃内存,甚至出现明显的卡顿。真遇到这种,就别在运行时解析了,离线处理成 UE 原生数据格式,运行时直接加载。
提示:
FJsonObject的键名是区分大小写的。GetStringField(TEXT("PlayerName"))和GetStringField(TEXT("playername"))拿到的不是同一个键,这个跟 JSON 标准一致,但在代码里特别是从外部接口对接时容易忽视。
我个人敲过几遍例题代码之后最深的体会是:UE 的 JSON 体系真正要背的并不多,你只需要记住FJsonValue是多态基类、FJsonObject持有TMap<FString, TSharedPtr<FJsonValue>>、FJsonSerializer负责出入流,剩下全是查文档的事。像读取文件前先检验编码、嵌套对象取值前先判断IsValid、写入后调用Close这类细节,都是在实际项目里踩过坑之后才会在意的。把例题代码自己重写两遍,再改造成自己的存档工具类,这一小节的课程目标就算真正拿下了。