UE5.8 反射系统与宏:从一次凌晨三点的崩溃说起

📑 目录

一个让老周失眠的 Bug

去年秋天,我们项目上线前两周,凌晨三点,测试群里炸出一条消息:某个关卡加载到百分之七十时,客户端闪退,复现率百分之百。老周是客户端主程,他远程连上机器,打开 dump,栈顶停在 UObjectBaseUtility::GetPathName 里面,再往上看,一个自定义的 UItemData 子对象在反序列化时变成了 nullptr

那个子对象我们在编辑器里明明填了数据,保存也成功了,加载时却像凭空蒸发。老周盯着代码看了二十分钟,忽然骂了一句:「我靠,这字段没加 UPROPERTY()。」

问题就这么简单。那个字段是个裸指针,UClass 的反射表里没有它,保存关卡时它不会被 Serialize 写进 uasset,GC 也不会把它当成有效引用。加载时父对象重建了,子对象早被扫掉。一行宏的缺失,换回了三个小时睡眠和三张请假条。

这事让我重新意识到:在 UE 里写 C++,反射不是装饰,是基础设施。你写的每一条 UPROPERTYUFUNCTIONUCLASS,背后都连着序列化、GC、编辑器、蓝图四条命。

反射到底在解决什么问题

C++ 原生没有完整的运行时类型信息。typeiddynamic_cast 能做一点,但远远不够:你不知道一个类有哪些字段,字段是什么类型,能不能在编辑器里改,能不能暴露给蓝图,也不知道对象之间谁引用了谁。UE 需要这些信息来做四件事:

  • 编辑器里的 Details 面板要能列出并修改任意对象的属性;
  • 蓝图要能调用 C++ 函数、读取 C++ 变量;
  • 保存关卡时要知道哪些对象和引用需要写进磁盘;
  • 垃圾回收时要能遍历对象图,判断谁还reachable。

这四件事靠手写代码维护根本不现实。UE 的解法是把元数据生成自动化:你在头文件里写宏,UHT(Unreal Header Tool)在编译前读这些宏,生成额外的 C++ 代码,最终把类型信息注册进 UClassUScriptStructUEnumUFunctionUProperty 这些元对象里。

用老周的话说:「反射就是你写一次声明,引擎帮你把户口本印出来。」

下面这张图是 UE5.8 反射数据在运行时的整体关系:

graph LR
    A[UClass] --> B[UProperty]
    A --> C[UFunction]
    A --> D[UStruct]
    E[UScriptStruct] --> D
    F[UEnum] --> D
    G[ UObject实例 ] --> A
    B --> H[序列化 / GC / 编辑器]
    C --> I[蓝图调用 / RPC]
    E --> J[结构体复制 / 网络同步]

UObject 实例通过 GetClass() 拿到自己的 UClassUClass 再挂出属性、函数、父结构。所有上层系统都从这棵树上查元数据。

五条宏:各司其职

UE5.8 里日常打交道的是五条宏。它们名字像,但负责的层面完全不同。

UCLASS:给类发身份证

UCLASS() 只能用在继承自 UObject 的类上。它告诉 UHT:这个类需要生成反射数据,需要注册到 UObjectGlobals,需要被 GC 认识,也可以被蓝图继承。一个最小例子长这样:

#include "CoreMinimal.h"
#include "UObject/NoExportTypes.h"
#include "ItemData.generated.h"

UCLASS(Blueprintable, BlueprintType)
class MYGAME_API UItemData : public UObject
{
    GENERATED_BODY()

public:
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Item")
    FString ItemName;
};

GENERATED_BODY() 必须放在类体的最前面,它展开成 UHT 生成的友元声明、StaticClass() 函数、属性注册代码等。没有它,.generated.h 里的实现找不到落脚处。

Blueprintable 表示这个类可以在蓝图里被继承,BlueprintType 表示可以作为蓝图变量类型。很多人只写 Blueprintable,结果在蓝图里压根选不到这个类型当变量,排查半天才发现漏了 BlueprintType

UPROPERTY:字段的户口页

UPROPERTY() 是使用率最高的宏。它不只是一个标记,而是一组开关。它决定:这个字段能不能在编辑器里改(EditAnywhere/VisibleAnywhere)、能不能被蓝图读写(BlueprintReadWrite/BlueprintReadOnly)、参不参与序列化(SaveGame)、是不是网络复制(Replicated)、会不会被 GC 扫描(默认会)。

