1. 为什么值得花时间啃反射系统如果你写过一段时间的UE4 C大概率遇到过这种场景在编辑器里给某个UCLASS加了一个UPROPERTY编译完打开编辑器属性面板里就自动出现了这个字段还能被蓝图读写、被序列化、被GC正确管理。整个过程你只写了一个宏剩下的全是引擎自动完成的。这套“自动完成”的背后就是UE4的反射系统在干活。反射这个词听起来很玄其实拆开看很朴素程序在运行时能够知道自己有哪些类型、每个类型有哪些成员、每个成员是什么类型、叫什么名字。C原生是不支持这个的编译完类型信息基本就没了RTTI那点能力远远不够。UE4为了支撑蓝图、序列化、GC、网络复制、编辑器细节面板这一大堆功能硬生生在C之上造了一套自己的反射体系。这套体系能做什么简单说让C类型变成“可被引擎查询和操作的数据”。蓝图能调用你的C函数是因为反射告诉蓝图“这个类有这个方法参数是这些”存档能保存你的对象是因为反射告诉序列化器“这个类有这些属性每个属性怎么读写”GC能回收垃圾是因为反射告诉它“这个对象里哪些字段是UObject引用需要追踪”。适合谁来读如果你只是用蓝图做逻辑那这套东西你感知不到也不用管。但只要你开始写C、开始碰UCLASS/UPROPERTY/UFUNCTION这些宏或者你想搞明白“为什么我加了个宏就能用”那反射系统就是绕不过去的一关。我见过太多人写了半年UE4 C还是把宏当黑魔法抄来抄去出了问题完全不知道怎么排查——根子就在没理解反射。这篇是系列的第一篇我打算先把反射系统的整体设计思路和几个核心概念讲透包括它为什么这么设计、UHT到底干了什么、那些宏展开后长什么样。后面几篇再深入到具体的运行时数据结构和查找流程。内容基于我自己的项目实践和踩坑记录涉及引擎内部实现的部分我会说明是基于常见版本的通用实现不同版本可能有细节差异。2. 反射系统的整体设计思路拆解2.1 原生C为什么不够用要理解UE4为什么自己造轮子先得看清楚原生C的短板在哪。C的类型信息在编译后基本只保留了RTTI那一层typeid能告诉你“这是个什么类型”dynamic_cast能做安全的向下转型但也就到此为止了。你没法在运行时问一个类型“你有哪些成员变量每个叫什么名字偏移量是多少”更没法问“你有哪些函数参数类型是什么”而UE4的需求恰恰全是这些。蓝图编辑器需要在运行时列出某个类的所有可暴露属性让美术拖拖拽拽就能改序列化系统需要遍历对象的所有属性逐个读写GC需要知道对象里哪些字段是UObject指针。这些需求原生C一个都满足不了。有人可能会想那用外部工具生成一份类型描述文件不就行了理论上可以但UE4要的不只是“描述”还要能在运行时高效地访问和操作这些成员。这就需要一个统一的运行时数据结构来承载类型信息并且这个结构要能被引擎的各个子系统蓝图VM、序列化、GC、网络共享使用。所以UE4的方案是编译期用工具生成类型信息的代码链接进最终程序运行时通过一套统一的注册机制把这些信息组织起来。2.2 UHT编译期的代码生成器UE4反射系统的第一环是Unreal Header Tool简称UHT。它不是一个独立的可执行文件让你手动跑而是集成在构建流程里的一个编译期工具。你每次编译项目UBTUnreal Build Tool会先扫描你的头文件把带UCLASS、USTRUCT、UPROPERTY、UFUNCTION这些宏的声明挑出来交给UHT处理。UHT干的事情本质上是解析代码生成。它读你的头文件识别出所有标记了反射宏的类型和成员然后为每个类型生成一个对应的.generated.h和.gen.cpp文件。这些生成的文件里包含了什么包含了一个描述该类型所有反射信息的静态数据结构以及一些辅助函数比如构造函数包装、属性注册函数等。这里有个关键点很多人没意识到UHT不是编译器它做的是文本层面的解析。它不依赖C编译器而是自己实现了一套头文件解析逻辑。这也是为什么UE4的宏写法有那么多讲究——比如GENERATED_BODY()必须放在类体开头宏参数不能随便换行这些都是UHT的解析规则决定的不是C语法要求。我踩过的一个坑就是有次把UPROPERTY的说明符写成了跨多行的形式C编译器没报错但UHT解析失败报了一堆莫名其妙的错排查了半天才发现是格式问题。2.3 宏展开后的真实面目很多人对UCLASS()、UPROPERTY()这些宏有误解以为它们是什么高级语言特性。其实剥开看它们就是普通的C宏展开后是一堆看起来有点吓人的代码。理解这一点很重要因为它意味着反射系统没有任何魔法全是可以用普通C解释的。拿GENERATED_BODY()举例它展开后会插入几个东西一个静态的StaticClass()函数声明、一个虚函数GetPrivateStaticClass()的重写、以及一些类型别名。UPROPERTY()展开后则是一段用于向反射系统注册该属性的代码通常放在生成的.gen.cpp里通过一个静态注册对象在程序启动时执行。我建议每个想深入理解反射的人都做一件事找一个简单的UCLASS把它的.generated.h和.gen.cpp打开看一遍。不用全看懂就看个大概结构。你会看到UHT生成的代码其实很规整无非就是“定义一个静态类信息对象往里面塞属性描述、函数描述、父类指针”。看一遍之后那些宏在你眼里就不再是黑盒了。2.4 运行时注册与查找机制编译期生成的信息要能在运行时用起来还需要一套注册机制。UE4的做法是每个反射类型都有一个对应的UClass或UScriptStruct、UEnum等对象这些对象在程序启动阶段被创建并注册到一个全局的类型注册表里。注册的时机很关键。UE4用的是静态初始化延迟注册的混合策略。生成的.gen.cpp里会定义一些静态对象它们的构造函数在main之前就会执行但这时候引擎的核心系统还没起来不能直接注册。所以这些静态对象只是把类型信息“挂”在那里等引擎初始化到某个阶段再统一遍历注册。查找的时候引擎维护了一个全局的TMapFName, UClass*之类的结构通过类名就能快速找到对应的UClass。蓝图里你写一个类名运行时就是通过这个映射找到实际的C类型。这个查找过程有缓存优化第一次查可能慢一点后面就是O(1)级别的哈希查找了。3. 核心概念与实操要点解析3.1 UCLASS、USTRUCT、UENUM的分工UE4的反射类型分几种最常用的是UCLASS、USTRUCT、UENUM它们各自对应不同的运行时表示。UCLASS对应UClass对象用于描述一个UObject派生类。它是反射系统里最复杂的一类因为要支持继承、接口、蓝图、GC、网络复制等一大堆特性。UClass里存了父类指针、属性列表、函数列表、接口列表、类标志位等等。USTRUCT对应UScriptStruct用于描述一个结构体。结构体没有继承UE4的USTRUCT不支持继承所以UScriptStruct比UClass简单不少主要就是属性列表和结构体大小、对齐信息。但结构体可以嵌套一个USTRUCT里可以包含另一个USTRUCT这个嵌套关系也要在反射信息里体现。UENUM对应UEnum描述一个枚举。枚举相对简单主要就是名字和值的映射。UE4的UENUM支持指定底层类型uint8、int32等也支持在蓝图里用。这三者的共同点是都必须用对应的宏标记都必须有GENERATED_BODY()都必须被UHT处理。区别在于它们注册到的运行时对象类型不同能用的说明符也不同。比如UPROPERTY只能用在UCLASS或USTRUCT的成员上不能用在普通类里BlueprintReadWrite这种说明符只对UCLASS的成员有意义。3.2 说明符反射系统的配置语言反射宏后面那一串括号里的东西官方叫说明符Specifier。它们决定了这个类型或成员在反射系统里的行为。比如UPROPERTY(EditAnywhere, BlueprintReadWrite, CategoryStats)三个说明符分别表示“编辑器里任何地方都能编辑”“蓝图可读写”“在Stats分类下显示”。说明符的种类非常多我按用途大致分几类编辑器相关EditAnywhere、EditDefaultsOnly、VisibleAnywhere、Category等控制属性在细节面板里怎么显示、能不能改。蓝图相关BlueprintReadWrite、BlueprintReadOnly、BlueprintCallable等控制蓝图能不能访问。序列化相关Transient、SaveGame、Config等控制属性要不要存档、要不要从配置文件读。GC相关BlueprintReadWrite隐含了GC追踪但有些情况下需要显式用meta标记。网络相关Replicated、ReplicatedUsing等控制属性怎么同步。这里有个实操心得说明符写错了UHT会报错但报错信息往往不直观。比如你把只适用于UCLASS的说明符写到USTRUCT上UHT可能报一个“unknown specifier”之类的错你得去查文档才知道哪个说明符用错了地方。我的习惯是写说明符之前先想清楚这个属性要干什么——是要给编辑器用还是给蓝图用还是给网络用——然后去查对应的说明符列表别凭记忆瞎写。3.3 属性偏移与内存布局反射系统要能读写属性就必须知道每个属性在对象内存里的位置。UE4用的是偏移量方案每个FProperty旧版本叫UProperty里存了一个Offset字段表示这个属性相对于对象起始地址的字节偏移。这个偏移量是UHT在生成代码时算出来的吗不是。UHT生成的是“属性注册代码”偏移量是在运行时通过offsetof宏或者类似机制计算出来的。具体来说生成的代码里会有一句类似PROPERTY_OFFSET(ClassName, PropertyName)的东西展开后就是offsetof(ClassName, PropertyName)。这里有个坑偏移量方案对内存布局很敏感。如果你在反射属性之间插入了非反射的成员变量或者改变了成员的顺序偏移量就会变必须重新编译。更麻烦的是如果你用了#pragma pack之类的对齐指令偏移量计算可能和预期不一致。我遇到过一次诡异的问题某个USTRUCT在编辑器里显示的值总是错位查了半天发现是结构体里有个非反射的bool成员导致后面反射属性的偏移量算错了。解决办法是把所有成员都加上反射宏或者把非反射成员挪到结构体末尾。3.4 反射与GC的协作机制GC是反射系统最重要的应用场景之一。UE4的GC是追踪式的它需要知道每个UObject里哪些字段是其他UObject的引用才能正确地标记和清除。反射系统怎么支持这个每个FProperty都有一个标志位表示“这个属性是不是UObject引用”。如果是GC在遍历对象时就会读这个属性的值把它指向的对象加入待检查队列。对于容器类型TArray、TMap等反射信息里还会包含容器元素的类型信息GC会递归地追踪容器里的每个元素。这里有个容易忽略的点不是所有UObject指针都会被GC追踪。只有标记了UPROPERTY的成员才会。如果你在C里裸写一个UObject*成员而不加UPROPERTYGC不会管它这个指针可能指向一个已经被回收的对象用的时候就是野指针。我见过不少崩溃都是这个原因——某个缓存指针忘了加UPROPERTY跑一段时间就崩。所以我的原则是只要是UObject指针成员一律加UPROPERTY哪怕你不需要它在编辑器里显示加个Transient或meta(AllowPrivateAccesstrue)都行关键是让GC知道它的存在。4. 实操过程与核心环节实现4.1 从零写一个可反射的类光说理论没意思我们实际走一遍流程。假设我要写一个简单的UMyActor带几个可反射的属性和函数。第一步创建头文件。关键点是包含正确的头文件、用正确的宏、把GENERATED_BODY()放在类体开头// MyActor.h #pragma once #include CoreMinimal.h #include GameFramework/Actor.h #include MyActor.generated.h UCLASS(Blueprintable, BlueprintType) class MYPROJECT_API AMyActor : public AActor { GENERATED_BODY() public: AMyActor(); UPROPERTY(EditAnywhere, BlueprintReadWrite, Category Stats) float Health 100.0f; UPROPERTY(EditAnywhere, BlueprintReadWrite, Category Stats) int32 Level 1; UFUNCTION(BlueprintCallable, Category Actions) void TakeDamage(float Amount); private: UPROPERTY(Transient) float CachedDamage 0.0f; };注意几个细节#include MyActor.generated.h必须是最后一个include这是UHT的硬性要求放错了会报错。GENERATED_BODY()必须放在类体最前面不能放在public:后面。MYPROJECT_API是模块导出宏如果你在多个模块间用这个类必须加。第二步实现文件// MyActor.cpp #include MyActor.h AMyActor::AMyActor() { PrimaryActorTick.bCanEverTick true; } void AMyActor::TakeDamage(float Amount) { Health - Amount; CachedDamage Amount; if (Health 0.0f) { Health 0.0f; } }第三步编译。UBT会自动调用UHT处理头文件生成MyActor.generated.h和MyActor.gen.cpp。编译成功后打开编辑器你就能在细节面板里看到Health和Level两个属性在蓝图里也能调用TakeDamage。4.2 查看UHT生成的代码编译完之后去Intermediate/Build目录下找生成的代码。路径大概是Intermediate/Build/[Platform]/[Config]/[Project]/Inc/[Module]/UHT/下面。找到MyActor.generated.h打开看看。你会看到类似这样的结构不同版本细节有差异// 生成的代码大致结构 #define FID_MyProject_Source_MyProject_MyActor_h_15_PROLOG \ int32 GetHealth() const { return ((int32)((char*)this 0x0000)) ... } // 类信息定义 DEFINE_DEFAULT_OBJECT_INITIALIZER_CONSTRUCTOR_CALL(AMyActor)实际内容比这复杂得多但核心就是定义了一个静态的UClass描述对象里面注册了Health、Level、CachedDamage三个属性每个属性有名字、类型、偏移量、标志位。还注册了TakeDamage函数包括它的参数信息。我强烈建议你花半小时把生成的代码通读一遍。不用全懂就看个大概。看完之后你会对“宏展开后是什么”有一个具体的印象以后遇到反射相关的问题至少知道该去哪里找线索。4.3 属性访问的运行时流程当蓝图里读取Health属性时运行时发生了什么这个流程值得拆开看。蓝图VM执行到读取Health的字节码时会拿到一个指向AMyActor实例的指针以及一个指向Health对应FProperty的指针。然后调用FProperty::GetValue_InContainer之类的函数这个函数内部就是(char*)Object Property-GetOffset()算出地址然后按类型读取。写入流程类似只是反过来。如果是复杂类型比如TArrayFProperty会调用对应的容器属性函数处理内存分配、元素拷贝等。这个流程的效率其实不低因为偏移量是预先算好的读取就是一次指针加法。但如果你在每帧的Tick里通过反射访问大量属性还是会有开销。所以性能敏感的代码我一般直接访问C成员不用反射接口。反射接口主要用在编辑器、序列化、蓝图这些“非每帧”的场景。4.4 反射信息的注册时机前面提到注册是“静态初始化延迟注册”具体流程是这样的程序启动时所有.gen.cpp里的静态对象构造函数执行它们把类型信息注册到一个全局的待处理列表里。这个阶段引擎核心还没起来所以不能直接创建UClass对象。等引擎初始化到UObject系统启动阶段会遍历这个待处理列表为每个类型创建对应的UClass/UScriptStruct/UEnum对象并建立继承关系、填充属性列表。这个过程叫注册反射类型。注册完成后还会有一个链接阶段把属性、函数、接口等信息和实际的C类型关联起来。比如把UClass的ClassConstructor指向实际的构造函数把FProperty的偏移量和实际成员对应上。这个时机很重要因为如果你在静态初始化阶段就试图访问反射信息比如在某个全局对象的构造函数里查UClass很可能拿到的是空的。我踩过一次坑在一个全局单例的构造函数里调用了FindObjectUClass结果返回nullptr查了半天才发现是注册时机的问题。解决办法是把这类访问延迟到EngineInit之后或者用FCoreDelegates::OnPostEngineInit之类的回调。5. 常见问题与排查技巧实录5.1 编译期常见报错与解决反射相关的编译错误大部分是UHT报的不是C编译器报的。UHT的报错信息有时候比较隐晦我整理了几个高频问题报错信息常见原因解决办法Cannot find generated.hinclude顺序错了确保.generated.h是最后一个includeUnrecognized type XXX属性类型没被反射系统识别检查类型是否是UCLASS/USTRUCT/UENUM或者加TSubclassOf等包装Missing GENERATED_BODY忘了加宏或位置不对放在类体第一行public之前Specifier XXX is not valid说明符用错地方查文档确认该说明符适用的类型Unable to find class前向声明问题反射类型不能用前向声明必须完整include这里重点说下Unrecognized type。UE4的反射系统只认识它自己定义的类型体系基本类型int32、float、bool、FString、FName等、UCLASS指针、USTRUCT、UENUM、以及它们的容器TArray、TMap、TSet。如果你用了一个自定义的普通C类作为UPROPERTY类型UHT会报错。解决办法是把它改成USTRUCT或者用TSubclassOf之类的包装。5.2 运行时属性错位的排查属性错位是个很隐蔽的问题表现是编辑器里显示的值和实际值对不上或者序列化出来的数据是乱的。根因通常是偏移量计算和实际内存布局不一致。排查思路是这样的首先确认所有反射属性都是连续的中间没有插入非反射成员。如果有非反射成员把它们挪到所有反射属性之后。其次检查有没有#pragma pack之类的对齐指令如果有确认UHT生成的偏移量计算是否考虑了这些指令。最后如果用了多重继承注意UE4的反射系统对多重继承支持有限UObject派生类最好用单继承。我遇到过一次更诡异的情况属性错位只在打包版本出现编辑器里正常。查了半天发现是打包时的优化选项导致结构体布局变了而UHT生成的偏移量是基于未优化布局算的。解决办法是在Build.cs里给相关模块加上bUseUnity false或者调整优化等级。这个坑比较深一般项目不会遇到但如果你做的是大型项目值得留意。5.3 反射与蓝图交互的坑蓝图和C的交互是反射系统用得最多的地方也是坑最多的地方。几个典型问题问题一蓝图里改了属性值C里读不到。这通常是因为属性没有标记BlueprintReadWrite或者标记了但蓝图里用的是Get节点而不是直接访问。检查说明符确认蓝图有读写权限。问题二C里改了属性值蓝图里不更新。如果属性是BlueprintReadOnly蓝图里只能读不能写C改了之后蓝图下次读会拿到新值。但如果蓝图缓存了这个值可能不会自动刷新。解决办法是用BlueprintImplementableEvent或BlueprintNativeEvent通知蓝图。问题三UFUNCTION参数类型不匹配。蓝图对参数类型有要求不是所有C类型都能直接暴露给蓝图。比如TArrayTUniquePtr这种就不能用。如果UHT报参数类型错误检查一下是不是用了蓝图不支持的类型。5.4 性能相关的注意事项反射系统虽然方便但用不好会有性能问题。几个经验第一避免在Tick里通过反射访问属性。反射访问比直接访问慢一个数量级每帧调用会累积成可观的的开销。如果确实需要把FProperty指针缓存起来避免每次查找。第二注意FindPropertyByName的开销。这个函数内部是遍历属性列表做字符串比较O(n)复杂度。如果需要频繁按名字查属性自己建一个TMapFName, FProperty*缓存。第三序列化大量对象时注意反射开销。序列化每个属性都要走反射接口如果对象数量很大考虑用自定义序列化重写Serialize函数绕过反射。第四GC追踪的开销和反射属性数量成正比。一个类如果有几百个UPROPERTYGC遍历时就要检查几百次。所以不要滥用UPROPERTY只给真正需要的成员加。5.5 版本差异与迁移注意UE4的反射系统在不同版本间有变化最明显的是4.25前后UProperty改名为FProperty整个属性体系的类名都变了。如果你在网上找的教程是旧版本的代码里的UProperty在新版本编译不过需要改成FProperty。另一个变化是GENERATED_BODY()和GENERATED_UCLASS_BODY()的区别。旧版本用GENERATED_UCLASS_BODY()新版本统一用GENERATED_BODY()。如果你维护的是老项目迁移时要注意这个。还有TSubclassOf、TSoftObjectPtr这些包装类型不同版本的支持程度不一样。做跨版本项目时建议先查一下目标版本的反射相关文档确认哪些特性可用。6. 我个人的一些实操体会反射系统这东西初看很吓人宏展开后一堆看不懂的代码。但只要你愿意花时间把生成的代码读一遍把注册流程跟一遍就会发现它其实很“笨”——全是老老实实的C代码没有任何黑魔法。UHT就是个文本解析器生成的代码就是普通的静态对象和函数调用。我自己的学习路径是先写一个最简单的UCLASS编译后把.generated.h和.gen.cpp打印出来逐行看然后在StaticClass()函数里打断点跟一遍注册流程最后自己写一个简单的反射查询工具遍历某个类的所有属性并打印名字和类型。走完这三步反射系统对你来说就不再是黑盒了。后面几篇我会深入到FProperty的具体实现、属性查找的优化、以及反射和蓝图VM的交互细节。如果你在反射这块遇到过什么奇葩问题或者有什么想深入了解的点欢迎交流。这个系列我打算写成一个完整的反射系统剖析从编译期到运行时从数据结构到实际应用尽量把每个环节都讲透。