第三章:Unreal 对象模型:UObject 体系、反射系统、垃圾回收机制

好,咱们今天来聊聊 Unreal 里最核心的东西——对象模型。说白了,就是 UObject 那套体系。很多新手刚接触 UE 时,会觉得这东西有点玄乎,不就是个基类嘛,继承它干嘛?

我刚开始做项目时也这么想。直到有一次,我写了个纯 C++ 类,没继承 UObject,结果发现没法在编辑器里看到它,也没法被蓝图调用,更别提自动释放内存了。嗯,从那以后,我才真正意识到 UObject 这套体系有多重要。

3.1 UObject 体系:一切对象的基石

UObject 是 Unreal 里几乎所有对象的根。你想想看,Actor、Component、Asset,甚至 UWorld、UGameInstance,它们最终都继承自 UObject。为什么?因为 UE 需要一套统一的管理机制。

这套机制包括:

  • 反射系统:让 C++ 对象在运行时能被“看穿”
  • 垃圾回收:自动管理内存,避免你手动 delete
  • 序列化:保存和加载对象状态
  • 编辑器集成:属性面板、蓝图节点等

我个人习惯,只要是需要被引擎管理的对象,一律继承 UObject。哪怕只是一个简单的数据容器,我也会用 UObject 而不是普通 C++ 类。为什么?因为后续扩展太方便了。

核心原则:如果你想让一个对象拥有生命周期管理、网络复制、编辑器支持,那就继承 UObject。否则,用普通 C++ 类即可。

3.2 反射系统:让 C++ 拥有“灵魂”

反射,说白了就是让程序在运行时能“认识”自己的结构。普通 C++ 里,你定义了一个类,编译完就变成二进制了,你没法在运行时问它“你有哪些属性?”。但 UE 的反射系统可以。

怎么做到的?靠的是 UE 的 UHT(Unreal Header Tool)。你写的头文件里那些 UCLASS、UPROPERTY、UFUNCTION 宏,UHT 会在编译前扫描它们,生成一堆反射数据。

举个例子:

// MyObject.h
UCLASS(BlueprintType)
class UMyObject : public UObject
{
    GENERATED_BODY()

public:
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Stats")
    float Health;

    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Stats")
    float MaxHealth;

    UFUNCTION(BlueprintCallable, Category = "Stats")
    void TakeDamage(float DamageAmount);
};

这段代码里,UCLASS 告诉引擎“这是个 UObject 子类”,UPROPERTY 告诉引擎“这个属性需要暴露给反射系统”,UFUNCTION 告诉引擎“这个函数可以被蓝图调用”。

我在项目中遇到过一个问题:有个同事写了个 UPROPERTY,但忘了加 EditAnywhere,结果美术在编辑器里死活找不到这个属性。排查了半天,其实就是少了个宏。所以,反射宏不是装饰,是实实在在的接口声明

小技巧:如果你想让一个属性在编辑器里可见但不可编辑,用 BlueprintReadOnly 而不是 EditAnywhere。这样既能展示数据,又防止误改。

3.3 反射系统的核心机制

反射系统到底怎么工作的?我简单梳理一下:

  1. UHT 预处理:编译前扫描头文件,生成 .generated.h 和 .gen.cpp 文件
  2. 注册阶段:启动时,所有 UClass 自动注册到全局的 UClass 注册表中
  3. 运行时查询:通过 UClass 对象,可以获取属性列表、函数列表、父类信息等
  4. 动态调用:通过 UFunction 的 ProcessInternal 机制,可以动态调用函数

你想想看,这意味着什么?意味着你可以写一个通用工具,遍历任意 UObject 的所有属性,自动生成 UI、自动保存数据、自动网络同步。这就是反射的威力。

我曾经写过一个通用存档系统,就是靠反射遍历所有 UPROPERTY,自动序列化到 JSON。代码量少得惊人,而且完全不需要为每个类单独写序列化逻辑。

3.4 垃圾回收机制:告别手动 delete

