项目标题:将 USTRUCT 类型的实例对象,转换成对应的 JSON 字符串格式
服务端要做一份配置下发接口,要求客户端把玩家当前状态打包成 JSON 字符串 POST 上去。我第一次图省事,用FString::Printf一段一段手工拼 JSON,十几个字段拼到一半就分不清谁是谁了,该转义的引号、换行也闹出过几次解析失败。换成 USTRUCT 加FJsonObjectConverter之后,几十行手拼代码缩成了两三行,字段谁有谁没有、什么类型什么名字,全部由反射系统统一处理,问题一次性清干净。
这篇内容讲的就是这件事:在 Unreal Engine 里,把一个声明了 USTRUCT 的结构体实例,按照字段定义转换成 JSON 字符串;顺带也讲清楚反向的 JSON 字符串还原结构体。文章会覆盖原理、模块依赖、基础写法、字段映射、复杂类型处理、性能边界和常见坑。适合正在做网络对接、存档读写、调试工具,或者单纯想把结构体快速导出去给别的程序用的开发者。不管你是刚接触 JSON 转换,还是已经踩过不少坑,都能在里面找到能直接拿去用的方案。
1. 项目拆解:USTRUCT转JSON到底在解决什么问题
1.1 标题背后的核心需求
标题里的两个关键词非常明确:一个是 USTRUCT,一个是 JSON。
USTRUCT 是 Unreal Engine 中用于声明“带反射信息结构体”的宏。所谓反射,就是这个结构体运行时能告诉引擎自己有哪些字段、字段是什么类型、字段名是什么。JSON 这端则是一种跨语言、跨平台、纯文本的数据交换格式,广泛用于 Web API、配置文件、日志上报、编辑器导出等场景。把两者结合起来,本质就是让 UE 的 C++ 结构体能以 JSON 文本形式“走出引擎”,被服务端、网页端、Python 脚本或者其他任何支持 JSON 的系统读取。
这个需求的背后,通常不是“想用 JSON”,而是“要和外界交换数据”。自己项目内部用结构体传参很舒服,可一旦数据要发到 HTTP 接口、写进人类可读的配置文件、或者导出给策划看,就必须转成文本。JSON 只是最通用、最不容易出错的文本载体。
1.2 典型应用场景
实际项目里,这个转换能力最常见的使用场景有四类。
第一类是网络消息体。客户端和服务端之间用 HTTP 或者 WebSocket 通信,消息体按 JSON 组织。客户端把 USTRUCT 代表的玩家信息、战绩、背包数据一次性转成 JSON 发送,服务端解析后入库。
第二类是本地配置和存档。把结构体实例保存为 JSON 文件,下次启动读回来。比起自己定义二进制格式,JSON 文件可以直接打开检查,出问题一眼就能看出来。
第三类是调试输出。结构体里字段多,用UE_LOG一个个打印太啰嗦。直接转换成 JSON 字符串打一条日志,字段名和值都看得清清楚楚。
第四类是编辑器工具和外部程序交换。比如写编辑器插件批量导资源信息,导出的就可以是 JSON 数组文件。
1.3 能做什么、不能做什么
这套做法能覆盖绝大多数普通结构体:整数、浮点数、布尔、字符串、枚举、数组、嵌套结构体,以及一部分容器类型。但它不是万能的。TMap、TSet这类容器的支持在不同引擎版本里差异很大;FText导出后是一个嵌套对象而不仅仅是文本;没有UPROPERTY修饰的字段不参与转换,反射系统看不到它。这些边界不是一个“转换函数”能包办的,需要写的人在代码里做好约定。
还有个容易搞混的点:USTRUCT 和 UObject 是两回事。USTRUCT 是轻量的值类型,可以用StaticStruct()拿到反射定义;UObject 是引擎对象,序列化走的是另一套UObjectToJsonObjectString之类的路径。标题里明确说“USTRUCT 类型的实例对象”,所以本文聚焦在结构体上。
2. 前置准备与序列化原理
2.1 反射系统:为什么 USTRUCT 是前提
先理解一个关键点:UE 里的 JSON 转换器不是靠猜字段来做序列化的,它靠的是反射元数据。
当你写下USTRUCT(BlueprintType)并给字段加上UPROPERTY后,UHT(Unreal Header Tool)会在编译期生成这个结构体的反射描述。运行时,FJsonObjectConverter会通过TFieldIterator<FProperty>遍历结构体的所有反射属性,逐个读取当前实例里对应字段的值,再根据字段类型决定写入 JSON 对象的方式。
所以有三条硬性规则:
- 结构体必须用
USTRUCT声明,并包含GENERATED_BODY()。 - 想导出到 JSON 的字段必须用
UPROPERTY修饰。 - 字段类型必须在转换器的支持范围内。
我见过不少新人把USTRUCT当普通 C++ 结构体用,字段全裸奔,结果转换函数返回空对象,原因就是反射系统根本看不到这些裸字段。
2.2 FJsonObjectConverter 与 FJsonSerializer 的分工
UE 的 JSON 体系里有两个核心工具,很多人会混淆。
FJsonObjectConverter负责统一“内存对象”和“FJsonObject”之间的转换。它做的事情是把 USTRUCT 实例里的每个字段映射成FJsonValue节点,再塞进一个TSharedPtr<FJsonObject>。
FJsonSerializer负责“FJsonObject”和“文本 JSON”之间的互相转换。它把一个树形的 JSON 对象序列化成字符串,或者从字符串解析出树形对象。
这里有个非常好的中间层思路:当你只需要“USTRUCT 转字符串”时,可以一步到位调用现成接口;但当你想在序列化前后修改字段、加字段、删字段时,就应该先转成FJsonObject改完再序列化。这个中间层也是后面做字段映射、自定义格式的入口。
2.3 工程模块依赖设置
写代码之前先确认工程模块引用了 JSON 相关模块。
在项目的.Build.cs文件里,需要添加依赖:
PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "Json", "JsonUtilities" });Json是核心模块,JsonUtilities提供了一些封装工具,旧版本工程尤其常见。如果只用到FJsonObjectConverter和FJsonSerializer,一般Json模块就够;但为了保险,我通常两个都加。
编写代码时,需要包含头文件:
#include "JsonObjectConverter.h" #include "Dom/JsonObject.h" #include "Serialization/JsonSerializer.h"如果编译报找不到JsonObjectConverter.h,先别查头文件路径,回去检查.Build.cs是否真的加了模块。
3. 基础实操:一行代码完成USTRUCT转JSON字符串
3.1 定义一个可转换的USTRUCT
以一个游戏玩家档案为例:
USTRUCT(BlueprintType) struct FPlayerProfile { GENERATED_BODY() UPROPERTY(BlueprintReadOnly) FString PlayerName; UPROPERTY(BlueprintReadOnly) int32 Level = 1; UPROPERTY(BlueprintReadOnly) float Score = 0.0f; UPROPERTY(BlueprintReadOnly) bool bIsVIP = false; UPROPERTY(BlueprintReadOnly) TArray<FString> Achievements; };必须注意,字段上的UPROPERTY不是可有可无。如果一个字段想不出现在 JSON 里,要么不写UPROPERTY,要么在结构体上用UPROPERTY(Transient)标记成瞬态字段,然后在转换时配合SkipFlags排除。
3.2 标准转换写法
声明一个实例并填充数据,然后调用转换接口:
FPlayerProfile Profile; Profile.PlayerName = TEXT("Ada"); Profile.Level = 42; Profile.Score = 99.5f; Profile.bIsVIP = true; Profile.Achievements = { TEXT("FirstBlood"), TEXT("TankKiller") }; FString OutJson; const bool bSuccess = FJsonObjectConverter::UStructToJsonObjectString( FPlayerProfile::StaticStruct(), &Profile, OutJson ); if (bSuccess) { UE_LOG(LogTemp, Log, TEXT("%s"), *OutJson); }输出结果是:
{"PlayerName":"Ada","Level":42,"Score":99.5,"bIsVIP":true,"Achievements":["FirstBlood","TankKiller"]}这一步就是标题说的核心需求。FPlayerProfile::StaticStruct()拿到结构体的反射定义,&Profile是实例内存地址,OutJson接收结果。
有一点要提前打预防针:不同引擎版本里UStructToJsonObjectString的参数表不完全一样。比如 UE4.27 和 UE5.3 在“是否支持缩进参数”“是否支持自定义序列化器”上就有差异。最稳妥的办法是在编辑器里对着JsonObjectConverter.h的声明确认参数顺序。本文示例方案是最常用的一组参数,核心用法在所有支持FJsonObjectConverter的版本里都成立。
3.3 反序列化:从JSON字符串还原USTRUCT
转换是双向的,光会导出不够,还要能读回来。
FPlayerProfile Restored; const bool bParseSuccess = FJsonObjectConverter::JsonObjectStringToUStruct( OutJson, FPlayerProfile::StaticStruct(), &Restored ); if (bParseSuccess) { // Restored.PlayerName == TEXT("Ada") }JsonObjectStringToUStruct是字符串入口,内部会先调用FJsonSerializer::Deserialize解析成FJsonObject,再通过JsonObjectToUStruct写回结构体。
这里有一个很常见的需求:服务端返回的 JSON 里可能有额外字段,而结构体里并没有对应属性。此时新版引擎的JsonObjectToUStruct提供了不允许“部分字段缺失”的严格模式参数。常规场景不启用严格模式,解析器会自动跳过结构体里没有的字段,这样前后端字段扩展时不会因为多一个字段就把整段解析搞挂。
3.4 CheckFlags 与 SkipFlags 这两个参数
UStructToJsonObjectString参数里有一对很容易被忽略的int64标记位:CheckFlags和SkipFlags。
CheckFlags表示只导出“包含这些标记”的属性。比如传入CPF_Edit,就只导出标了Edit的属性,平时基本用不上。
SkipFlags表示跳过“包含这些标记”的属性。这个才是真正常用的。
举例来说,如果一个字段加了Transient,表示它不需要持久化:
UPROPERTY(Transient) FString SessionToken;转换时不想带上它,就可以这样:
FJsonObjectConverter::UStructToJsonObjectString( FPlayerProfile::StaticStruct(), &Profile, OutJson, 0, CPF_Transient );我还会把CPF_Deprecated也放进SkipFlags,把标记了废弃的字段一起过滤掉。这个参数在处理老结构体、兼容历史字段时非常有用,比改结构体定义要温柔得多。
4. 实用细节:字段名映射与复杂类型处理
4.1 字段名默认规则与坑
UE 的 JSON 转换器默认输出的是UPROPERTY原本的名字。也就是说,C++ 里叫PlayerName,JSON 里就是PlayerName,不会自动转成playerName或player_name。
这在实际对接外部系统时经常出问题。服务端接口可能是player_name,可能是playerName,甚至可能是全小写。如果为每个字段改名,最简单的做法是先转成FJsonObject,再在对象层做改名映射:
TSharedPtr<FJsonObject> RootObject = MakeShared<FJsonObject>(); FJsonObjectConverter::UStructToJsonObject( FPlayerProfile::StaticStruct(), &Profile, RootObject.ToSharedRef(), 0, 0 ); // 把 PlayerName 改成 player_name FString Value = RootObject->GetStringField(TEXT("PlayerName")); RootObject->RemoveField(TEXT("PlayerName")); RootObject->SetStringField(TEXT("player_name"), Value);改完之后再序列化:
FString OutJson; TSharedRef<TJsonWriter<TCHAR>> Writer = TJsonWriterFactory<TCHAR>::Create(&OutJson); FJsonSerializer::Serialize(RootObject.ToSharedRef(), Writer);这个做法的好处是把“协议字段名”和“C++字段名”彻底解耦。我自己的项目里会把映射关系集中放一张TMap<FString, FString>配置表,后端改协议名时只改配置,不动结构体代码。
4.2 布尔字段的 b 前缀引发的麻烦
UE 的命名规范是布尔字段加b前缀,例如bIsVIP。转换器导出时也原样带出去,JSON 里就出现了"bIsVIP":true。
外部接口通常不喜欢这个b。处理方式和字段改名一样,在FJsonObject中间层把bIsVIP改成is_vip或者isVip。
也可以从结构体设计层面规避。如果这个结构体只是内部逻辑使用的,那就保留b前缀;如果是专门为网络协议建的传输 DTO,可以故意给字段起名时不带b,比如就叫IsVIP,不过这样会牺牲一点 UE 命名风格的一致性。在这个问题上没有绝对正确答案,团队约定一致最重要。
4.3 嵌套结构体、数组、TArray 与枚举
嵌套的 USTRUCT 会被自动展开成 JSON 对象,不需要额外处理:
USTRUCT(BlueprintType) struct FPlayerProfile { GENERATED_BODY() UPROPERTY(BlueprintReadOnly) FEquipment Equip; };转换结果里的Equip会是一个完整的 JSON 对象,等价于内嵌结构体的字段集合。
TArray序列化成 JSON 数组,里面的元素可以是基础类型,也可以是嵌套结构体,转换器都会递归处理。
枚举是很多团队栽过跟头的地方。不同引擎版本对枚举的序列化方式不一样,有的导成数字,有的导成字符串名称。如果对接的服务端对枚举类型有严格要求,我建议不要依赖转换器的默认行为,要么在结构体里单独用一个int32存枚举整数值,要么在FJsonObject中间层手动读取枚举名并写入字符串字段。
容器类型里,TMap是最需要小心的。旧版引擎对TMap的支持不稳定,部分版本直接不支持;新版本对TMap<FString, T>这类“字符串键”的支持相对好,但整数键、结构体键会出各种怪问题。常规建议是:网络对接用的结构体,尽量避免TMap,改成TArray加“Key、Value”成对字段。这样无论引擎版本怎么变,序列化结果都是稳定的。
4.4 FText、软引用等特殊类型注意
FText在 UE 里是本地化文本,内部结构远比FString复杂。JSON 转换时,它会被导成一个带culture、text、key等字段的嵌套对象,而不是一个简单的字符串。如果服务端不关心本地化,只是想要一个字符串,请直接用FString类型。
FSoftObjectPath、TSoftClassPtr、TSubclassOf这些资源引用类型,转换器导出的通常是对象路径字符串。反序列化时,能否真正加载出对象取决于项目资源和引擎行为,不要指望 JSON 一解析完就有可用的对象指针。
5. 性能边界与更成熟的设计
5.1 别在 Tick 里高频转换
FJsonObjectConverter 用的是反射遍历,虽然有不错的优化,但每次转换都会创建FJsonObject树、动态分配节点、生成字符串。在开发构建下这个成本会明显放大,如果放在Tick里对几百个对象每帧转换一次,很快就能看到主线程卡顿。
我的经验法则是:
- 低频任务(每秒一次、手动触发、存档、发送消息),随便用。
- 高频任务(每帧、每几百毫秒轮询),先考虑做快照,避免直接持有游戏线程上的大结构体。
- 万级以上对象的批量转换,优先丢到异步线程,转换完再回到游戏线程处理结果。
还有一个实用技巧:如果同一份结构体在短时间内需要多次转 JSON,可以在字段不变时缓存上一次的结果字符串,减少重复反射遍历。
5.2 用 FJsonObject 中间层做高级操作
前面提到过,UStructToJsonObjectString是为了方便的一次性封装,真正灵活的是先转FJsonObject再操作。
一个典型的例子是在导出前给根对象追加一个公共字段:
TSharedPtr<FJsonObject> RootObject = MakeShared<FJsonObject>(); FJsonObjectConverter::UStructToJsonObject( FPlayerProfile::StaticStruct(), &Profile, RootObject.ToSharedRef(), 0, 0 ); RootObject->SetStringField(TEXT("client_version"), TEXT("1.8.5")); RootObject->SetNumberField(TEXT("timestamp"), FDateTime::UtcNow().ToUnixTimestamp());还可以把一个结构体塞进另一个结构体作为子对象,或者把整个对象丢进数组。这些操作在纯字符串层面几乎没法做,但在FJsonObject树形结构上就是几个方法调用的事。
5.3 不要依赖字段顺序
很多人的直觉是结构体字段按定义顺序导出,JSON 里也是按这个顺序显示。实际上,FJsonObject内部用的是映射结构存储字段,序列化输出的字段顺序并不保证和结构体定义顺序一致,在不同引擎版本、不同编译配置下都可能变化。
这个排序的不确定性在对接时千万不要设为前提。JSON 格式本身就不该依赖字段顺序,服务端、脚本、测试工具都应该按键名取字段。如果某个场景真的必须固定顺序,比如要做文件签名校验,就需要完全绕开FJsonObject,直接用手写TJsonWriter的方式生成字符串:
TSharedRef<TJsonWriter<TCHAR>> Writer = TJsonWriterFactory<TCHAR>::Create(&OutJson); Writer->WriteObjectStart(); Writer->WriteValue(TEXT("PlayerName"), Profile.PlayerName); Writer->WriteValue(TEXT("Level"), Profile.Level); Writer->WriteObjectEnd(); Writer->Close();这样输出顺序完全由代码控制,不会受映射结构干扰。代价是每个字段都要手写,适合少量、稳定的场景。
6. 问题排查与实操速查
6.1 常见问题速查表
我把实际开发中遇到最多的问题整理成一张表,覆盖率和命中率都非常高。
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
输出{}或缺失字段 | 字段没加UPROPERTY | 给字段补上UPROPERTY |
输出{}且完全不报错 | 结构体没有GENERATED_BODY()或宏写错 | 检查USTRUCT定义,重新生成头文件 |
编译找不到JsonObjectConverter.h | 模块依赖没加Json | 在.Build.cs添加Json和JsonUtilities |
布尔导出带b前缀 | UE 字段命名规范导致 | 在FJsonObject中间层改名,或定义传输 DTO 时不用b前缀 |
| JSON 字段顺序乱 | FJsonObject内部是映射结构 | 后端按键名取值;固定顺序请手写 Writer |
| 反序列化返回 false,但 JSON 看起来正常 | 字段类型不匹配,或启用了严格模式 | 打印 JSON 核对类型;关闭严格模式,使用宽容解析 |
| 枚举导出成数字/字符串不符合预期 | 不同版本默认行为不同 | 用int32手动存枚举,或中间层手动改字段 |
TMap转换报错或结果不对 | 旧引擎对容器支持有限 | 改用TArray<FKeyValuePair>或自定义序列化 |
FText导成一大串嵌套对象 | FText内部结构特殊 | 传输层改用FString |
6.2 版本差异自查方法
Unreal 的 JSON 转换接口在 4.x 和 5.x 之间经历过不少调整。与其记我写的某一版参数,不如掌握一个自查方法:打开引擎源码目录,找到JsonObjectConverter.h,直接看当前版本里UStructToJsonObjectString和JsonObjectStringToUStruct的完整声明。
版本差异集中在几个地方:
FieldToPropertyMap参数改名或删除。- 是否支持缩进参数
Indent。 - 是否支持自定义序列化器
TCustomJsonSerializationMap。 - 反序列化时是否允许部分字段缺失。
遇到参数不匹配的编译错误,不要硬套老代码,优先去看当前引擎的头文件注释,那里才是最新、最准确的行为说明。
这篇内容看起来是讲一个转换函数,实际上是把 UE 反射序列化这条链路完整走了一遍。我个人在实际项目维护中最深的一个体会是:与其让结构体字段直接暴露给外部协议,不如在FJsonObject层面做一层命名映射和字段过滤,把协议变化隔离在转换模块内部。这样后端改一次字段名、加一次字段类型,改动范围都只限于那个转换模块,而不会波及整个游戏逻辑。如果你也在长期对接外部系统,这个思路值得尽早落地。