UE4的C++工程编译完之后,每个带反射标记的头文件旁边都会多出几个以.generated.h结尾的文件。很多新手不敢打开这些文件,觉得那是引擎的“禁地”。但我必须说,如果你理解了这些生成代码,UE4整个类型系统在你面前就没有秘密了。这篇文章就沿着“为什么需要这套类型系统 → UHT如何生成代码 → 运行时如何注册和查询类型 → CDO、序列化和GC怎么跟类型系统配合 → 实际项目里会踩到哪些坑”这条主线,把UE4类型系统完整扒一遍。适合已经在用UE4写功能、但对底层反射机制一知半解、或者准备做编辑器工具和代码生成相关工作的开发者。
1. 为什么UE4要自建一套类型系统
1.1 C++原生的RTTI到底差在哪
C++标准库里其实有一套运行时类型识别机制,就是RTTI,核心是typeid和dynamic_cast。它能告诉你一个对象具体是哪个类,也能安全地把基类指针转成派生类指针。但如果你做过复杂的引擎开发,很快就会撞到它的天花板:typeid只能给你个类名字符串,这个字符串连格式化都不保证;你没法枚举一个类有哪些成员变量,没法从字符串反查出一个字段的偏移,更没法在不知道具体类型的情况下动态构造一个对象。
而游戏引擎的数据驱动开发模式,要求的是另一套东西。存档系统要把一个对象的所有成员序列化成二进制;编辑器属性面板要把蓝图上指定的变量自动显示成可编辑的控件;服务器要按名字加载资源并实例化对应Actor;一个Actor放在关卡里,收集器要知道它引用了哪些其他对象,才能正确判活。这些需求全都绕不开一个共同的基础:对象能描述自己的结构。C++原生RTTI做不到,所以UE4在编译期和运行期各做了一层“补丁”,这就是整套类型系统的由来。
1.2 类型系统的三大支柱
UE4类型系统虽然看起来复杂,但核心对象其实就三类。
第一类是UObject。它是所有带反射能力对象的祖先类,提供了最基础的能力:类型查询、实例化、名字管理、序列化接口、垃圾回收支持、网络复制标记。你项目里几乎所有需要被蓝图引用、需要存盘、需要被GC管理的类,最终都继承自它。AActor、UActorComponent、UWidget、USceneComponent,往上追都是UObject。
第二类是UStruct。它是“带结构的类型”的基类,代表一个拥有成员字段的集合。UClass继承自UStruct,所以一个类本身就是一种结构;除此之外UScriptStruct(对应C++里的普通struct)和UFunction(对应成员函数)也都属于这个体系。UStruct最关键的成员是ChildProperties链表,这个链表把所有成员属性串起来,运行时遍历全靠它。
第三类是FProperty。它描述的是一个成员变量本身:类型是什么、在对象里的内存偏移是多少、有什么标记(可编辑、可蓝图读写、可复制)。UE4在4.25版本之后把属性从UProperty改成了FField体系,这是一个重要转折,后面细讲。简单理解:UStruct描述“有什么”,FProperty描述“具体是什么、放在哪里”。
1.3 宏声明背后到底发生了什么
现实中你写代码是这样的:
UCLASS(Blueprintable, BlueprintType) class MYGAME_API UMyItem : public UObject { GENERATED_BODY() public: UPROPERTY(EditAnywhere, BlueprintReadWrite) int32 StackCount; UFUNCTION(BlueprintCallable) void UseItem(AActor* User); };新人往往把UCLASS、UPROPERTY、UFUNCTION当成“注解”,这是最大的误解。它们不是注释,而是让Unreal Header Tool(UHT)识别的关键标记。UHT在编译前扫描头文件,把带这些标记的类型、属性、函数找出来,自动生成一大堆代码。宏本身展开后其实是若干空实现或者声明,真正干活的是UHT生成的那部分。换句话说,你写的“注解”是输入,.generated.h里生成的一大堆函数、静态结构体、构造代码才是真正的产物。
理解了这一点,后续所有内容都顺了:类型系统的信息不是运行时靠遍历内存猜出来的,而是在编译期就生成好、在启动时注册进去的。这也决定了UE4反射的一大特点:只有加了宏标记的成员才会进入类型系统,普通C++成员对反射不可见。
2. UHT的工作机制与代码生成
2.1 UHT到底扫描了什么
UHT是UE4工具链里一个独立的可执行程序,基于Clang的库做C++语法解析。它跟编译器是两套东西:编译器做代码编译,UHT只看头文件里“哪些成员带了UCLASS/UPROPERTY/UFUNCTION宏”,以及这些成员的类型和修饰符。
这里有个非常重要的工程约束:所有带反射标记的类,头文件里必须包含最后一行#include "MyClass.generated.h"。这个不是规范强迫症,而是因为生成的代码引用了大量内部结构和函数,不放在头文件末尾,编译必然报错。UHT处理头文件时,会先把宏展开成自己能识别的形式,然后解析出完整的元信息模型,例如类名、父类、属性列表、属性类型、标记位、函数签名、参数默认值等。
2.2 生成的.generated.h里到底有什么
以UMyItem为例,UHT会在MyClass.generated.h里生成大致这样几类东西。
首先是Super和ThisClass别名:
typedef UMyItem Super; typedef UMyItem ThisClass;这是UE4代码风格里所有Super::调用的根源。然后是各种STATIC访问函数,其中最核心的是这个:
static UClass* GetPrivateStaticClass(); static UClass* StaticClass();另外还会为每个反射属性生成对应的声明、为每个反射函数生成包装方法,以及一堆内部专用的模板特化。
生成的.cpp(以MyClass.gen.cpp结尾)里内容更多。它会生成一个Z_Construct_UClass_UMyItem函数,这个函数负责创建UClass对象本身;生成一个Z_Construct_UObject_UMyItem_NoRegister用来创建类的默认对象(CDO);生成一个FCompiledInDefer类型的静态注册对象,把“这个类叫什么、怎么构造、依赖哪个模块”的信息塞进去。这些函数都带Z_Construct_前缀,有经验的开发者看到这个前缀就应该知道:这是引擎在编译期为你预制好的“构造工厂”。
2.3 为什么选择“代码生成”而不是“运行时扫描”
这是很多引擎架构师会问的问题。C#可以用反射扫程序集,Lua可以直接查table,为什么C++非要搞一个预处理工具?原因有三点。
第一,C++标准没有提供任何标准化的“枚举类型成员”机制,想在运行时拿到类布局信息,必须在编译期做手脚。第二,运行时扫描通常依赖解析调试符号或者运行时记录,这两者都慢,而且要额外存储大量元数据,对游戏这种追求启动速度和内存可控的场景不友好。第三,代码生成是编译期行为,如果类型信息不对,编译阶段就报错了,日常开发的人根本不会把错误留到运行期才暴露。
我见过团队里有人试图绕过UHT,自己用宏手写一套反射,最后维护成本高到失控。UHT看似多了一个工具链环节,但它换来了两个非常实在的好处:编译期检查、生成代码可直接调试。你在VS里按F11,能看到生成代码的执行过程,这一点是很多“黑魔法”反射方案做不到的。
3. 运行时类型注册与动态查询
3.1 启动时类型是怎么注册进去的
UHT生成代码之后,还差最后一步:这些“构造工厂”和“类型描述”必须在引擎启动时自动执行注册。这一步靠的就是FCompiledInDefer这个静态对象。
简单说一下机制:UHT生成的.gen.cpp里会写类似这样的代码:
static FCompiledInDefer Z_CompiledInDefer_UObject_UMyItem( UObject_UMyItem_StaticClass, UObject_UMyItem_StaticRegisterNativeClass, TEXT("UMyItem"), false, UObject_UMyItem_AddReferencedObjects );FCompiledInDefer的构造函数会把一个延迟注册条目的函数指针挂到全局数组里。引擎启动时,FEngineLoop会在初始化阶段调用StaticRegisterNativeClass相关的注册流程,逐个执行这些函数,创建对应的UClass对象,把类的名字注册到全局类型映射表FindObject体系中的静态类映射里。自此,UMyItem::StaticClass()返回的非空UClass*就是一个完整的运行时类型描述对象了。
这也能解释一个经典问题:为什么热重载和个别静态库链接不进最终包时,很多类型会“丢失”。因为注册链条断了,全局表里没有这个类。
3.2 从类型名到实例:动态创建对象的路径
类型系统最有价值的应用之一,就是仅仅给一个字符串,就能创建出对应对象。这在游戏里太常用了:技能配置表里写的是"BP_Fireball",运行时就要能new出这个蓝图类;存档里记录了一个Actor的类名,读档的时候就要能恢复它。
动态创建的核心调用是NewObject和StaticLoadObject。它们的底层逻辑是沿UClass对象里保存的类信息,调用StaticConstructObject_Internal来分配内存并执行构造。这里有个隐蔽但关键的细节:NewObject并不直接调用普通的构造函数,而是走UObject的构建管线,先分配内存,然后通过构造函数指针执行具体的类构造函数,再执行PostInitProperties。所以你在构造函数里越早做的事情,越要小心那些尚未初始化的引擎子系统。
如果你需要在只知道类名的情况下创建对象,标准姿势是:
UClass* TargetClass = StaticLoadClass( UMyActor::StaticClass(), nullptr, TEXT("/Game/Blueprints/BP_MyActor.BP_MyActor_C") ); UMyActor* Actor = NewObject<UMyActor>(GetTransientPackage(), TargetClass);注意蓝图生成类的名字末尾通常带_C后缀。这个后缀就是类型系统里蓝图类与C++类共存的标志,忘了它你就总会load到“编辑器辅助类”而不是真正的运行时类。
3.3 属性遍历与编辑器属性的关系
类型系统另一大用武之地是编辑器的细节面板。你在编辑器里选中一个Actor,右侧面板能列出所有EditAnywhere变量,并能自动生成对应的输入控件,这个过程靠的就是IPropertyHandle和反射属性遍历。
遍历一个类的所有属性,最常用的是TFieldIterator:
for (TFieldIterator<FProperty> It(MyClass); It; ++It) { FProperty* Prop = *It; FString PropName = Prop->GetName(); EPropertyFlags Flags = Prop->GetPropertyFlags(); // 根据 Prop->GetClass() 判断是int、float还是Struct }注意,TFieldIterator默认是递归遍历整个继承链的,包括父类的所有属性。如果你只想看当前类自己声明的成员,需要传EFieldIteratorFlags::ExcludeSuper。我自己写编辑器插件的时候,经常在这上面栽跟头——本来想只显示子类增量,结果把父类几十个属性全列出来了。
反射属性不仅能读,还能写。拿到属性对象之后,通过ContainerPtrToValuePtr可以定位到具体对象里这个属性的内存地址:
void* ValuePtr = Prop->ContainerPtrToValuePtr<void>(TargetObject);然后就能用ImportText或者CopyCompleteValue写值进去。这套“读值/写值”的API,是很多批量资产工具、数据表格导入插件、测试自动化工具的地基。
3.4 UFunction与蓝图调用
上面讲的是属性,函数反射同样重要。UFUNCTION宏标记的函数在生成代码里有一套完全对应的处理:UHT会生成一个execFunctionName开头的C++函数,同时把函数名、参数结构、返回值类型注册到UFunction对象里。
蓝图调用C++函数,本质上不是直接调用函数指针,而是通过ProcessEvent查找到UFunction,再根据函数的“thunk”指针跳到真正的实现。这也是为什么BlueprintNativeEvent、BlueprintImplementableEvent这类函数会有多段实现:事件版本允许蓝图自己实现逻辑,C++版本则提供一个默认实现,引擎通过UFunction里的函数指针动态决定到底执行哪一端。
理解了UFunction的机制,再看网络复制就简单了:UFUNCTION(Server/Client/NetMulticast, Reliable)之所以能跨机器执行,正是因为函数具备了“可被序列化调用描述”的能力——参数被打包、路由、在远端重建函数调用,这一切都建立在这个函数本身能被反射体系识别的基础上。
4. CDO、序列化与垃圾回收
4.1 CDO是个被低估的设计
Class Default Object,简称CDO,是UE4类型系统里极度重要却又容易被忽视的部分。每个UClass都有且仅有一个CDO,它是这个类最“原始”的一个实例——所有属性都处于默认值状态。你在蓝图里看到的“类默认值”面板,编辑的其实就是这个CDO的属性。
CDO的价值不只是一个“样板对象”。它的核心地位体现在原型链模式上。UE4对象创建实例时,默认走的是“继承自CDO”的数据传递,而不是像很多引擎那样从头执行构造函数并初始化所有字段。蓝图子类可以覆盖CDO里某个变量,而实例创建时,引擎会把所有属性复制一份。这套机制保证蓝图修改默认值后,所有已存在的实例在属性上是独立的,但又不需要每个实例都重新执行全套构造逻辑。
实际项目里,你会反复用到这个关键函数:
AMyActor* DefaultActor = MyActorClass->GetDefaultObject<AMyActor>();比如你在编辑器工具里想知道一个类某个属性默认是多少,直接查CDO就够了,不需要创建一个实例再销毁。做网络同步时,判断某属性是否等于默认值也会经常查CDO,从而决定是否值得复制。
4.2 序列化为什么能“无差别”处理所有对象
UE4存档系统、网络复制、关卡保存、Pak打包,底层都依赖序列化。序列化的核心接口是FArchive,一个抽象的数据读写流。对象要存盘时,框架遍历这个对象的反射属性,逐个把属性值写进Archive;读档时,再根据存档里的类型信息创建对象、遍历属性、一个值一个值地填回去。
这里有个细节很多人没注意:UE4的属性序列化是按属性名写的。存盘的时候,一个属性名称字符串或者名称索引ID会先被记下来,然后才是它的值。读取时遇到未知属性名,就跳过它。这个设计让它能兼容“版本升级”后属性增删的情况。这也是为什么你把一个类的属性改名之后,旧存档读出来会丢数据——名字匹配不上了。
如果你手动写序列化代码,推荐直接复用反射系统:
void SerializeMyData(FArchive& Ar) { // FStructProperty的序列化器会递归处理内部字段 for (TFieldIterator<FProperty> It(GetClass()); It; ++It) { FProperty* Prop = *It; Prop->SerializeItem(Ar, Prop->ContainerPtrToValuePtr<void>(this)); } }但注意,这句话不适合在所有场景无脑套用。属性上有“SaveGame”标记才应该进存档系统,EditAnywhere标记只进编辑器存盘。写存档时一定要自己过滤标记,否则会把Transient的临时缓存也写进存档。
4.3 垃圾回收是怎么利用反射信息的
UE4的GC大家都不陌生,但很多人不知道它内部对类型系统的依赖有多深。GC的标记清扫算法从根集出发,沿对象引用图遍历,能到达的对象判活,不能到达的回收。问题在于:怎么知道一个UObject引用了哪些其他UObject?答案还是反射属性。
保存UObject引用的属性,其FProperty类型是FObjectPropertyBase或其子类(如FSoftObjectProperty、FLazyObjectProperty)。GC遍历时,会沿着UStruct的ChildProperties链逐个检查属性,凡是能标记为“引用类型”的属性,就取出它指向的对象加入遍历队列。换句话说,如果你没有把引用的对象用UPROPERTY()标记,GC不会知道这个引用存在,那这个被引用对象在GC眼里就是“没人用”,随时可能被回收。这是新手最容易踩的雷区,也是很多“对象莫名其妙被销毁”事故的根因。
这从侧面解释了为什么UE4不鼓励在UObject里裸存裸指针map,而推荐TMap<TSoftObjectPtr<UObject>, ...>或者TStrongObjectPtr。这些容器要么自带GC追踪,要么本身就是反射属性体系的一部分。
4.4 FField与FFieldClass的变迁
前面提到4.25把属性体系从UProperty改成了FField,这个改动值得单独说。旧体系里,属性本身也是UObject,意味着每一个属性都占用一个UObject的句柄和GC资源。一个中大型项目里,光属性和函数的UObject数量就能轻松突破十万,这让GC启动扫一遍的压力不小。
新体系把FProperty、FFieldClass等抽出来,变成轻量级对象,不再参与GC,不再走UObject的分配。这让反射元数据的内存占用大幅下降,同时访问速度更快。但对开发者来说,影响主要在两个地方:API名字变了(UProperty*改成FProperty*),以及写原生序列化时要注意FFieldClass与UClass在遍历方式上的细微差异。很多老项目的代码会遇到UProperty已经找不到头文件的情况,这就是因为在迁移到这个新体系的过程中改了名字。
5. 项目实战中容易踩的坑与排查思路
5.1 编译期高频报错与解决办法
进入多模块、多人协作阶段后,类型系统相关的编译错误会占掉不少调错时间。下面这几类是我见得最多的。
第一,头文件包含顺序错误。.generated.h必须放在头文件最后,一旦有宏标记的头文件在生成头之前引用了别的反射结构,就会出现一堆“unknown type”和宏展开错误。报错信息往往很乱,但根源就是包含顺序。一个典型的例子是你在头文件里定义了一个UPROPERTY类型为另一个还没有被包含完整定义的UObject子类,UHT解析就会失败。
第二,类名冲突。反射系统的全局类型名不允许重复。如果两个不同模块里定义了同名UObject类,启动时注册阶段不会立刻报错,但在“模块包子加载”阶段会出现诡异行为。排查方法是在崩溃模块里用StaticFindObject打日志查类型名,通常很快就能定位。
第三,GeneratedBody相关宏缺失或重复。手动写了GENERATED_BODY()但漏了#include "xx.generated.h",或者两个宏标记类放在同一个头文件里但只有一个包含生成头,都会导致无法理解的错误。处理原则是:每个带反射标记的类,单独一个头文件,且只包含一次自己的生成头。
5.2 运行期反射操作的性能陷阱
反射很方便,但也不是免费的。FindObject、FindPropertyName这类按字符串查找的操作,在热路径里用成本很高。例如,在Tick里每帧执行一次MyClass->FindPropertyByName("Health"),那就是每帧都在做哈希查找,还会带来内存cache miss。正确的姿势是在BeginPlay或者初始化时把这个FProperty*缓存下来,后续直接用指针访问。
还有一个常见的性能瓶颈是CDO构造。当你在编辑器里修改了蓝图默认值,重新保存时引擎可能需要重建CDO并触发其PostInitProperties。如果你的构造函数和PostInitProperties里做了重型操作,比如加载大量资源、创建额外对象,就会在保存资产时卡顿。排查这类卡顿,可以打开“类别注册耗时统计”控制台命令。
5.3 调试类型相关问题的实用技巧
如果怀疑一个对象是不是被GC误回收、或者类型注册没成功,我通常按这个顺序排查。
先看对象指针是否还在,在调试器里直接执行Ptr->GetClass()->GetName(),能正确返回类名,说明对象访问是有效的。再用控制台命令obj list查看当前存活的所有UObject,obj list class=UMyItem可以精确过滤出指定类实例。如果列表里本来应该有的对象没有出现,那就不是类型系统问题,而是对象根本没创建成功。
如果问题出在“属性值没生效”,比如蓝图里改了默认值但运行时不认,优先检查CDO和实例之间的拷贝链路。可以用obj dump classname看指定类实例的完整属性值,对照CDO的默认值,判断是实例化阶段复制失败,还是加载蓝图时覆盖了CDO。这套方法我用了很多年,基本能覆盖大部分类型系统运行期疑难杂症。
5.4 自己扩展类型系统时要注意什么
最后聊一点进阶方向。很多编辑器工具、数据驱动框架会尝试在新模块里注册自定义的“元信息”,也就是不依赖UHT而是自己往反射表里塞东西。我的建议是:不要这么做。UE4的反射体系是在编译期和启动时两条路径高度协同的,自行塞入的类型往往在打包、热重载、GC判活上出现各种边缘case,排查成本极高。
如果确实需要代码生成或者类型扩展,优先在UHT框架内做:比如使用编辑器脚本、插件扩展、自定义Kismet节点,或者干脆用数据驱动的方式(DataTable/DataAsset)来承载运行时变化。这些都是官方支持的路径,稳定性远高于自己黑进反射表。我做数据驱动配置框架时,底层全部用UDataAsset加反射属性完成,从编辑器到运行时只走一套存取逻辑,几乎没有为类型系统本身付出额外维护成本。
在我个人经验里,UE4类型系统最反直觉的地方就是“宏不是注释”。一旦跨过这个认知门槛,后面所有内容都是顺理成章的:UHT解析宏生成代码,启动时注册类型,运行时通过UClass和FProperty访问结构信息,序列化、GC、蓝图、复制全都建立在同一张类型信息网络上。你真的值得在某天下午,点开一个自己的MyClass.generated.h,从第一行看到最后一行,那种“原来如此”的体验,比读任何教程都有用。