ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

UE USTRUCT 转 JSON:反射序列化与 FJsonObjectConverter 完整指南

UE USTRUCT 转 JSON:反射序列化与 FJsonObjectConverter 完整指南 项目标题将 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后UHTUnreal Header Tool会在编译期生成这个结构体的反射描述。运行时FJsonObjectConverter会通过TFieldIteratorFProperty遍历结构体的所有反射属性逐个读取当前实例里对应字段的值再根据字段类型决定写入 JSON 对象的方式。所以有三条硬性规则结构体必须用USTRUCT声明并包含GENERATED_BODY()。想导出到 JSON 的字段必须用UPROPERTY修饰。字段类型必须在转换器的支持范围内。我见过不少新人把USTRUCT当普通 C 结构体用字段全裸奔结果转换函数返回空对象原因就是反射系统根本看不到这些裸字段。2.2 FJsonObjectConverter 与 FJsonSerializer 的分工UE 的 JSON 体系里有两个核心工具很多人会混淆。FJsonObjectConverter负责统一“内存对象”和“FJsonObject”之间的转换。它做的事情是把 USTRUCT 实例里的每个字段映射成FJsonValue节点再塞进一个TSharedPtrFJsonObject。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) TArrayFString 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 里叫PlayerNameJSON 里就是PlayerName不会自动转成playerName或player_name。这在实际对接外部系统时经常出问题。服务端接口可能是player_name可能是playerName甚至可能是全小写。如果为每个字段改名最简单的做法是先转成FJsonObject再在对象层做改名映射TSharedPtrFJsonObject RootObject MakeSharedFJsonObject(); 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; TSharedRefTJsonWriterTCHAR Writer TJsonWriterFactoryTCHAR::Create(OutJson); FJsonSerializer::Serialize(RootObject.ToSharedRef(), Writer);这个做法的好处是把“协议字段名”和“C字段名”彻底解耦。我自己的项目里会把映射关系集中放一张TMapFString, 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的支持不稳定部分版本直接不支持新版本对TMapFString, 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再操作。一个典型的例子是在导出前给根对象追加一个公共字段TSharedPtrFJsonObject RootObject MakeSharedFJsonObject(); 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的方式生成字符串TSharedRefTJsonWriterTCHAR Writer TJsonWriterFactoryTCHAR::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转换报错或结果不对旧引擎对容器支持有限改用TArrayFKeyValuePair或自定义序列化FText导成一大串嵌套对象FText内部结构特殊传输层改用FString6.2 版本差异自查方法Unreal 的 JSON 转换接口在 4.x 和 5.x 之间经历过不少调整。与其记我写的某一版参数不如掌握一个自查方法打开引擎源码目录找到JsonObjectConverter.h直接看当前版本里UStructToJsonObjectString和JsonObjectStringToUStruct的完整声明。版本差异集中在几个地方FieldToPropertyMap参数改名或删除。是否支持缩进参数Indent。是否支持自定义序列化器TCustomJsonSerializationMap。反序列化时是否允许部分字段缺失。遇到参数不匹配的编译错误不要硬套老代码优先去看当前引擎的头文件注释那里才是最新、最准确的行为说明。这篇内容看起来是讲一个转换函数实际上是把 UE 反射序列化这条链路完整走了一遍。我个人在实际项目维护中最深的一个体会是与其让结构体字段直接暴露给外部协议不如在FJsonObject层面做一层命名映射和字段过滤把协议变化隔离在转换模块内部。这样后端改一次字段名、加一次字段类型改动范围都只限于那个转换模块而不会波及整个游戏逻辑。如果你也在长期对接外部系统这个思路值得尽早落地。
返回列表