news 2026/9/30 11:37:02

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Unreal对C++做了什么:UCLASS反射与垃圾回收解析

第一次在 Unreal 工程里写 C++,大概率会怀疑人生。同样是类、同样是成员变量,按标准 C++ 写一个 class Player 到这边居然要先塞一串 UCLASS、UPROPERTY、GENERATED_BODY() 的宏,少写一个,轻则编辑器读不到,重则直接访存崩溃。很多从传统 C++ 项目转过来的朋友第一反应是“这玩意儿还是 C++ 吗”,等搞明白之后又会觉得,这套设计其实是 Unreal 跟标准 C++ 开的一个很聪明的挂。这个系列想做的事情,从名字应该也能看出来:《Unreal对C++做了什么》。作为前言,我不打算直接铺代码,先把几个最关键的改造点摆上台面——UCLASS 反射、UPROPERTY 属性、GC 垃圾回收,以及它们到底怎么改变了你写 C++ 的方式。

1. 为什么 Unreal 要“折腾” C++

1.1 标准 C++ 缺了什么

先聊一个很多人潜意识里有、但没人明说的问题:C++ 编译完之后,类信息就“蒸发”了。你可以在 IDE 里看到 Player 类有哪些成员变量和函数,但运行时的二进制文件里只保留了地址、大小、函数入口这些底层信息。想知道“这个类有几个 float 属性、那个函数接收什么类型的参数”,标准 C++ 本身回答不了。

引擎编辑器恰恰需要这种能力。你在 Unreal 编辑器里选中一个 Actor,右侧详情面板能列出它的 MoveSpeed、MaxHP、Inventory 这些成员;你拖一个蓝图节点,编辑器能列出当前类所有可以被蓝图调用的函数;你保存关卡,系统要把场景里每个 Actor 的坐标、旋转、缩放以及自定义属性写到磁盘。这些需求全都指向同一个基础设施:运行时反射。