那次崩溃就是少了这个宏。裸指针在 UE 里是二等公民,UHT 看不见它,上层系统也看不见它。

UFUNCTION:函数的接线板

UFUNCTION() 把 C++ 函数挂到反射系统。常见用法:

  • BlueprintCallable:蓝图可以调用;
  • BlueprintPure:蓝图里当作纯函数,无副作用;
  • BlueprintImplementableEvent:C++ 声明,蓝图实现;
  • BlueprintNativeEvent:C++ 有默认实现,蓝图可以覆盖;
  • Server / Client / NetMulticast:网络 RPC。

BlueprintNativeEvent 有个坑:实际生成的函数名会带 _Implementation 后缀。你在 C++ 里写实现时必须用 MyFunc_Implementation,否则链接报错能把你带到天亮。

USTRUCT:轻量结构的反射

USTRUCT() 不需要继承 UObject,它给普通结构体加上反射能力,用于蓝图变量、网络复制、TMap/TArray 的键值、CSV/JSON 导入导出。结构体不由 GC 管理,里面如果放 UObject* 指针,必须谨慎处理生命周期。

USTRUCT(BlueprintType)
struct FWeaponConfig
{
    GENERATED_BODY()

    UPROPERTY(EditAnywhere, BlueprintReadWrite)
    float Damage;

    UPROPERTY(EditAnywhere, BlueprintReadWrite)
    TSubclassOf<AProjectile> ProjectileClass;
};

UENUM:枚举的元数据

UENUM() 让 C++ 枚举出现在蓝图下拉框里。BlueprintType 让枚举可以作为蓝图变量,DisplayName 则可以给枚举值一个友好的中文名字。

UENUM(BlueprintType)
enum class EWeaponType : uint8
{
    Rifle     UMETA(DisplayName="步枪"),
    Shotgun   UMETA(DisplayName="霰弹枪"),
    Sniper    UMETA(DisplayName="狙击枪")
};

UHT:藏在编译背后的翻译官

宏只是入口,真正干活的是 Unreal Header Tool。它不是 C++ 编译器,而是一个专门的解析器,在正式编译之前扫描你的 .h 文件,把宏和声明翻译成元数据,再生成 .generated.h.gen.cpp

这两份生成文件里藏着很多东西:StaticClass() 的实现、ClassConstructorInitializePrivateStaticClassExecNativeGetPrivateStaticClass、属性偏移计算、函数反射注册等等。你平时不用看它们,但链接错误时它们经常出现在报错信息里。

flowchart TD
    A[编写 .h 头文件] --> B[UHT 扫描宏与声明]
    B --> C{语法合法?}
    C -->|否| D[报错: 缺少 GENERATED_BODY 或宏语法错误]
    C -->|是| E[生成 .generated.h]
    B --> F[生成 .gen.cpp]
    E --> G[C++ 编译器编译]
    F --> G
    G --> H[链接生成 DLL/EXE]

这个流程里最容易踩的坑是 include 顺序。.generated.h 必须是你这个类头文件的最后一个 include。如果下面又 include 了别的头文件,UHT 有时会给出非常晦涩的报错,提示你「generated header must be the final include」。老周的经验是:养成习惯,把 #include "Xxx.generated.h" 永远放在 #pragma once 之后的最后一行。

下面是 UHT 与编译器协作的时序:

sequenceDiagram
    participant Dev as 开发者
    participant UHT as UnrealHeaderTool
    participant Gen as 生成文件
    participant CL as C++ 编译器
    participant Link as 链接器

    Dev->>UHT: 保存 .h 并触发编译
    UHT->>UHT: 解析宏、提取元数据
    UHT->>Gen: 写入 .generated.h / .gen.cpp
    Gen->>CL: 作为源文件参与编译
    CL->>CL: 生成 .obj
    CL->>Link: 提交目标文件
    Link->>Link: 解析 StaticClass 等符号
    Link->>Dev: 输出 DLL/可执行文件

UHT 的解析能力有限,它不认识所有 C++ 语法。模板、复杂的 SFINAE、宏嵌套都可能让它困惑。所以 UE 代码里有一些奇怪的规矩:反射相关的类尽量保持声明简单,不要在 UPROPERTY 上面叠太多条件编译。