C++ 程序员最头疼的是什么?内存泄漏。UE 的垃圾回收(GC)机制就是为了解决这个问题。但它不是 Java 那种全自动 GC,而是基于引用计数的可达性分析。

核心原理很简单:

  • 每个 UObject 有一个内部引用计数器
  • 当引用计数为 0 时,对象会被标记为待回收
  • GC 每帧或每隔一段时间执行一次,清理标记的对象

但这里有个坑:普通 C++ 指针不会被 GC 追踪。你必须用 UPROPERTY 标记,或者用 TObjectPtr、TStrongObjectPtr 等智能指针。

避坑指南:我曾经犯过一个错误——在 UObject 里用普通 C++ 指针指向另一个 UObject,结果那个对象被 GC 回收了,我还傻乎乎地继续用,直接崩溃。记住:UObject 之间的引用,必须用 UPROPERTY 标记

正确的做法:

UCLASS()
class UMyGameInstance : public UGameInstance
{
    GENERATED_BODY()

public:
    // 正确:UPROPERTY 标记,GC 会追踪
    UPROPERTY()
    UMyObject* MyObject;

    // 错误:普通指针,GC 不会管
    UMyObject* MyObjectUnsafe;
};

3.5 GC 的触发时机与手动控制

GC 不是实时触发的。默认情况下,它会在以下时机执行:

  • 每帧结束后(默认频率)
  • 显式调用 CollectGarbage()
  • 内存压力过大时

我个人建议,在性能敏感的场景下,比如游戏运行时频繁创建销毁对象,可以手动控制 GC 时机:

// 强制立即执行 GC
GetWorld()->ForceGarbageCollection(true);

// 或者延迟几帧再执行
GetWorld()->DelayGarbageCollection(0.5f);

但要注意,频繁调用 GC 会导致卡顿。我见过一个项目,每帧都调用 ForceGarbageCollection,结果帧率直接掉到 20。后来改成每 10 秒一次,问题就解决了。

3.6 对象生命周期管理

除了 GC,UE 还提供了几种生命周期管理方式:

方式 说明 适用场景
UPROPERTY 引用 GC 自动追踪 大多数情况
TObjectPtr<> 现代 UE 推荐的智能指针 替代原始 UObject*
TStrongObjectPtr<> 强引用,阻止 GC 需要确保对象存活
TWeakObjectPtr<> 弱引用,不阻止 GC 避免循环引用

这里有个经验之谈:能用 TObjectPtr 就别用原始指针。UE5 开始,TObjectPtr 已经成为默认推荐,它比原始指针更安全,而且支持延迟加载。

个人习惯:我在写新代码时,所有 UObject 指针都用 TObjectPtr。只有在需要兼容旧代码或性能极端敏感的地方,才用原始指针。

3.7 总结与避坑清单

好,咱们把这一章的核心点串一下:

  • UObject 是根基:需要引擎管理的对象,就继承它
  • 反射靠宏:UCLASS、UPROPERTY、UFUNCTION 不是装饰,是接口
  • GC 不是万能的:它只追踪 UPROPERTY 标记的引用
  • 智能指针更安全:TObjectPtr 是未来趋势

最后,分享几个我踩过的坑:

  • 我曾经在构造函数里访问其他 UObject,结果崩溃——因为 GC 还没初始化完成。记住:构造函数里别做复杂操作。
  • 我曾经用 TArray<UObject*> 存了一堆对象,结果全被 GC 回收了——因为 TArray 不会自动追踪 UPROPERTY。必须用 UPROPERTY() TArray<TObjectPtr<UObject>>。
  • 我曾经在蓝图中创建了一个 UObject,但没保存引用,结果它瞬间被 GC 回收——嗯,蓝图的变量默认是弱引用。

这些坑,你迟早会遇到。提前了解,能省不少调试时间。

下一章,咱们聊聊 Actor 和 Component 的生命周期,以及 Tick 的正确使用姿势。到时候见。