标准 C++ 不是完全没有反射,RTTI(typeid 和 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 = NewObject<AMyActor>(),这是“印章盖出来的成品”;而中间的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,没有任何 UPROPERTY,GC 完全不知道这条引用链,可能在某个回收周期里把这个 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 则是另一套逻辑:你通过NewObject<T>()、SpawnActor<T>()、CreateDefaultSubobject<T>()等函数创建对象,但通常不需要手动销毁,尤其不要在普通 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 还提供了几种专门的智能指针,需要区分清楚。

TWeakObjectPtr<T>是“弱引用”。它持有的是一份关于 UObject 的弱引用信息,GC 不会因为它而延长对象的生命,但会在对象被回收后自动把指针置空。这种指针适合缓存、观察、事件监听场景,它不会造成悬挂崩溃,但也不影响对象的回收时机。

TStrongObjectPtr<T>则是“强引用”。它会给对象增加一个“根引用”关系,GC 看到它会保留对象。它更像 C++ 的shared_ptr,但专门用于 UObject。它适合跨系统的对象持有,比如一个非 UObject 的管理器要长时间保存某个关卡内的对象。

TObjectPtr<T>在 UE5 里很常见,但它不是管理指针,只是编辑器里的对象引用封装,主要解决延迟加载问题和编辑器资产引用追踪。它在生成代码和资产系统里出现频率高,你日常写逻辑时不必刻意使用。

4.4 手动生命周期控制:什么时候才用 AddToRoot

多数游戏代码不需要直接和 GC 打交道,UObject 的创建、引用、回收都由反射自动维护。但有一些特殊场景必须人工干预。比如你从资源系统异步加载一张“永不卸载”的表格,希望它全局常驻,可以用LoadObject()之后调用AddToRoot();或者你写了一个对象池,希望池里的对象在空闲时不被回收,可以把空闲对象AddToRoot(),取回池里时再RemoveFromRoot()。

我更想提醒的是:不要为了省内存而到处手动deleteUObject。正确方式是让 GC 按引用关系判断;如果确实需要立即生效,应该调用ConditionalBeginDestroy()或标记为待回收状态,而不是直接释放底层内存。手动控制生命周期属于高级操作,新手阶段少做,多做你会在性能和稳定性之间两头挨打。

5. 实践中踩过的坑和排查方法

5.1 因为忘了 UPROPERTY,对象被 GC 收走

我曾在一个副本地图系统里吃过亏。副本管理器里有一个TArray<AActor*> SpawnedMonsters;,用来追踪刷出来的怪物,方便副本结束时统一清理。数组声明时没写 UPROPERTY,我当时想“反正都是普通 C++ 容器,我自己管”。开始测试没问题,但在一次长时间驻留后,副本里的怪物突然开始陆续变成无法交互的“幽灵”。

排查后发现,GC 在某个时机把没有被“反射引用”的怪物回收了。副本管理器自己持有SpawnedMonsters,但它不是 UObject 的 UPROPERTY 容器,GC 检查引用时完全忽略这层关系。怪物一旦被回收,编辑器里还能看到半透明的幽灵 Actor,但任何交互都会失败。

解决办法很简单:把容器改成UPROPERTY() TArray<AActor*> 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 class=AMyActor,或在编辑器的脚本控制台里用反射调试命令。更多时候,最简单的办法是打开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 打开看一眼,你可能会发现,那些看似“黑魔法”的宏,其实比你想的要老实得多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/30 11:36:55

AZ-204备考指南:从题库到原理,高效刷题与技能提升

简介&#xff1a;这份题库覆盖微软AZ-204开发人员认证的重点考题&#xff0c;面向准备参加认证考试、希望熟悉Azure云服务场景化题型的开发者和运维工程师。压缩包内含1个PDF文件&#xff0c;约221KB&#xff0c;内容紧凑&#xff0c;便于快速阅读和反复自测。当前已有338人学习…

作者头像 李华
网站建设 2026/9/30 11:36:39

RESP.app连不上Redis?从服务端到客户端的排查指南

有阵子我在好几个技术群里反复看到同一类求助&#xff1a;“我用RESP.app连不上Redis&#xff0c;是不是这个软件有毛病&#xff1f;”点开截图一看&#xff0c;报错五花八门&#xff0c;但绝大多数问题根本不在客户端这边。RESP.app作为一款跨平台Redis图形化客户端&#xff0…

作者头像 李华
网站建设 2026/9/30 11:36:30

2D技术实战全解析:从Unity碰撞检测到医学图像分割

最近网上流传一句很火的“纪录片式”旁白&#xff1a;“大型纪录片《终于是2D的了&#xff0c;就只冲这一点也要狠狠支持》持续为您播出&#xff01;”配合那标志性的配音腔&#xff0c;评论区里清一色刷“狠狠支持”。 很多人把它当段子看&#xff0c;但我作为一个经常和 Uni…

作者头像 李华
网站建设 2026/9/30 11:33:50

让决策贴近数据:衡石企业级 BI 的订阅触达与权限管理

企业决策通常从数据发现开始&#xff0c;再进入业务沟通、确认与行动。衡石企业级 BI 可通过订阅触达、阈值预警、应用发布、权限管理和指标管理&#xff0c;帮助企业在可控范围内分发分析结果、管理数据访问与指标资产。本文以公开产品能力为边界&#xff0c;说明这些能力适用…

作者头像 李华
网站建设 2026/9/30 11:33:37

板级适配 · 链接脚本 lds(下)①:逐段解剖各内存段

系列目录&#xff1a;本篇是板级适配系列第 4 篇&#xff08;下&#xff09;的第 1 部分。承接第 10 篇&#xff08;上&#xff09;的「256KB 公寓楼」规则&#xff0c;带你逐间房进去看——.isr_vector/.text/.data/.bss 每段怎么布置、门牌号&#xff08;VMA&#xff09;怎么…

作者头像 李华
网站建设 2026/9/30 11:28:17

合并两个有序链表:迭代、递归与原地合并的面试全攻略

1. 这道题为什么值得反复刷&#xff1a;合并有序链表的本质与常见误区 如果只让我推荐三道链表入门题&#xff0c;LeetCode 21“合并两个有序链表”一定在其中。它的题干极短&#xff1a;给定两个升序链表 list1 和 list2 &#xff0c;把它们合并成一个新的升序链表并返回。…

作者头像 李华