遇到 UHT 报错怎么办

UHT 报错通常出现在正式编译之前,Visual Studio 的输出窗口会显示 UnrealHeaderTool 字样。最常见的原因有三类:

  • 某个 UObject 派生类的头文件忘了写 GENERATED_BODY(),或者写在了错误的位置;
  • #include "Xxx.generated.h" 不是最后一个 include,下面又跟了别的头文件;
  • 宏括号里的字符串用了中文引号或转义错误,比如某个 Category 值把英文双引号写成了中文双引号。

老周的排查顺序是:先搜 GENERATED_BODY,再搜 .generated.h 位置,最后检查宏里的引号。九成的问题都能在这三步内解决。如果报错信息指向 .generated.h 里的某一行,真正出错的地方几乎都在你自己的头文件里,别被文件名骗了。

一个小技巧:在 Visual Studio 里把 Intermediate/Build/Win64/YourProject/Inc 下的生成文件加进外部工具,偶尔看一眼 UHT 到底给你生成了什么。看多了你就知道 GENERATED_BODY 展开后大概长什么样,遇到链接错误也不会慌。

Specifier 的细节战场

宏后面的括号里填的是 Specifier,它们决定反射数据的具体行为。UE5.8 的 Specifier 非常多,这里只挑几个天天见但经常踩坑的讲。

EditAnywhere vs VisibleAnywhere vs EditDefaultsOnly

这三个名字只差一两个单词,意思差很远:

  • EditAnywhere:在实例和原型上都能改;
  • VisibleAnywhere:只在 Details 面板显示,不能改;
  • EditDefaultsOnly:只能在类默认值(CDO)里改,放到场景里的实例改不了。

我们项目里有个策划,曾经在实例上改了 EditDefaultsOnly 的数值,发现运行时不生效,以为出 bug,在群里骂了十分钟。后来才发现那个字段设计时就不允许实例覆盖。

BlueprintReadWrite vs BlueprintReadOnly

这个组合控制蓝图对属性的读写权限。BlueprintReadOnly 在蓝图里只能读,C++ 里该咋写咋写。很多服务端字段,比如玩家唯一 ID、服务器时间戳,应该用 BlueprintReadOnly,防止蓝图脚本意外篡改。

Category

Category="Gameplay|Weapon" 这样的写法会在 Details 面板里生成层级折叠菜单。层级用 | 分隔。Category 填得好,策划和 TA 找属性时能省很多时间。我见过有项目所有属性都堆在 Default 下面,Details 面板拉起来像瀑布,体验极差。

meta = (…)

meta 里可以放更精细的提示。几个常用的键:

  • ClampMin / ClampMax:编辑器里限制数值输入范围;
  • EditCondition:根据另一个布尔字段动态显示或禁用;
  • ExposeOnSpawn:在 SpawnActor 时暴露到构造函数参数;
  • DisplayName:给属性起一个中文显示名;
  • ToolTip:鼠标悬停提示。
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Status", meta=(ClampMin="0", ClampMax="999", DisplayName="最大生命值"))
int32 MaxHealth;

UHT 会把这些 meta 信息写进 UMetaData,编辑器 Details 面板在绘制属性时读取它们。这套机制让 C++ 代码和编辑器表现解耦:程序员定义数据,策划和 TA 在编辑器里以人类友好的方式调整。

TSubclassOf 与 SoftObjectPath

反射系统除了描述「我有什么」,还要解决「我引用谁」。在 UE 里,硬引用 UClass*UObject* 往往意味着强加载,容易造成内存压力。TSubclassOfFSoftObjectPath 就是两种更优雅的引用方式。

TSubclassOf

TSubclassOf<AProjectile> 表示「一个继承自 AProjectile 的类的类型引用」。它比裸 UClass* 类型安全:编辑器下拉框只会列出 AProjectile 的子类,蓝图里也不会选错。

UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Weapon")
TSubclassOf<AProjectile> ProjectileClass;

// 使用时
FActorSpawnParameters Params;
GetWorld()->SpawnActor<AProjectile>(ProjectileClass, SpawnTransform, Params);

TSubclassOf 内部还是 UClass*,但它通过模板参数把类型约束做在编译期。如果填错类型,编译器会直接报错,而不是等到运行时才发现蓝图画了个错的类进去。

FSoftObjectPath 与 TSoftObjectPtr

