ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Unreal对C++做了什么:UCLASS反射与垃圾回收解析

Unreal对C++做了什么:UCLASS反射与垃圾回收解析 第一次在 Unreal 工程里写 C大概率会怀疑人生。同样是类、同样是成员变量按标准 C 写一个 class Player 到这边居然要先塞一串 UCLASS、UPROPERTY、GENERATED_BODY() 的宏少写一个轻则编辑器读不到重则直接访存崩溃。很多从传统 C 项目转过来的朋友第一反应是“这玩意儿还是 C 吗”等搞明白之后又会觉得这套设计其实是 Unreal 跟标准 C 开的一个很聪明的挂。这个系列想做的事情从名字应该也能看出来《Unreal对C做了什么》。作为前言我不打算直接铺代码先把几个最关键的改造点摆上台面——UCLASS 反射、UPROPERTY 属性、GC 垃圾回收以及它们到底怎么改变了你写 C 的方式。1. 为什么 Unreal 要“折腾” C1.1 标准 C 缺了什么先聊一个很多人潜意识里有、但没人明说的问题C 编译完之后类信息就“蒸发”了。你可以在 IDE 里看到 Player 类有哪些成员变量和函数但运行时的二进制文件里只保留了地址、大小、函数入口这些底层信息。想知道“这个类有几个 float 属性、那个函数接收什么类型的参数”标准 C 本身回答不了。引擎编辑器恰恰需要这种能力。你在 Unreal 编辑器里选中一个 Actor右侧详情面板能列出它的 MoveSpeed、MaxHP、Inventory 这些成员你拖一个蓝图节点编辑器能列出当前类所有可以被蓝图调用的函数你保存关卡系统要把场景里每个 Actor 的坐标、旋转、缩放以及自定义属性写到磁盘。这些需求全都指向同一个基础设施运行时反射。标准 C 不是完全没有反射RTTItypeid 和 dynamic_cast能告诉你类型是什么但它不能枚举字段不能列出函数签名更不能把 C 成员变量变成编辑器里可拖拽的滑块。Unreal 需要的是更完整的反射能力那怎么办自己造。1.2 代码生成才是 Unreal 的答案Unreal 没有去改 C 编译器也没有把整个引擎换成另一种语言它的做法是在编译流程中插入一个“元信息收集器”。这个工具叫 UnrealHeaderTool简称 UHT。它会在代码编译前扫描带 UCLASS、UPROPERTY、UFUNCTION、USTRUCT、UENUM 这些宏的头文件解析出类的结构然后生成一份额外的 .generated.h 文件和对应的 .gen.cpp 文件。这里有个容易误解的点。UCLASS 这种宏并不是万能的装饰器它在 C 编译器眼里什么都不是顶多是一个展开为空的宏但 UHT 把它当成标记信号。UHT 看到 UCLASS就知道“这是一个要被反射系统登记的类”看到 UPROPERTY就知道“这一个成员要暴露给反射系统”。然后 UHT 生成的代码会被 include 回原头文件最终编译出来的 DLL 里不仅有你的业务逻辑还带上一整张反射信息表。你可以在工程里随便打开一个头文件看末尾那句#include MyClass.generated.h这就是 UHT 的产出。把它去掉编译器会报一堆“GENERATED_BODY 未定义”的错误加上它你的类就在 Unreal 的反射世界里活过来了。1.3 增强后的 C 到底解决了什么问题前面说的反射表实际接手了 Unreal 里一大堆看似毫不相关的功能。属性面板能显示成员变量因为编辑器从反射表里查到了变量名和类型蓝图能访问 C 函数因为反射表里记录了 UFUNCTION 的函数签名存档和网络同步能把成员变量按名字序列化因为反射表知道每个属性在内存中的偏移和大小就连垃圾回收也依赖反射表去遍历对象引用的边。四个大需求底层就这一套“标记 代码生成 反射表”的结构。你可以把 Unreal 的 C 理解为 C 的“方言增强版”语法核心还是标准 C但通过宏和代码生成器在编译期硬生生补了一层反射信息。理解这一点后面所有宏、所有工具链逻辑都不神秘了。2. UCLASS 与反射系统让 C 认识自己2.1 UCLASS 宏并不只是注释很多初学者注释掉UCLASS()之后发现程序还能跑就误以为这个宏是给人看的注释。实际上它是 UHT 的入口信号。只有被 UCLASS 标记的类UHT 才会把它的相关信息送进生成代码这个类的父类、可见性、蓝图使用方式、可否实例化等说明都会被记录到运行时的一个 UClass 对象里。UClass 是 Unreal 反射系统里最核心的类型。它本身也从 UObject 继承你可以把它理解成“类的描述对象”。每个带 UCLASS 的 C 类在游戏运行时都对应着一个全局唯一的 UClass 实例。你在代码里执行AMyActor::StaticClass()拿到的就是一个指向这个描述对象的指针。这个指针能给你什么Class 的名称、父类链、属性列表、函数列表、实现这个类的 C 类型信息全都在里面。UCLASS()括号里也能写说明符比如Blueprintable表示这个类能被蓝图继承Abstract表示不能在关卡里直接生成NotPlaceable表示不能拖到场景里。这些说明符会直接影响编辑器行为和蓝图系统比单纯给程序员看的注释重要得多。2.2 UObject、UClass 和实例的关系把这三者的关系理清楚Unreal C 的一大半困惑就解开了。用生活化的话说你写了一个类AMyActor这是“模具”运行时创建一个对象AActor* Actor NewObjectAMyActor()这是“印章盖出来的成品”而中间的AMyActor::StaticClass()则是“模具的规格说明书”——它介绍这个模具能生产什么成品、有哪些字段、有哪些接口。具体到代码层面AMyActor这个 C 类本身编译成一个普通类型但因为它继承自AActor并且带 UCLASS 标记系统在启动时会把对应的 UClass 对象注册进全局的类注册表。对象之间互相引用引用的是 UObject 实例而反射系统遍历时拿 UClass 上的 FProperty 列表去查看每个实例的内存按属性的偏移量读出具体值。这也是为什么反射系统看起来“无所不能”它拿着 UClass 这张结构图就能看懂任何实例的内部布局不依赖你手动写 Getter 和 Setter。属性面板、序列化、网络同步、GC全是拿着这张结构图在工作。2.3 反射数据在编辑器、蓝图、序列化里的实际用途反射数据就像一个巨大的户口本。Unreal 编辑器启动后会遍历所有模块注册进来的 UClass形成类列表。你在内容浏览器的“Place Actors”面板里看到的每个可放置类就是从这个户口本里过滤出来的。你打开蓝图编辑器搜索某个自定义类的函数也是在查户口本。编辑器不一定需要运行你的游戏代码但它仍然能正确显示和操作你的类靠的就是这份反射数据。序列化同样依赖反射。一个 Actor 被保存到关卡文件时引擎会检查它的 UClass 上所有包含序列化要求的属性把每个属性的名字、类型、值写入二进制加载时再按同样的记录逐个恢复。这就是为什么你改了一个动画的蓝图不用重新编译也能保存状态——蓝图里的变量不一定对应 C 内存中的某个固定结构但反射可以动态地判断。GC 也离不开反射。Unreal 的垃圾回收要遍历所有 UObject 的引用关系它怎么知道哪个 UObject 持有哪些别的 UObject答案还是反射。只有被标记为 UPROPERTY 的成员字段GC 才会把字段里保存的指针当作一条引用边来处理。这一点直接决定了你的对象什么时候会被回收什么时候不会被回收。3. UPROPERTY一句话影响三个系统3.1 UPROPERTY 的基本用法和元数据UCLASS 让类进入反射系统UPROPERTY 则让类里的具体字段进入反射系统。一个简单的例子UCLASS() class MYPROJECT_API AMyActor : public AActor { GENERATED_BODY() public: UPROPERTY(EditAnywhere, BlueprintReadWrite, Category Stats) float MaxHP; UPROPERTY(EditAnywhere, BlueprintReadWrite, Category Stats) float MoveSpeed; };这里每个 UPROPERTY 括号里的说明符都会生效。EditAnywhere表示这个属性在编辑器的“类默认值”和“关卡实例”里都能改BlueprintReadWrite表示蓝图既能读也能写Category决定它在详情面板里归到哪个分组。类似的说明符还有很多比如VisibleInstanceOnly只能在实例面板查看但不能修改Transient表示不参与存档SaveGame表示能被存档系统保存。这些说明符看着琐碎但它们决定了编辑器里的交互形态。你要做一个配置类属性只在默认值里能改就写EditDefaultsOnly你希望运行时动态变化并显示在面板上就写VisibleAnywhere。用错说明符轻则功能不可用重则状态被序列化到关卡文件里产生莫名其妙的问题。3.2 序列化与存档没有 UPROPERTY 等于没存老手经常强调一句话想让一个 C 变量在 Unreal 里被保存、被复制、被 GC 保护就必须加 UPROPERTY。这是由反射驱动的。序列化时引擎拿着 UClass 的 FProperty 列表逐个属性读取内存中的值。如果你没有把某个成员标记成 UPROPERTY反射表里根本没有这个字段序列化系统自然就当它不存在。举一个真实的坑用一个自定义结构体保存角色的位置结构体里只有普通 C 的FVector Position;没加 UPROPERTY。结果在编辑器里手动调好了位置保存关卡重新打开位置全部恢复成蓝图默认值。排查半天发现结构体成员没标记 UPROPERTY序列化直接忽略了它。同样的道理也适用于SaveGame存档系统属性没标SaveGame存档里就永远不会保存它。网络同步同理。UPROPERTY(Replicated)标记的属性会被网络系统纳入同步范围UPROPERTY(ReplicatedUsing OnRep_MaxHP)还能指定属性变化后触发一个回调函数。如果属性没有 UPROPERTY无论你写多少复制相关代码客户端都收不到这个值。因为复制系统不知道有这个字段的存在。3.3 垃圾回收标记GC 的唯一引用通道UPROPERTY 的另一个隐性作用是给 GC 提供引用追踪的依据。UE 里创建的 UObject 不像普通栈对象那样需要手动 delete垃圾回收器会定期扫描。扫描时它要知道“这个对象还活着吗”而判断依据就是看还有没有强引用指向它。GC 判断强引用的主要来源就是各个 UObject 上用 UPROPERTY 标记的成员指针。这句话翻译成人话就是一个 AActor 持有一个 UObject 指针如果这个指针被 UPROPERTY 标记GC 就会认为“Actor 活着它指向的对象也连带活着”如果你只是随便用一个裸指针存了 UObject没有任何 UPROPERTYGC 完全不知道这条引用链可能在某个回收周期里把这个 UObject 当垃圾干掉。我把这种错误称为“隐形的悬垂指针”。程序跑起来通常没问题一旦 GC 触发把对象清理掉下次再通过裸指针访问只能撞上一个崩溃。这个坑非常普遍后面第 5 部分我会专门复盘一次实战排查。4. Unreal 的 GC把 new 和 delete 收编了4.1 传统 C 内存管理 vs. Unreal 的托管对象标准 C 工程里你负责new也负责delete后来有了std::shared_ptr、unique_ptr用 RAII 做自动释放。Unreal 里的 UObject 和 Actor 则是另一套逻辑你通过NewObjectT()、SpawnActorT()、CreateDefaultSubobjectT()等函数创建对象但通常不需要手动销毁尤其不要在普通 C 代码里直接对 UObject 调用delete。GC 系统会接管生命周期。但要注意Unreal 的 GC 和 .NET 或 Java 的托管堆不一样。UObject 分配在普通 C 堆上只是被一个反射驱动的图结构纳入了管理。GC 采用标记清除的思路从根集合出发把所有能到达的对象标记为“存活”遍历结束后没有被标记的对象就会被回收。4.2 根集合从哪里来垃圾回收不能漫无目的地扫。GC 每次执行时需要一套明确的“起点”官方叫 root set根集合。根集合里的对象永不被回收GC 从它们出发遍历引用关系。Unreal 里的根集合来源很多比如全局的 UObject 注册表、被AddToRoot()显式标记的对象、编辑器里打开的资产对象、某些引擎固有单件以及所有被 TStrongObjectPtr 或类似机制强引用的对象。理解根集合的意义在于你可以在运行时用AddToRoot()让一个对象“不死”。“我再也不舍得删这个对象了把它钉在根集合里”这在管理异步加载出来的临时对象、缓存对象时很有用。但使用完必须RemoveFromRoot()否则它会一直占着内存直到游戏结束。4.3 TWeakObjectPtr、TStrongObjectPtr、TObjectPtr 这些指针是什么用普通裸指针存 UObject 有 GC 盲区那 Unreal 还提供了几种专门的智能指针需要区分清楚。TWeakObjectPtrT是“弱引用”。它持有的是一份关于 UObject 的弱引用信息GC 不会因为它而延长对象的生命但会在对象被回收后自动把指针置空。这种指针适合缓存、观察、事件监听场景它不会造成悬挂崩溃但也不影响对象的回收时机。TStrongObjectPtrT则是“强引用”。它会给对象增加一个“根引用”关系GC 看到它会保留对象。它更像 C 的shared_ptr但专门用于 UObject。它适合跨系统的对象持有比如一个非 UObject 的管理器要长时间保存某个关卡内的对象。TObjectPtrT在 UE5 里很常见但它不是管理指针只是编辑器里的对象引用封装主要解决延迟加载问题和编辑器资产引用追踪。它在生成代码和资产系统里出现频率高你日常写逻辑时不必刻意使用。4.4 手动生命周期控制什么时候才用 AddToRoot多数游戏代码不需要直接和 GC 打交道UObject 的创建、引用、回收都由反射自动维护。但有一些特殊场景必须人工干预。比如你从资源系统异步加载一张“永不卸载”的表格希望它全局常驻可以用LoadObject()之后调用AddToRoot()或者你写了一个对象池希望池里的对象在空闲时不被回收可以把空闲对象AddToRoot()取回池里时再RemoveFromRoot()。我更想提醒的是不要为了省内存而到处手动deleteUObject。正确方式是让 GC 按引用关系判断如果确实需要立即生效应该调用ConditionalBeginDestroy()或标记为待回收状态而不是直接释放底层内存。手动控制生命周期属于高级操作新手阶段少做多做你会在性能和稳定性之间两头挨打。5. 实践中踩过的坑和排查方法5.1 因为忘了 UPROPERTY对象被 GC 收走我曾在一个副本地图系统里吃过亏。副本管理器里有一个TArrayAActor* SpawnedMonsters;用来追踪刷出来的怪物方便副本结束时统一清理。数组声明时没写 UPROPERTY我当时想“反正都是普通 C 容器我自己管”。开始测试没问题但在一次长时间驻留后副本里的怪物突然开始陆续变成无法交互的“幽灵”。排查后发现GC 在某个时机把没有被“反射引用”的怪物回收了。副本管理器自己持有SpawnedMonsters但它不是 UObject 的 UPROPERTY 容器GC 检查引用时完全忽略这层关系。怪物一旦被回收编辑器里还能看到半透明的幽灵 Actor但任何交互都会失败。解决办法很简单把容器改成UPROPERTY() TArrayAActor* SpawnedMonsters;GC 就能顺着这个数组找到所有怪物不再误回收。这个坑让我彻底记住了在 Unreal 里凡是需要跨帧访问的 UObject 指针默认都应该加 UPROPERTY或者至少用 TWeakObjectPtr 观察。5.2 编译中的 “Generated.h” 和 UHT 报错另一种高频问题来自 UHT 本身。只要头文件里用了 UCLASS 或 UPROPERTY却没有GENERATED_BODY()或者缺少#include xxx.generated.h编译就会报各种难以理解的宏错误。这类问题的关键词通常是UnrealHeaderTool报错、Undefined type、requires a GENERATED_BODY。解决办法是回到头文件查看类声明格式是否正确确认 GENERATED_BODY 在最前面把#include xxx.generated.h放在头文件末尾。还有一个常见细节UCLASS 标记的类必须继承自 UObject 或它的子类普通 C 类直接标记 UCLASS 也会让 UHT 报错。遇到这类报错时先看 UHT 报的是哪个头文件再从头文件里逐行检查宏、继承和 include。大多数时候不是语法问题而是“Unreal 约定”没满足。5.3 反射不生效用这几个手段排查如果你已经加了 UPROPERTY但属性在编辑器和蓝图里就是不出现先确认你到底用的是哪个头文件、哪个类。我见过不少人在 A 类里改了属性却在 B 类的实例上找也见过重构后头文件里旧的声明没删干净UHT 读取的还是旧版本。编译后如果反射信息没同步可以关掉编辑器重新编译再开项目加载。Unreal 的 Live Coding 虽然方便但反射相关的结构性修改比如增加属性、改变类继承关系依赖稳定的 UHT 产物热重载偶发不生效。想要准确查看一个类的反射信息可以在控制台输入obj list classAMyActor或在编辑器的脚本控制台里用反射调试命令。更多时候最简单的办法是打开DefaultInput.ini、Saved文件夹里的日志搜UClass注册记录。反射系统初始化时会把大部分信息打到日志里看到日志里有你类的名字说明注册成功看不到八成是 UHT 没有识别这个类。5.4 怎么阅读 Unreal 源码不被反射系统绕晕很多人想深入理解这套机制去读 Engine 源码结果一头扎进几十万行代码里找不到方向。我的建议是抓三条线。第一条线看 UHT 生成的文件。选中你的头文件在工程里找到对应的.generated.h你会看到 UHT 为你的类生成了哪些代码。UCLASS 会生成_STATIC_CLASS、StaticClass等函数UPROPERTY 会生成属性访问的相关辅助代码。看懂这些宏的“魔法感”就消失了。第二条线看反射系统的核心代码。重点关注Engine/Source/Runtime/CoreUObject/Private/UObject下的UObjectBaseUtility.cpp、Class.cpp、Property.cpp。这三个文件分别是对象基类管理、UClass 注册、属性描述的核心。第三条线从语法层面验证。找到一个 UPROPERTY 宏的定义右键“转到定义”你会发现它最终展开的其实就是普通 C 代码加一些static_assert。看多了之后你会发现Unreal 对 C 的“改造”并不是魔法而是宏 代码生成 一套聪明的运行时结构。我个人在实际操作中的体会是刚接触 Unreal C 时最好把“UCLASS 标记的类”和“普通 C 类”从心理上分开。前者进入反射世界享受属性面板、序列化、GC、蓝图互通的便利但必须遵守 Unreal 的规矩后者保留标准 C 的灵活性却不能和反射系统兼容。写任何一个类之前先问自己一句这个类需要被编辑器看到吗需要参与序列化吗会被蓝图引用吗答案如果是“需要”麻利地加上 UCLASS、UPROPERTY并根据需求挑好说明符答案如果是“不需要”那普通类也完全没问题别为了 Fancy 给所有东西都打标签。这套反射与 GC 体系就是 Unreal 对 C 做的最关键的一件事。它让 C 拥有了接近托管语言的开发体验同时保留了引擎底层的运行性能。后面这个系列会逐步展开 UCLASS 的深刻使用、UPROPERTY 的完整说明符清单、UFUNCTION 的网络与蓝图联动、以及 GC 源码级别的标记清除细节。有兴趣的话可以先把手边任何一个 UObject 子类的 .generated.h 打开看一眼你可能会发现那些看似“黑魔法”的宏其实比你想的要老实得多。
返回列表