ARTICLE DETAIL

资讯详情

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

UE中USTRUCT转JSON字符串的完整实现与避坑指南

UE中USTRUCT转JSON字符串的完整实现与避坑指南 1. 项目概述与核心需求拆解1.1 这个需求到底在解决什么问题USTRUCT 是 Unreal Engine 里进行数据组织和网络同步的基础单元而 JSON 是跨语言、跨平台交换数据时几乎绕不开的文本格式。把 USTRUCT 实例对象转成 JSON 字符串最典型的场景包括把服务端返回的数据持久化成配置文件、把玩家存档以 JSON 明文形式导出以便调试、把蓝图/ C 里计算的复杂结构体推送给 Web 前端、或者把一整套战斗统计数据序列化后塞进 HTTP 请求体里。很多刚接触 UE 序列化的开发者会下意识地去找一个“一键转换”的内置蓝图节点但实际动手之后就会发现UE 官方并没有提供完整的 “UStruct To JSON String” 的傻瓜式全自动方案。自带的FJsonObjectConverter::UStructToJsonObject能做结构体到 JSON 对象的转换但它输出的是一棵FJsonObject对象树而不是一个可以直接发给后端的字符串。你需要再调Serialize或者TJsonWriter做一步序列化。这个中间步骤的缺失恰恰是很多新手在论坛上反复提问的原因。除此之外USTRUCT 里能装的类型很多嵌套结构体、数组、TMap、TSet、bool、enum class、FVector、FRotator、FDateTime等等。每种类型的 JSON 表现形态都不一样处理不当就会出现字段丢失或者类型转换异常。我见过有人用UKismetSystemLibrary::Conv_StringToObject这类节点试图硬转结果在编辑器里可能有效打包后因为反射信息不全或者没有正确的序列化标记数据直接变空。所以这个项目标题背后真正的核心需求是在 UE 的开发框架内打通USTRUCT 反射系统 → 通用 JSON 数据模型 → 纯文本字符串这条链路而且要能裁剪出可配置、可扩展、能解释清楚每一步“为什么”的版本。1.2 适用人群与实际落地场景如果你是做 GamePlay 的需要把角色身上的装备属性面板导出一份“明文配置”给策划校验这个方案很适用如果你是做客户端网络层的要把一条结构化请求转成 JSON 字符串进行日志记录或者加签这个方案也很适用哪怕你只是想在编辑器工具脚本里批量把 DataTable 行数据转成文件用于自动化测试用例生成同样的思路可以复用。有一点要提前说明如果你只是有几个固定字段、手写拼接字符串就能解决的场景其实没必要引入 FJsonObjectConverter。这个工具的本质价值在于“反射驱动的泛型转换”也就是任意 USTRUCT 丢进去都能自动遍历属性。如果你面对的数据结构是固定的那么手写TSharedRefFJsonObject RootObj MakeSharedFJsonObject();然后一行行RootObj-SetStringField反而更直白也更好排错。但一旦结构体多了、嵌套深了这种手写方式就不可维护了。下面的方案权衡就是基于这两种取舍来展开的。2. USTRUCT 与 JSON 之间的“翻译”机制2.1 为什么不能直接调用 ToString做 UE 开发的人都知道每个USTRUCT在编译期会被 UHTUnreal Header Tool扫描生成反射元数据包括属性名、属性类型、偏移量、标记CPF_BlueprintVisible之类。这套反射系统是引擎所有序列化、网络复制、编辑器细节面板的基石。JSON 序列化也建立在它之上遍历属性的反射信息读取值再写入FJsonObject。但这里有个关键点USTRUCT 本身没有提供一个类似ToString的虚函数来约定“输出我自己的 JSON”。它只是一个数据聚合体你说它是“待翻译的原文”更合适。所以只能靠外部的转换器来做翻译。UE 内置的FJsonObjectConverter就是这样一个通用的外部翻译机它扫描反射系统但多了一层“中间对象”FJsonValue的封装。我可以打个比方USTRUCT像一份中英对照表的数据源里面有几百行条目FJsonObjectConverter是翻译官FJsonObject是翻译后整理出的“中文版目录”而最终的 JSON 字符串是把这份目录用排版规则打印成文本文档。你不能指望翻译官直接把数据源变成打印好的文件中间总要有一步“整理成对象”的动作。2.2 FJsonObjectConverter 是“翻译官”还是“排版工”深入看FJsonObjectConverter::UStructToJsonObject的源码会发现它的职责非常单一根据反射信息把 USTRUCT 的每个属性转换成对应的FJsonValue塞进FJsonObject。它不管缩进、不管换行、不管字符编码那属于TJsonWriter的活儿。FJsonObjectConverter底层的处理路径大概是获取 UStruct 的FStructProperty列表。遍历每个属性拿到属性名和FProperty。根据FProperty的类型分派到不同的转换函数比如FJsonObjectConverter::ConvertScalarFPropertyToJsonValue、ConvertArrayFPropertyToJsonValue、ConvertMapFPropertyToJsonValue等。调用FJsonObject::SetField写入。所以要得到最终字符串还得再把FJsonObject交给FJsonSerializer::Serialize让它识别 JSON 的标准语法输出成字符串。分段来看整体功能链条是这样FJsonObjectConverter::UStructToJsonObject - FJsonObject (对象树) FJsonSerializer::Serialize(JsonObject, Writer) - FString (JSON 字符串)理解这个链条的最大好处是你可以随时在中间插入“自定义字段”“过滤敏感字段”“统一加密”等步骤。后面讲实操时我会重点利用这个中间层做可复用的封装。2.3 字段名和 UPROPERTY 标记的微妙关系FJsonObjectConverter默认会使用 UPROPERTY 的名称作为 JSON key。这意味着你原本的命名规范会直接暴露在 JSON 里。比如你在 C 里写m_PlayerHealth或者PlayerHealth_JSON 里也会出现这种风格的名字与前端团队约定俗成的player_health或者playerHealth不一致。要想改变这个名称两个常用方案在 UPROPERTY 上附加SerializeAs说明符这会在编辑器里提供一个字段名别名。在转换前拷贝结构体并重命名属性不过这样做会破坏反射的完整性更推荐用FJsonObjectConverter::CustomExportCallback来干预导出过程。实际项目里我倾向于这样约定如果字段名需要对外不可变比如供外部存档兼容那必须用SerializeAs如果只是内部调试导出直接保持原有命名就好没必要在工具链上过度设计。3. USTRUCT 与 JSON 字段映射的完整拆解3.1 基础字段类型对照表这五个类型占掉了 90% 的工作量先列一张核心对照表这是我在实施这个功能时反复对源码确认过的结论USTRUCT/C 类型转换后的 JSON 类型备注int32/int64/uint8NumberUE 内部按JsonNumber处理如果你的值超出 JS 安全整数范围2^53 – 1注意 Web 端解析精度丢失问题float/doubleNumber特别注意NaN、Infinity在标准 JSON 里不合法序列化时可能会输出怪异文本需要提前处理或过滤FString/FNameString一般情况下没问题但如果你有特殊字符如引号、反斜杠TJsonWriter会自动转义boolBool这里有个大坑USTRUCT 里的bool可能被 UHT 合并成 bitfield而bool bFlag : 1的转换行为不同后面详述枚举UENUMNumber 或 String取决于你是否给枚举设置了JsonEnumName或者在 UPROPERTY 上做了什么配置我自己实测下来int64和enum是问题最大的两个。int64的原因不是 UE 转不出来而是 JSON 标准本身没有 64 位整数类型JS 里解析时容易丢失精度enum的原因则是很多人默认转成字符串名但实际上 UE 的默认行为往往是把底层uint8值输出形成了“我以为能读结果看到一个数字”的困惑。3.2 嵌套结构体和数组的“递归翻译”逻辑FJsonObjectConverter对嵌套结构体的处理方式其实是递归调用自身。这在阅读源码时很多人容易忽略它并不是“拍平”了映射到一个扁平的 JSON 对象里而是遇到一个内部结构体属性时继续为这个子结构体创建子FJsonObject。举个例子如果我有USTRUCT(BlueprintType) struct FInventoryItemData { GENERATED_BODY() UPROPERTY(EditAnywhere, BlueprintReadWrite) FString ItemName; UPROPERTY(EditAnywhere, BlueprintReadWrite) int32 StackCount; }; USTRUCT(BlueprintType) struct FPlayerSaveData { GENERATED_BODY() UPROPERTY(EditAnywhere, BlueprintReadWrite) int32 PlayerLevel; UPROPERTY(EditAnywhere, BlueprintReadWrite) TArrayFInventoryItemData InventoryItems; };那么转换出的 JSON 大致长这样{ PlayerLevel: 12, InventoryItems: [ { ItemName: 铁剑, StackCount: 1 }, { ItemName: 治疗药水, StackCount: 3 } ] }这个递归逻辑的好处显而易见只要你在 C 层把结构体定义清楚嵌套层次再多转换器都能自动展开。坏处也很直接如果结构体内部有循环引用比如结构体里塞了一个指向自身的指针递归就会把栈击穿。所以在设计数据结构时指针型属性尽量不要放在 USTRUCT 里用于序列化而是改为 ID 引用例如存ItemID而不是直接存UObject*。3.3 TMap 和 TSet 的 JSON 表现形态TMapFString, FString转换后通常是一个 JSON ObjectKey 作为属性名Value 作为属性值。这会带来一个比较麻烦的问题如果 Key 不是字符串类型比如TMapint32, FStringFJsonObject无法直接表达整数 Key最终会尝试把它转成字符串 Key。解析回来时你需要自己处理Atoi的转换和可能的 Key 排序变化。TSet的情况更特殊它的 JSON 输出往往是数组。这本身没问题但要注意数组是有顺序的而 TSet 的迭代顺序在每次运行之间可能不同。如果你拿这个 JSON 当缓存签名或哈希依据就必须先给 TSet 排序否则签名会因哈希顺序抖动而频繁失效。从工程经验角度我的建议是序列化消息体时尽量避免直接用 TMap 和 TSet 作为顶级结构它们适合被包在某个业务结构里并且字段数量可控时使用。如果实在需要建议在序列化时统一排序或者干脆在 USTRUCT 里放TArrayFPairStruct代替虽然看起来不优雅但兼容性和可预测性都更好。4. 实操过程与核心环节实现4.1 一个可直接落地的 UStructToJsonString 函数直接给结论这是我在项目里反复打磨过的一个通用函数兼顾了“单个结构体”和“结构体数组”两种需求// Header #pragma once #include CoreMinimal.h #include Serialization/JsonSerializer.h #include JsonObjectConverter.h #include Kismet/BlueprintFunctionLibrary.h #include StructToJsonFunctionLibrary.generated.h UCLASS() class UStructToJsonFunctionLibrary : public UBlueprintFunctionLibrary { GENERATED_BODY() public: // 将单个 USTRUCT 实例转成 JSON 字符串 // 注意 TemplateType 必须是一个 USTRUCT 类型 template typename StructType static FString ConvertStructToJsonString(const StructType InStruct, bool bPrettyPrint false, bool bIncludedDefaultValues false); }; template typename StructType FString UStructToJsonFunctionLibrary::ConvertStructToJsonString(const StructType InStruct, bool bPrettyPrint, bool bIncludedDefaultValues) { static_assert(TIsDerivedFromStructType, FStructBase::IsDerived, ConvertStructToJsonString 只支持 USTRUCT 类型); FString OutputString; // 第一步把 USTRUCT 翻译成 FJsonObject // 注意第三参数 bIncludedDefaultValues默认 false 表示跳过与默认值完全相同的字段 TSharedRefFJsonObject JsonObject FJsonObjectConverter::UStructToJsonObject( StructType::StaticStruct(), InStruct, 0, 0, bIncludedDefaultValues ); // 第二步把 FJsonObject 序列化成字符串 TSharedRefTJsonWriterTCHAR, TPrettyJsonPrintPolicyTCHAR JsonWriter ...; // 根据 bPrettyPrint 决定使用紧凑输出还是美化输出 if (bPrettyPrint) { TSharedRefTJsonWriterTCHAR, TPrettyJsonPrintPolicyTCHAR PrettyWriter TJsonWriterFactoryTCHAR, TPrettyJsonPrintPolicyTCHAR::Create(OutputString); if (!FJsonSerializer::Serialize(JsonObject, PrettyWriter)) { UE_LOG(LogTemp, Error, TEXT(结构体转 JSON 字符串序列化失败)); return TEXT({}); } } else { TSharedRefTJsonWriterTCHAR, TCondensedJsonPrintPolicyTCHAR CondensedWriter TJsonWriterFactoryTCHAR, TCondensedJsonPrintPolicyTCHAR::Create(OutputString); if (!FJsonSerializer::Serialize(JsonObject, CondensedWriter)) { UE_LOG(LogTemp, Error, TEXT(结构体转 JSON 字符串序列化失败)); return TEXT({}); } } return OutputString; }4.2 参数选择的细节0 和 0 是什么意思很多人第一次看到UStructToJsonObject的四参版本时会晕那两个0分别是CheckFlags和SkipFlags。它们决定了哪些属性标记会影响序列化CheckFlags第一个0指定必须包含的属性标记一般传0表示“不过滤”。SkipFlags第二个0指定必须跳过的属性标记传0表示“全部都转”。如果你想跳过所有EditInstanceOnly的属性可以这样写EPropertyFlags SkipFlags CPF_Edit | CPF_BlueprintVisible; TSharedRefFJsonObject JsonObject FJsonObjectConverter::UStructToJsonObject( StructType::StaticStruct(), InStruct, 0, SkipFlags, false );这个标志位机制非常有用但在普通业务代码里很容易被忽略。我建议在封装函数时增加一个TSetEPropertyFlags参数把需求变化的可能留出来而不是写死0, 0。4.3 Blueprint 侧如何调用由于模板函数没法直接暴露给蓝图常见的做法是提供一个普通参数版本比如传const FGenericStructWrapper或者直接把常用结构体打包成几个重载函数。不过 UE 的蓝图函数库支持自定义结构体作为参数琥珀色地“动态”转换的函数还是比较麻烦。最实用的是这样如果你只需要导出几个固定结构体就写几个显式包装函数UFUNCTION(BlueprintPure, Category Json|Struct) static FString ConvertInventoryDataToJson(const FInventoryItemData Data, bool bPrettyPrint false);每个函数内部调用模板函数。这样做的好处蓝图节点有明显的输入输出参数类型是强类型不容易接错同时在 C 层保持了泛型转换的灵活性。要是你想做一个“传任何结构体都行”的泛型蓝图节点需要引入UK2Node自定义节点工作量大不少不建议一开始就投入除非项目里这种需求非常多。4.4 实际项目中的输出示例简单与嵌套结构用上面的FInventoryItemData跑一遍紧凑输出大概是{ItemName:铁剑,StackCount:1}用FPlayerSaveData跑一遍紧凑输出大概是{PlayerLevel:12,InventoryItems:[{ItemName:铁剑,StackCount:1},{ItemName:治疗药水,StackCount:3}]}美化了之后{ PlayerLevel: 12, InventoryItems: [ { ItemName: 铁剑, StackCount: 1 }, { ItemName: 治疗药水, StackCount: 3 } ] }有一个细节值得注意紧凑输出更适合用来做日志、做缓存 key因为文本体积小、不会引入多余的空白符美化输出更适合给测试人员或策划人员阅读用来排查配置错误。5. 类型收敛与特殊场景处理5.1 bool 字段的“隐藏陷阱”如果 USTRUCT 里的bool属性没有单独加uint8或: 1位域声明UHT 在某些编译设置下会把它视作普通属性。一旦你攃杂了 bitfield比如UPROPERTY(EditAnywhere, BlueprintReadWrite) bool bIsAlive : 1;在某些引擎版本里反射系统会把它当成一个独立的布尔属性处理但底层存储是和旁边的其他 bitfield 布尔挤在一起的。此时如果项目里混用老代码转 JSON 时可能会出现“值不对”的情况但排查起来异常困难因为你在调试器里看结构体里的值是正常的。我的建议是能不用 bitfield 就不用 bitfield。特别是涉及存档/网络同步的数据结构一个 bool 占 4 字节和占 1 bit在绝大多数业务场景里根本不值得省那点内存。改成普通 bool 后序列化的确定性大幅提升也让 FJsonObjectConverter 少一层意外。5.2 枚举到底该转数字还是字符串默认行为下枚举会被转成底层数值。这在 UE 里没什么问题因为读取回来时可以static_castEYourEnum(JsonValue-AsNumber())。但 JSON 的接收方一旦换了语言、换了框架他看到的只是一个数字可读性极差尤其当枚举项有几十个的时候必须维护一份“数字↔含义”的对照表。想让枚举变成字符串可以在枚举声明上做文章UENUM(BlueprintType, JsonSerializeAsString) enum class EEquipmentSlot : uint8 { Head, Chest, Legs };加了JsonSerializeAsString后FJsonObjectConverter在序列化时会把枚举名作为字符串输出。这个标记在新版引擎里是支持的如果你用的引擎没有就需要自己重写导出回调把枚举值映射到约定字符串。开发时我一般优先用字符串除非和外部系统有明确的二进制压缩要求因为字符串排障太方便了。5.3 FVector、FRotator、FLinearColor 怎么处理这些引擎内置结构体很特殊它们既有USTRUCT的反射元数据但业务上又经常被当成“基础标量”处理。FJsonObjectConverter默认会把它们转成嵌套对象例如FVector会变成{ X: 1.0, Y: 2.0, Z: 3.0 }这本身没有错但很多外部接口更希望收到一个数组比如[1.0, 2.0, 3.0]或者一个带精度控制的定制格式。这种情况我建议你不要去改转换器全局行为而是让业务结构体自己承载序列化格式USTRUCT(BlueprintType) struct FPositionPayload { GENERATED_BODY() UPROPERTY() TArrayfloat Coords; };然后从FVector转成Coords。这样做的代价是要写几个转换函数但收益是 JSON 契约完全可控不再依赖引擎的默认表达。5.4 FDateTime 与 FGuidFDateTime在 JSON 里默认输出的是Ticks或毫秒时间戳外部系统看到一串长数字非常不友好。我更习惯把日期时间字段在 USTRUCT 里声明为FString业务层负责格式化这样前端不用再做时间戳解析。FGuid默认会转成字符串这个基本没有坑但要注意字符串里的大小写风格ToString(EGuidFormats::DigitsWithHyphens)和默认输出不完全一样。为了契约统一建议统一走一次规范化再入库。6. 常见问题与排查技巧实录6.1 转出来是空对象 “{}”最经典的原因结构体类型没有加USTRUCT标记或者属性没有加UPROPERTY。反射系统遍历的就是这些元数据只要标记缺失属性就会被跳过。检查时不要只看代码里有没有USTRUCT还要确认整个类在头文件里被 UHT 正确处理过。一个有效的确认方法在编辑器里随便拖一个该结构体变量到蓝图节点上如果能正常选择其成员反射就没问题如果不能就是 UHT 没有识别。还有一个容易忽略的情况USTRUCT 定义在.cpp文件里。UHT 默认只扫描.h中的声明放在.cpp里的结构体在某些版本里能编译但反射不完整序列化出来就是空对象。所以USTRUCT 必须放在头文件里这一条务必写进团队规范。6.2 int64 数值末尾的精度丢失如果你把int64转成 JSON Number然后用 Javascript 的JSON.parse解析数字超过Number.MAX_SAFE_INTEGER时精度会丢。这已经不是 UE 的问题而是 JSON 格式本身没有“bigint”概念。如果业务里确实有大数据 ID建议用FString存储或者序列化时专门写成带后缀的大数字字符串比如999999999999999999让接收方自行决定解析策略。6.3 特殊字符和编译器的“UTF-8 BOM”问题输出中文或特殊字符到 JSON 时TJsonWriter默认支持FString内码到 UTF-8 的转换但如果你把字符串直接打印到控制台或者 Windows 下的命令行会出现乱码。这通常是控制台代码页问题不是 JSON 序列化的问题。用文本文件保存 JSON 时优先选用带 BOM 的 UTF-8否则某些低版本的工具特别是记事本老版本会视为 ANSI。6.4 嵌套数组很大时为何性能掉得厉害FJsonObjectConverter需要遍历反射元数据并为每个字段创建智能指针对象字段多了之后装箱开销不小。如果你一个结构体里嵌套了成千上万个元素每帧都转换性能会非常难看。排查时可以用Stats或者简单的FPlatformTime::Cycles前后差值统计。结论通常不是“转换器太慢”而是“调用频率太高”或者“单次数据量太大”。优化手段也简单适合分批转换、延迟转换、或者缓存转换结果而不是换一个更快的 JSON 库。6.5 常见问题速查表现象可能原因处理办法转出来是{}USTRUCT 或 UPROPERTY 缺失/UHT 未识别确认结构体在头文件属性全部加 UPROPERTYEnum 输出数字而非字符串未加JsonSerializeAsString或版本不支持改用 CustomExport或在结构体上做字符串映射TArray 顺序错乱使用了 TSet 或对 TArray 做了并发写确认容器类型TSet 需排序导出日期变成一串数字FDateTime 默认走时间戳业务上用 FString 保存格式化日期嵌套太深导致栈溢出结构体循环引用改为 ID 引用禁止 USTRUCT 里存指针7. 从“单次转换”到“统一序列化层”的工程化思考写到这里这个工具函数单看已经能用了但它会在项目里到处被调用日志里用、存档里用、网络包里也用。一旦出现了“存档用 A 版本网络用 B 版本”的偏差排查起来就非常痛苦。所以我最后想分享的是工程层面的体会。我一般会在项目里再抽象一层IFJsonSerializable或者一个专门负责序列化的USubsystem它的职责不是“怎么转”而是“哪些类该用什么规则转”。比如某一类存档结构体在导出前要加版本号和 CRC某一类网络请求体在导出前要把 DateTime 格式统一成 ISO8601。这些规则如果散落在各个调用点后期维护就是灾难。集中到一个统一模块后规则可以测试、可以配置、也可以在切换后端协议时做一次性重写。从我在几个项目里的实测结果来看花一下午把这个小工具打磨成可配置的序列化层后续省下来的排障时间远超投入。它不像战斗系统、渲染管线那么引人注目却是存档兼容、前后端联调、数据排查这些环节的地基工程。所以如果你现在正在做 USTRUCT 转 JSON 的功能别止步于“能转出字符串”多想想你的数据结构有多少种变体、谁来提供契约、字段变更时怎么灰度那才是这个功能的真正价值所在。
返回列表