最近在某个项目的存档模块里折腾 JSON 读写UE5 的 C 侧 API 绕来绕去最后发现核心就两个类FJsonObject和FJsonValue。如果你也在跟教程学 UE5 C 的 JSON 部分到了第 61-3 节这里刚好进入最绕的一段——类继承链、各种 Value 子类、老师给的例题代码。我干脆把这块彻底理一遍从数据结构到继承关系再到可以直接抄的代码一次说清楚。这份整理适合正在学FJsonObject系列类的同学也适合做工具、配置、存档功能时被 JSON API 绕晕的人。1. FJsonObject 与 FJsonValueJSON 在 UE 中的两个主角1.1 FJsonObject 到底存了什么JSON 文件的内容本质上是键值对的集合比如{name: Ava, level: 12}。在 UE5 中这个整体对象就是FJsonObject。一句话总结FJsonObject存储了 json 文件里的键值对它的内部结构可以理解为TMapFString, TSharedPtrFJsonValue Values;FString是键名TSharedPtrFJsonValue是值这意味着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 子类说明AvaFJsonValueString字符串值12FJsonValueNumber数值内部存 doubletrueFJsonValueBoolean布尔值[1,2,3]FJsonValueArray数组内部是TArrayTSharedPtrFJsonValue{...}FJsonValueObject嵌套对象内部是TSharedPtrFJsonObjectnullFJsonValueNull空值从这张表能看出来UE5 把 JSON 的六种类型对应到了 FJsonValue 的各个子类上。也就是说一个 FJsonValue 的 TSharedPtr拿到之后要判断它到底是什么子类或者直接用各种 AsXXX 方法取用。这里有个新手很容易搞混的点FJsonValueArray和TArrayFJsonValue不是一回事。FJsonValueArray是一个类它内部再包一层TArrayTSharedPtrFJsonValue。取数组时你要这样做TArrayTSharedPtrFJsonValue Items InObject-GetArrayField(TEXT(inventory));拿到Items后每个元素又是一个TSharedPtrFJsonValue还得继续根据元素类型做判断。这种嵌套包装的方式有人觉得麻烦但好处是类型边界非常清晰不会出现类型混用的问题。2. 继承链拆解从 FJsonValue 到 FJsonValueObject2.1 一张表看懂子类很多教程讲到这个地方就是罗列类名如果自己不画一遍继承关系很容易忘。我根据 UE 源码整理了一份简明的“JSON 相关类族谱”FJsonValue ├── FJsonValueObject ├── FJsonValueString ├── FJsonValueNumber ├── FJsonValueBoolean ├── FJsonValueArray ├── FJsonValueNull └── (其他少见子类)对应的头文件分布Dom/JsonObject.hFJsonObjectDom/JsonValue.hFJsonValue及全部子类Serialization/JsonSerializer.hFJsonSerializer负责序列化与反序列化Serialization/JsonWriter.hTJsonWriter写字符串流Serialization/JsonReader.hTJsonReader读字符串流整个体系不复杂核心关系一句话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 TSharedPtrFJsonValue 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之前一直是字符串后来想改成数字时间戳。解析代码的目标是把这个字段取出来TSharedPtrFJsonValue Value Root-TryGetField(TEXT(lastLogin));从读取角度看TryGetField返回的就是TSharedPtrFJsonValue你不需要预先知道它是字符串还是数字只需要在取用的时候选择AsString()或AsNumber()。更妙的是TryGetString和AsString这类接口有转换能力比如某些情况下数字也能通过AsString()得到字符串形式而FJsonValueString也能输出对应的数字形态这在数据宽进宽出时非常灵活。从写入角度看你随时可以 new 一个FJsonValueString或FJsonValueNumber然后放进FJsonObject::SetField。这套接口完全不需要上层感知具体子类类型统一由FJsonValue这个抽象层兜底。当初我第一次学这块时也问过“为什么不能直接写一个TMapFString, FString”。后来遇到“数组里套对象对象里再套数组”的三层嵌套结构才明白FJsonValueObject作为递归节点有多关键。一个统一的基类让这种递归嵌套成为可能FJsonValueArray里就可以存FJsonValueObjectFJsonValueObject内部又能再存一个FJsonValueArray。3. 老师例题代码逐行解析从文件到 FJsonObject3.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, TSharedPtrFJsonObject 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, TSharedPtrFJsonObject 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 来逐字解析字符串 TSharedRefTJsonReader Reader TJsonReaderFactory::Create(FileContent); // 第 3 步反序列化 // 注意这里必须传 TSharedPtrFJsonObject函数内部会对 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) { TSharedPtrFJsonObject 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的类型必须是TSharedPtrFJsonObject。函数内部会根据语法树构造一个FJsonObject也可能是一整个以对象为根的结构根对象里再递归构造各个FJsonValue子类。如果最外层是一个数组用FJsonSerializer::Deserialize(Reader, OutArray)配合TArrayTSharedPtrFJsonValue也行但文件里大多是对象直接对根调用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 下另存为 ANSIFJsonSerializer::Deserialize可能在第一个字符就失败。你可以在记事本另存时选 UTF-8 解决也可以手动判空。读入之后的取值环节GetStringField内部其实会优先尝试找到指定键名对应的FJsonValue然后调用它的AsString()。如果类型不匹配比如字段值是数字AsString()会尝试做转换所以有时候你写错了字段类型程序并不会立刻报错只是结果跟你预期不一样。这也是为什么调 JSON 时要先打印一遍。4. 写入与反向操作FJsonObject → JSON 字符串4.1 序列化写作的代码与细节上面例题主要演示读取实际开发中写出的场景同样常见。比如你要生成一个存档文件、导出一份配置表就需要从FJsonObject写到 FString再写到磁盘。基础写法如下bool UJsonLoadSample::SaveJsonToFile(const FString FilePath, const TSharedPtrFJsonObject InObject) { if (!InObject.IsValid()) { return false; } FString OutputString; // 第 1 步创建 Writer TSharedRefTJsonWriter 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); }对应的构造对象代码TSharedPtrFJsonObject MakePlayerObject(const FString Name, int32 Level, bool IsVIP) { TSharedPtrFJsonObject Root MakeSharedFJsonObject(); Root-SetStringField(TEXT(playerName), Name); Root-SetNumberField(TEXT(level), Level); Root-SetBoolField(TEXT(isVIP), IsVIP); // 数组字段 TArrayTSharedPtrFJsonValue Inventory; Inventory.Add(MakeSharedFJsonValueString(TEXT(sword))); Inventory.Add(MakeSharedFJsonValueString(TEXT(shield))); Root-SetArrayField(TEXT(inventory), Inventory); // 嵌套对象 TSharedPtrFJsonObject Stats MakeSharedFJsonObject(); Stats-SetNumberField(TEXT(attack), 88); Stats-SetNumberField(TEXT(defense), 75); Root-SetObjectField(TEXT(stats), Stats); return Root; }这里注意几个细节MakeSharedFJsonObject()是 UE 推荐的创建共享指针方式避免直接new后还要手动管理引用计数。SetArrayField需要的不是TArrayFString而是TArrayTSharedPtrFJsonValue所以每个元素都要包一层FJsonValueString。这个过程比较繁琐想省事的可以用FJsonObjectConverter直接把 UStruct 转成FJsonObject但那就绕开了例题想讲的类继承关系适合后续进阶再看。写完Serialize之后最好调用Writer-Close()确保缓冲区内容被完全写出。尤其当 Writer 内部有格式化逻辑时不 Close 可能导致最后一个}没写进字符串。有同学问格式化输出怎么弄。TJsonWriterFactory::Create(OutputString)默认输出是不带缩进的紧凑格式阅读性差。想输出带缩进格式需要指定EWJP_PrettyPrint枚举也叫JsonWriterPrettyPrintTSharedRefTJsonWriter 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; TSharedPtrFJsonObject JsonObj FJsonObjectConverter::UStructToJsonObject(PlayerInfo);不过这类转换依赖反射系统要求成员变量必须是UPROPERTY且对嵌套容器、动态数组的支持也有边界。例题里老师故意用原生FJsonObjectFJsonValue就是要你先把最底层的数据组织结构搞明白。一旦你理解了FJsonValue子类的存在再看FJsonObjectConverter会发现它的原理无非是帮你遍历结构体成员然后逐个 new 对应的FJsonValue子类填进去。在项目选型时我的建议是结构固定、层级简单、不想写一堆 SetField 代码 → 用 Converter结构灵活、需要动态增删字段、嵌套层级深 → 老老实实用原生类。两者在序列化性能上差别不大主要差在代码可维护性和类型安全性上。5. 踩坑实录与常见问题速查5.1 常见报错与排查思路把项目里实际遇到过的 JSON 相关坑整理成一张速查表你对照着找就行症状原因解决方案Deserialize返回false文件编码非 UTF-8或字符串开头有 BOM用无 BOM 的 UTF-8 另存或读取后检查首字符是否为{Deserialize返回true但OutObject为空传入的TSharedPtr未初始化或解析根节点不是对象初始化RootObject MakeSharedFJsonObject()再传入取值总是空/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持有TMapFString, TSharedPtrFJsonValue、FJsonSerializer负责出入流剩下全是查文档的事。像读取文件前先检验编码、嵌套对象取值前先判断IsValid、写入后调用Close这类细节都是在实际项目里踩过坑之后才会在意的。把例题代码自己重写两遍再改造成自己的存档工具类这一小节的课程目标就算真正拿下了。