FSoftObjectPath 是一个字符串路径,比如 /Game/Assets/Sounds/ShotFire.ShotFire。它本身不加载资源,只在需要时通过 LoadSynchronous 或异步加载接口去取。TSoftObjectPtr<T> 是带类型的软引用,加载完成后可以拿到真正的对象指针。

UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Audio", meta=(AllowedClasses="SoundWave"))
TSoftObjectPtr<USoundBase> FireSound;

// 异步加载
FStreamableManager& Streamable = UAssetManager::GetStreamableManager();
Streamable.RequestAsyncLoad(FireSound.ToSoftObjectPath(), FStreamableDelegate::CreateLambda([this]()
{
    USoundBase* Loaded = FireSound.Get();
    // 播放
}));

软引用是内存管理的关键工具。我们项目早期有个武器系统,把所有武器的音效、特效、模型全做成硬引用,结果一把枪加载就拖进来几十兆无关资源。改成 TSoftObjectPtr 后,只在换武器时加载当前需要的资源,内存峰值降了接近百分之四十。

反射驱动的四大系统

讲完宏和引用,再看反射在引擎里真正支撑了哪些系统。

序列化

FArchive 在保存和加载对象时,会遍历 UClass 里的所有 UProperty,根据属性类型调用对应的 Serialize 方法。UPROPERTY() 标记的字段会自动参与这个过程;没标记的字段对序列化系统来说不存在。

这意味着:如果你加了一个新字段,只要加上 UPROPERTY(),保存的 uasset 天然知道怎么读旧数据。对于新增字段,旧资源加载时会被初始化成默认值;对于删除字段,旧数据会被静默跳过。UE 的 Serialize 不是简单的内存 dump,而是基于属性的版本化读写。

Details 面板

编辑器选中一个 Actor 或 Asset,Details 面板会调用 ForEachPropertyOfClass 或类似的遍历接口,把 UProperty 一个个画成 UI 控件。EditAnywhereCategorymeta 这些信息都在这里生效。你不需要为每个类手写属性面板,这就是反射给工具链带来的复利。

蓝图暴露

蓝图虚拟机通过反射找到 C++ 类的 UFunctionUProperty,完成调用和属性绑定。BlueprintCallable 的函数会被注册到 FFieldNotificationClassDescriptor,变量读写会走反射路径。没有反射,蓝图和 C++ 之间不可能做到这种程度的互通。

垃圾回收

UE 的 GC 采用可达性分析。它会从 Root Set 出发,沿着 UObject* 引用遍历对象图。哪些引用算有效引用?基本上只有 UPROPERTY() 标记的 UObject*TArray<UObject*>TMap 里的对象指针会被识别。裸指针、原始 void*、没有 UPROPERTY 的智能指针都不会被扫描。

architecture-beta
    group ue(Runtime) {
        service UObject(UObject)
        service UClass(UClass)
        service UProperty(UProperty)
        service GC(GarbageCollector)
        service Serializer(FArchive Serializer)
        service Details(Details Panel)
        service BP(Blueprint VM)
    }

    UObject:R --> L:UClass
    UClass:R --> L:UProperty
    UProperty:B --> T:Serializer
    UProperty:B --> T:GC
    UProperty:B --> T:Details
    UProperty:B --> T:BP

GC 扫描的复杂度大致与对象图中的引用边数成正比。用 EE 表示引用边数量,VV 表示对象数量,一次 Mark 阶段的时间开销可以近似写成 O(V+E)O(V + E)。这个量级看起来不大,但当场景里有几万个带引用的 UObject 时,GC 的停顿感就会变得明显。减少不必要的 UPROPERTY 引用、合理使用软引用,是优化 GC 的重要手段。

反射不是没有代价

很多人以为加几个宏只是多写几行声明,实际上反射数据会进二进制。每个 UPROPERTY 都会生成对应的 UProperty 对象,每个 UFUNCTION 都会生成 UFunction 对象,每个 UCLASS 都会生成 UClass 对象。这些元对象在编辑器模式和运行时都占用内存,加载模块时还要做一次注册。

我们项目有一次做内存分析,发现某个模块加载后凭空多了两兆常驻内存。查下去发现是一个数据表相关的 C++ 类里塞了七八十个 UPROPERTY,其中一大半只是给编辑器配置用的临时字段,运行时根本不需要。把它们改成 WITH_EDITORONLY_DATA 包裹之后,Shipping 包里的反射数据少了一半,内存也降了下来。

#if WITH_EDITORONLY_DATA
UPROPERTY(EditAnywhere, Category="EditorOnly")
FString DebugDescription;
#endif

这个宏的意思是:只在编辑器构建里生成反射数据,Shipping 包彻底剔除。对于只在编辑器下调试或配置的数据,这是一个非常实用的开关。

另外,反射遍历本身也有 CPU 开销。序列化大对象、GC 标记阶段、Details 面板刷新,都会走到属性遍历。项目后期做优化时,我们经常用 FProperty 的新 API 替代旧的 UProperty 访问,因为 FField 体系在 UE4.25 之后逐步替代了老的反射字段实现,内存布局和缓存友好性都更好。UE5.8 里新项目应该尽量用 FProperty* 而不是 UProperty* 做底层遍历。

还有一个容易忽略的成本:BlueprintCallable 的函数会在反射系统里多占一份空间。如果一个函数只在 C++ 内部调用,完全没有必要暴露给蓝图。我见过有同学习惯给所有 public 函数都加 BlueprintCallable,结果一个类的反射数据膨胀得厉害。更好的做法是:先问自己一句,蓝图真的需要调这个函数吗?如果答案是否定的,就别加。

一段能直接跑的示例

下面这段代码把前面讲的几件事串在一起:一个带反射的武器配置,使用 TSubclassOf 指定投射物,用 FSoftObjectPath 引用音效,通过 UFUNCTION 暴露给蓝图一个开火接口。

#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "WeaponBase.generated.h"

UCLASS(Blueprintable, BlueprintType)
class MYGAME_API AWeaponBase : public AActor
{
    GENERATED_BODY()

public:
    AWeaponBase();

    UPROPERTY(EditDefaultsOnly, Category="Weapon|Config", meta=(ClampMin="0.1", ClampMax="10"))
    float FireRate;

    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="Weapon|Projectile")
    TSubclassOf<AProjectile> ProjectileClass;

    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="Weapon|Audio", meta=(AllowedClasses="SoundWave"))
    FSoftObjectPath FireSoundPath;

    UFUNCTION(BlueprintCallable, Category="Weapon")
    void Fire();

    UFUNCTION(BlueprintNativeEvent, Category="Weapon")
    void OnFire();
    virtual void OnFire_Implementation();

protected:
    UPROPERTY(VisibleAnywhere, Category="Weapon|State")
    float LastFireTime;
};

这个类一共不到五十行,但已经覆盖了 UCLASSUPROPERTYUFUNCTIONUSTRUCT 的思想、TSubclassOfFSoftObjectPathBlueprintNativeEvent、Category 分层、meta 限定。把它跑通,比看十页文档都管用。

我们后来定的几条规矩

那次崩溃之后,老周在组里定了几条硬性规范:

  1. 所有 UObject* 成员必须加 UPROPERTY(),没有例外。临时指针用 TWeakObjectPtr
  2. 武器、技能、道具配置里的资源引用优先用 TSoftObjectPtrFSoftObjectPath,禁止直接硬引用美术资源。
  3. 蓝图需要调的函数才加 BlueprintCallable,不需要的不要滥用,避免生成不必要的反射数据。
  4. BlueprintNativeEvent 的实现必须带 _Implementation 后缀,代码审查时重点检查。
  5. 每个 UPROPERTY 都要有 Category,不允许出现 Default 分类的堆积。

这几条规矩执行下来,序列化相关的崩溃基本绝迹,Details 面板也清爽了很多。反射系统本身不复杂,复杂的是人对规则的敬畏程度。

写在最后

UE5.8 的反射系统本质上是一套「声明即数据」的契约。你写宏,UHT 帮你生成元数据,引擎上层用这些元数据做序列化、GC、编辑器、蓝图四件事。理解了这个契约,很多看起来莫名其妙的规定就有了落脚点:为什么裸指针会丢数据,为什么 .generated.h 要放在最后,为什么 BlueprintNativeEvent 要加后缀,为什么软引用能省内存。

老周现在每次 review 代码,看到新人漏写 UPROPERTY() 都会把那个凌晨三点的 dump 链接甩过去。那个 bug 早就修好了,但它留下的习惯和敬畏还在。