第02章 · UObject:引擎的对象世界观

📑 目录

导语:凌晨三点,「回响峡谷」的打包版又崩了:玩家打开第十七个宝箱时直接闪退,崩溃栈指向一段三周没动过的背包代码。真凶不在栈里——是一个两周前新建的数据对象,身上没有任何"登记过的引用",被垃圾回收顺手清走,而它留下的那块内存当晚已经住着别人。UE 里相当比例的疑难崩溃,最后都能追溯到同一件事:没搞懂引擎把对象当什么。本章把这件事讲透。

本章地图

mindmap
  root((UObject 世界观))
    为什么不用裸 C++ 对象
      反射换来的四样东西
      序列化 / GC / 编辑器 / 蓝图
    对象家谱
      UCLASS 类
      USTRUCT 结构体
      UENUM 枚举
      UINTERFACE 接口
    垃圾回收
      反射驱动的可达性分析
      标记-清除
      Root Set 与引用图
    序列化与资产
      Package 与 .uasset
      CDO 类默认对象
    智能指针的边界
      TObjectPtr
      TWeakObjectPtr
      TSharedPtr 家族管不到这里
    验尸报告
      悬空指针
      GC 误杀
      所有权混乱

2.1 为什么 UE 不用裸 C++ 对象:反射换来的四样东西

裸 C++ 对象对引擎来说是隐形的。new 出来的实例,编辑器看不见,序列化系统摸不着,GC 不知道它引用了谁。在普通 C++ 项目里这无所谓,在游戏引擎里这是残疾:场景要存盘、关卡要编辑、策划要在面板上拖数值、蓝图要调函数,这些全都要求引擎"认识"每一个类型的长相。

C++ 编译完成后类型信息几乎被抹光,这是它和 Java/C# 的本质差别——后两者的虚拟机在运行时握着完整类型元数据,反射是免费的。UE 的解法是代码生成:Unreal Header Tool(UHT)在编译前扫描头文件里的 UCLASS/UPROPERTY/UFUNCTION 宏,生成 *.generated.h 里的反射注册代码,把类型信息在运行期"补回来"。这套元数据是整栋大楼的地基,它换来四样东西。每一样都值得反问一句:没有它会怎样。

第一样:序列化。 有了反射,引擎知道每个类有哪些字段、各是什么类型,存盘读盘就是按元数据自动搬运字节。没有它,就得给每个类手写读写函数,像维护一套没有 schema 工具的私有 protobuf:500 个类,每改一个字段都要同步改存档代码,旧存档兼容全靠人脑记。「回响峡谷」的存档系统一行序列化代码都没写,靠的就是 UPROPERTY(SaveGame) 这一个说明符。

第二样:垃圾回收。 GC 要做可达性分析,前提是知道"谁引用了谁"。反射元数据恰好就是这张引用图的图纸。没有它,只剩两条路:引用计数(处理不了循环引用,见 2.3 的公式)或者手动 delete(悬空指针地狱)。2.6 的验尸报告会展示后者的样子。

第三样:编辑器集成。 细节面板(Details Panel)能列出 UPROPERTY 让你直接编辑、把资产拖进槽位,靠的还是反射。没有它,每个可配置字段都要手写 UI 代码,策划想改把武器的伤害值得排队等程序改代码、重编译、重打包——在 50 人团队里,这条队伍会排到下个里程碑。

第四样:蓝图互通。 UCLASS(Blueprintable)UFUNCTION(BlueprintCallable),蓝图就能看见并调用 C++ 的类和函数。没有它,所有玩法逻辑都得 C++ 写死,关卡设计师摆个触发器都要提工单。第 4 章会专门谈 C++ 与蓝图的分工协议,地基就是这里打下的。

这四样东西是互相咬合的齿轮:编辑器面板改的值,靠序列化存进资产;资产加载重建的对象,进 GC 的引用图决定生死;蓝图挂的逻辑,最终也落在同一套对象模型上。所以"反射"在 UE 里是对象存在的形式本身——一个类型要么在反射体系内,享受全部四样服务并缴纳 GC 扫描的税;要么在体系外,当纯 C++ 用,两不相欠。半只脚踩进来(比如继承 UObject 却不挂宏),才是事故的高发姿势。

补一个常被忽略的实操点:UHT 在什么时候跑?每次编译前,Unreal Build Tool(UBT)先检查哪些头文件变了,对变更的头文件调用 UHT,生成的代码落在工程的 Intermediate 目录下,随后才进入真正的 C++ 编译。这也是为什么改了 UPROPERTY 说明符后有时要重编才能生效——说明符的语义一半在反射数据里,一半在编辑器读到的缓存里。另一个常见误会:这些宏在 C++ 编译器眼里是空——UCLASS(...) 括号里的说明符展开后为零,它们纯粹是写给 UHT 的"情书"。理解了这点,就不会再在说明符拼错时对着编译器的沉默发呆——编译器根本不检查它,只有 UHT 和编辑器会理它。

一句话总结本节:宏是装饰?说到底是登记。UCLASS 把类登记给引擎,UPROPERTY 把字段登记给序列化和 GC,UFUNCTION 把函数登记给蓝图。漏了登记,对象就活在引擎的法治之外——平时看不出来,出事就是凌晨三点那种事故。

2.2 UCLASS/USTRUCT/UENUM 的家谱

所有引擎对象的根是 UObject。每一个活着的 UObject 实例都登记在全局对象表 GUObjectArray 里,分到唯一索引——这个索引就是对象的身份,弱引用、网络复制、序列化里的对象引用,底层都是它。

classDiagram
    UObject <|-- AActor
    UObject <|-- UActorComponent
    UObject <|-- UGameInstance
    UObject <|-- USaveGame
    UObject <|-- UDataAsset
    AActor <|-- APawn
    APawn <|-- ACharacter
    UActorComponent <|-- USceneComponent
    UActorComponent <|-- UCharacterMovementComponent
    class UObject {
        +GetClass() UClass*
        +GetOuter() UObject*
        +GetName() FString
        +Serialize(FArchive&)
        +BeginDestroy()
    }
    class AActor {
        +GetWorld() UWorld*
    }

家谱里有几条主干要分清。AActor 是能放进关卡、有变换、能 tick 的对象;UActorComponent 挂在 Actor 身上,负责把功能拆成可复用的块;还有一整支"非场景"对象——资产、配置、UGameInstanceUSaveGame——它们不在世界里,但同样受 GC 和序列化管辖。判断一个类该不该继承 UObject,只看一个问题:它需要被引擎的反射/序列化/GC 体系看见吗?需要,就进来;不需要,用普通 C++ 类,别来占全局表的位置。

每个 UObject 还有一个 Outer 指针,指向它的容身之所,层层向外直到包(UPackage)。GetPathName() 打出来的长名字——/Game/Maps/Canyon.Canyon:PersistentLevel.BP_TreasureChest_3——就是这条 Outer 链的完整路径。这套命名就是资产系统和编辑器定位对象的基础。

USTRUCT 是另一条路:值语义,像普通 C++ struct 一样按值拷贝,不进 GUObjectArray,不参与 GC。它里面的 UPROPERTY 字段会被持有它的宿主(某个 UObject 或容器)顺带扫描和序列化,但结构体自己没有身份。FVectorFRotator 都是 USTRUCTUENUM 更简单,登记一下让编辑器和蓝图认得枚举值。UINTERFACE 则是个两班倒的设计:U 前缀的类只做反射壳,I 前缀的类写真正的纯虚接口,C++ 侧按接口编程,蓝图侧靠元数据认门。

反过来说,有些东西就不该进这棵树。一次命中检测的中间结果、一帧内用完的临时几何数据、纯算法 helper——这些对象如果继承 UObject,每个实例都要在全局表里占一个索引、被 GC 扫描一轮、被反射系统登记一遍,量大了全是纯税。「回响峡谷」的伤害结算流水线里,中间态的伤害上下文就是普通 C++ struct,帧末即焚,引擎管不着也不用管。经验法则:一个类型需要出现在编辑器面板、蓝图图、存档或资产文件里,才配姓 U。

UINTERFACE 的两班倒设计值得多一句嘴:蓝图能实现 I 前缀的接口函数(配合 BlueprintNativeEventBlueprintImplementableEvent),C++ 侧则按普通接口调用,两边在反射层会师。跨系统解耦——比如伤害系统不依赖具体角色类、只认"可受伤"接口——就靠它。

下面是一个最小但五脏俱全的 UCLASS,「回响峡谷」的背包物品原型就长这样。它解决的问题:一个纯数据对象,要能被编辑器配置、被蓝图读写、被存档系统带走。

UCLASS(Blueprintable)
class ECHOES_API UEchoInventoryItem : public UObject
{
    GENERATED_BODY()

public:
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Item")
    FName ItemId;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Item")
    int32 MaxStack = 99;

    UPROPERTY(SaveGame)
    int32 Count = 0;

    UFUNCTION(BlueprintCallable, Category = "Item")
    int32 AddToStack(int32 Delta);

    UPROPERTY()
    TObjectPtr<UTexture2D> Icon;
};

三个要点。GENERATED_BODY() 展开的是 UHT 生成的样板代码,没有它这个类在编译期就过不去。每个 UPROPERTY 的说明符各管一摊:EditAnywhere 给编辑器,BlueprintReadWrite 给蓝图,SaveGame 给存档系统,什么都不写只写 UPROPERTY()——比如 Icon——意思是"登记给 GC 和序列化,但不对外露面"。TObjectPtr 是 5.x 里 UObject 成员指针的标准写法,2.5 细讲。

创建这类对象有两个常用入口,用错场合是新手高发区。NewObject<T>() 是运行时的通用构造,需要一个 Outer 决定容身之所——不指定就挂到临时包(Transient Package)下,这个包里的对象不参与存档、不进关卡文件,适合纯运行时数据。CreateDefaultSubobject<T>() 则只能在构造函数里调用,创建的对象归 CDO 管,所有实例共享这份"默认零件",Actor 身上的组件就是这么挂上去的。判断标准很直白:要随类模板走、每人都该有一份的(碰撞体、网格组件),用 CreateDefaultSubobject;游戏跑起来之后按需产生的(一次拾取、一个运行时 Buff 数据),用 NewObject。混用的典型翻车现场是在构造函数里 NewObject——CDO 构造路径上跑出运行时对象,编辑器里会看到莫名其妙的"幽灵成员"。

2.3 垃圾回收:谁引用谁,谁就被留着

UE 的 GC 规则一句话说完:从根出发,沿着登记过的引用能走到的对象,全部留下;走不到的,清走。难点全在"登记过"三个字上。

先纠正一个流传甚广的误解:UE 的 UObject 管理用的是标记-清除(Mark-and-Sweep)式的可达性分析,和 JVM、CLR 是同一个思想流派,但实现路径截然不同——这正是 UE 的特异处,值得展开。TObjectPtr 不计数,UPROPERTY 不计数,没有任何字段记录"有几个人指着这个对象"——引用计数这套东西,UE 压根没用。

用形式化语言把两种机制摆在一起对比。设对象集合为 OO,登记过的引用关系为有向边 EO×OE \subseteq O \times O,根集为 ROR \subseteq O(引擎内部通过 AddToRoot 标记的对象构成 RR 的种子)。引用计数维护的是每个对象的入度:

rc(o) = \left|\{\, a \in O \mid (a, o) \in E \,\}\right|, \qquad rc(o) = 0 \ \Rightarrow\ \text{回收 } o

可达性分析维护的是从根出发的闭包:

\mathrm{Live}(R) = \{\, o \in O \mid \exists\, r \in R:\ r \xrightarrow{*} o \,\}, \qquad \mathrm{Garbage} = O \setminus \mathrm{Live}(R)

其中 \xrightarrow{*} 表示 EE 的自反传递闭包,也就是"沿引用边走零步或多步可达"。

差别在一个经典病例上见分晓:A 引用 B、B 引用 A,构成环,环上每个对象入度都是 1,引用计数永远到不了 0,内存泄漏;而可达性分析根本不看入度,只看根能不能走到——走不到,环整体进 Garbage\mathrm{Garbage}。这就是 UE 不用引用计数管理 UObject 的核心理由:游戏对象之间的引用关系(Actor 引用组件、组件引用资产、UI 引用数据、数据再引用回 Actor)天然成环,引用计数在这张图面前是无解的。

再看实现路径的差异。Java/C# 的可达性分析,根是线程栈和静态字段,所有引用天然可见,虚拟机扫描的是活内存本身。UE 不一样:C++ 运行期没有类型信息,扫内存无从扫起,所以它的引用图 EE从反射元数据静态推导出来的——UHT 在编译期就为每个类算好了"你的第 N 个字段是指向 UObject 的引用",GC 标记时按这张图纸走。后果非常直接:没有登记为 UPROPERTY(或通过 AddReferencedObjects 申报)的引用,对 GC 是隐形的。Java 程序员眼里"只要还有人引用就不会被回收"的直觉,在 UE 里要改成"只要还有登记过的引用就不会被回收"。这一字之差,就是 2.6 里全部案件的卷宗封面。

和 Rust 的对比是另一个维度。Rust 的所有权在编译期静态裁定,运行时零开销,代价是生命周期必须符合编译器能证明的形状;UE 的选择是运行时动态分析,付出 GC 停顿的成本,换来的是编辑器随时改类、资产随时加载卸载、蓝图随时挂接的灵活性。两种都对,服务的是不同的场景。

标记阶段的流程如下。UE5 的标记支持多线程并行,清除阶段(Purge)增量执行、分帧摊销,避免一次性释放造成卡顿尖峰。

flowchart TD
    A[触发:计时器到期 / 显式调用 CollectGarbage] --> B[锁定增量清除]
    B --> C[从 Root Set 播种标记队列]
    C --> D[按反射元数据遍历对象的 UPROPERTY 引用]
    D --> E{还有未处理的可达对象?}
    E -- 有 --> D
    E -- 没有 --> F[未标记对象判定为不可达]
    F --> G[逐个调用 ConditionalBeginDestroy]
    G --> H[分帧增量清除:释放内存]
    H --> I[对象索引归还 GUObjectArray 复用]

一个对象的一生,对应这张状态图。注意"不可达"不等于"已释放":从断引用到内存真正归还,中间隔着下一次 GC 的标记与清除,这段窗口期就是弱引用返回 nullptr 但对象尸体还在的灰色地带。

stateDiagram-v2
    [*] --> 已创建: NewObject / 包加载
    已创建 --> 可达: 被 UPROPERTY 或根引用
    可达 --> 不可达: 最后一个登记引用断开
    不可达 --> 待销毁: GC 标记阶段判定
    待销毁 --> 已回收: 增量清除释放内存
    已回收 --> [*]
    可达 --> 可达: 引用图变化

标记过程本身,伪代码写出来就是这个形状。真实实现是并行 worklist,细节在 Engine/Source/Runtime/CoreUObject/Private/UObject/GarbageCollection.cpp,但骨架没差。

# 伪代码:可达性标记(简化版,UE 实际为并行 worklist)
def mark_reachable(roots):
    worklist = list(roots)
    for obj in worklist:
        obj.mark()
    while worklist:
        obj = worklist.pop()
        # 反射元数据:这个类的哪些字段是 UObject 引用
        for ref in reflection_refs_of(obj.get_class(), obj):
            target = ref.resolve()
            if target is not None and not target.is_marked():
                target.mark()
                worklist.append(target)
    # 未被 mark 的对象 -> BeginDestroy -> 分帧释放

工程上还有三件事要知道。一是 AddToRoot():把一个对象钉进根集,永远可达,常用于全局管理器,记得配对 RemoveFromRoot(),否则就是手动制造的泄漏。二是 FGCObject 接口:当你的非 UObject 类(比如一个原生 C++ 容器)想让 GC 看见它持有的 UObject 引用,就实现 AddReferencedObjects(FReferenceCollector&) 在标记期把引用申报上去。三是成本量级:中大型项目可达对象在几十万到上百万这个数量级,标记耗时从亚毫秒到数毫秒不等,取决于引用图密度而非对象总数——具体数字以项目实测为准,控制台 stat gcobj list 是观察窗口,词条见官方文档「Garbage Collection」。

还有两件与手感直接相关的事。GC 的触发时机在编辑器和打包版里不一样:编辑器为了尽早暴露悬空引用,回收相当勤快;打包版默认按计时器周期性触发(秒级间隔),也可以在配置里调整或按内存压力触发。后果是同一处悬空指针 bug,编辑器里百发百中,玩家机器上时隐时现——2.6 案件一"打包版高发"的根源就在这。另一个是簇(Cluster)机制:粒子、物理形体这类成百上千、生命周期同步的对象,引擎把它们捆成一簇,标记时以簇为单位走,避免逐对象遍历的开销。这是引擎内部的优化,业务代码一般接触不到,但读 GC 源码时会迎面撞上,先打个照面。

最后纠正一个考古名词:老代码和老教程里的 MarkPendingKill()/PendingKill,在 5.x 里已更替为 MarkAsGarbage() 语义体系,见到旧称知道是同一回事即可,新代码按官方文档「Unreal Object Handling」词条的当前写法来。

2.4 序列化与资产:磁盘上的 .uasset 和内存里的对象

磁盘上的一个 .uasset 文件,对应内存里的一个包(UPackage)。包里不止一个对象:头部是导入表(Import Map,列出本包依赖的外部对象)和导出表(Export Map,列出本包包含的对象),一张蓝图资产的包里有类、有函数、有默认对象,一张关卡的包里躺着整个世界大纲。UPackage 是所有成员的 Outer,包本身也是一个 UObject。

加载一个关卡时,反射和序列化是这样配合的:

sequenceDiagram
    participant E as 加载请求方
    participant L as FLinkerLoad
    participant P as UPackage
    participant C as UClass 与 CDO
    participant O as 关卡对象树
    E->>L: LoadPackage(/Game/Maps/Canyon)
    L->>L: 读头部:导入表 / 导出表
    L->>P: 创建 UPackage 作为全体成员的 Outer
    L->>C: 先加载所需的 UClass 和 CDO
    C-->>L: 返回类模板与默认值
    L->>O: 按导出表逐个 NewObject(Outer = Package)
    L->>O: 逐对象 Serialize:字节流灌进 UPROPERTY
    O-->>E: 返回根对象,全员登记进 GUObjectArray
    Note over O: 从此进入可达性图
生死由引用关系决定

中间有个绕不开的角色:类默认对象(Class Default Object, CDO)。每个 UClass 在加载时都会创建一份 CDO,作用有二——NewObject 时它是属性初始值的模板,编辑器里改蓝图默认值改的也是它(及其子对象链)。关卡里那个宝箱 Actor 出生时的网格体、碰撞预设,全是从 CDO 拷贝出来的默认值再被序列化数据覆盖的结果。UPROPERTY 说明符里,Transient 表示"不序列化",SaveGame 表示"进存档流",其余的默认参与普通序列化——字段布局由反射决定,所以蓝图继承、资产重定向(Redirector)、跨版本兼容这些高级货,全是元数据上的上层建筑。

顺带把两种"不着急加载"的引用记住:TSoftObjectPtr<T> 只存资产路径,不触发加载、不构成保活引用,需要时再 LoadSynchronous() 或走 FStreamableManager 异步加载;TSubclassOf<T> 是带类型约束的类引用,常用于"配置一个可生成的类型"。TSoftObjectPtr 是大地图和 MMO 的常客——「远港 Online」的物品图标、技能特效全走软引用,不然启动就把整个资产库拖进内存,第 16 章的流送预算会当场爆炸。

底层机制上还有一个设计值得记住:UObject::Serialize(FArchive&) 一个函数同时管读和写。FArchive 是个带方向的抽象流——IsLoading() 为真时把字节灌进字段,为假时把字段写进字节,同一段代码跑两个方向。这个对称设计消除了"读写函数不同步"这一整类 bug,代价是函数里不能写只对单方向成立的逻辑;需要方向相关行为时,用 Ar.IsLoading() / Ar.IsSaving() 显式分支。自定义序列化行为(比如版本迁移时手工修数据)也是重写这个函数。

序列化不是免费午餐,有两笔账要提前知道。一笔是版本兼容:字段布局变了(删字段、改类型),旧存档读进来会发生什么?引擎按自定义版本号(Custom Version)和反射元数据尽量对齐,但对齐不了的字段会静默走默认值——线上项目必须为存档格式演进写升级代码,「远港 Online」的背包结构改版时这件事专门排过期。另一笔是资产改名搬家的代价:对象路径(Outer 链加名字)就是资产的身份证,移动资产后引用它的包会留下重定向器(Redirector)这种"替身对象"维持链接,积多了既拖慢加载又污染引用图,定期在编辑器里执行 Fix Up Redirectors 是项目卫生的一部分。

2.5 智能指针三兄弟:TSharedPtr/TWeakPtr/TObjectPtr 的边界

UE 里有两套互不兼容的内存管理体系,划界的标准只有一条:管的是不是 UObject

UObject 世界由 GC 管辖,所有权语义是可达性;非 UObject 世界(Slate 控件、自定义数据结构、异步任务的负载)由 TSharedPtr/TSharedRef/TWeakPtr 这组引用计数指针管辖。两套体系不能跨界:TSharedPtr<UObject> 在编译期就被静态断言拦下,TObjectPtr 也管不了普通 C++ 对象。选型的混乱基本源于没看清这条边界。

quadrantChart
    title "指针选型地图:管辖世界 × 保活语义"
    x-axis "不保活" --> "保活"
    y-axis "非UObject" --> "UObject"
    quadrant-1 "保活且管 UObject"
    quadrant-2 "观察 UObject"
    quadrant-3 "观察非 UObject"
    quadrant-4 "保活且管非 UObject"
    "TObjectPtr(UPROPERTY内)": [0.85, 0.88]
    "AddToRoot": [0.95, 0.65]
    "TWeakObjectPtr": [0.15, 0.85]
    "TSoftObjectPtr": [0.25, 0.72]
    "TSharedPtr": [0.85, 0.15]
    "TSharedRef": [0.95, 0.30]
    "TWeakPtr": [0.12, 0.18]

先看内存里这张引用图长什么样——这是整章的"犯罪现场平面图",后面三份验尸报告都要回到这张图。

Root SetAddToRoot 钉住的对象UPackage(关卡包)全体成员的 OuterAActor 宝箱UPROPERTY 成员链UActorComponent存活UI 槽位TWeakObjectPtr 观察UEchoItem(孤儿)无任何入边 → 待回收引用已断开实线 = UPROPERTY 强引用(参与保活) 虚线 = 弱引用(只观察) 灰框虚线 = 不可达对象,下一次 GC 清除

TObjectPtr<T> 是 5.x 的主力写法。在编辑器里它带延迟解析和加载追踪,打包后退化成一个裸指针的开销——但注意,它的保活能力不来自自身,而来自所在的 UPROPERTY:写在裸字段里的 TObjectPtr 和裸指针一样对 GC 隐形。TWeakObjectPtr<T> 是观察者:不保活,目标被回收后 IsValid() 返回 false、Get() 返回 nullptr,UI、缓存、跨帧回调都该用它。

// 旧式写法(4.x 遗留):裸指针成员,全靠 UPROPERTY 兜底
UPROPERTY()
UTexture2D* IconLegacy;

// 5.x 标准写法:TObjectPtr,编辑器下可延迟解析
UPROPERTY()
TObjectPtr<UTexture2D> Icon;

void UUIItemSlot::RefreshIcon(TObjectPtr<UTexture2D> NewIcon)
{
    // 跨帧观察:弱引用,不保活,防悬空的唯一正确姿势
    IconWeak = NewIcon;

    GetWorld()->GetTimerManager().SetTimerForNextTick([this]()
    {
        // 回调到达时目标可能已被回收,必须先检查
        if (UTexture2D* Icon = IconWeak.Get())
        {
            ImageBrush.SetResourceObject(Icon);
        }
    });
}

TSharedPtr 家族这边也有自己要还的债。它是 2.3 公式里的引用计数路线,环的问题原样奉还:两个 TSharedPtr 互相持有,谁也别想归零,泄漏悄然而生。解法也是教科书式的——环上至少有一条边降级为 TWeakPtr,用的时候 Pin() 提升回强引用,提升失败就是对方已死。这套手法和 Rust 的 Rc/Weak、Objective-C 的 strong/weak 完全同构,熟任何一边都能直接迁移。Slate 界面代码里大量父子控件关系就是这么处理的:父持有子是 TSharedPtr,子回望父是 TWeakPtr

四种常用指针的边界收进一张表,比背规则可靠:

指针类型管辖世界是否保活空态检查典型用途
TObjectPtr<T>(配 UPROPERTYUObject直接判空成员变量、资产引用
TWeakObjectPtr<T>UObjectIsValid() / Get()UI 观察、缓存、异步回调
TSharedPtr<T> / TSharedRef<T>非 UObject判空 / 保证非空Slate、原生数据结构
TWeakPtr<T>非 UObjectPin()打破 SharedPtr 循环引用

选型规则一句话:成员变量用 UPROPERTY + TObjectPtr;局部临时变量用裸指针或 TObjectPtr 皆可,作用域内 GC 不会动栈上引用链可达的对象;跨帧观察一律 TWeakObjectPtr;非 UObject 才轮到 TSharedPtr 家族登场。

2.6 常见崩溃案发现场:三份验尸报告

下面三份报告全部改写自真实项目的崩溃单。格式统一:崩溃栈特征 → 死因 → 预防。认熟这三种死状,能躲开 UE 内存问题的大头。

为什么偏偏是这三种?因为它们对应着这套世界观的三条基本法:引用必须登记(违了是案件一)、生死归 GC 裁定(违了是案件三)、而 GC 的裁定是异步的(没适应这个节奏就是案件二)。几乎所有 UObject 相关的内存事故,都是这三条基本法某一条的变体。所以读报告时不要只记症状,要认出每条症状背后那条被违反的法。

案件一:宝箱里的幽灵指针(悬空指针)

崩溃栈特征:EXCEPTION_ACCESS_VIOLATION reading address 0x0000000000000018,或一次虚函数调用跳到了乱码地址。崩溃点每次都不一样,经常落在完全无辜的代码里——背包逻辑、动画通知、甚至引擎内部。不可稳定复现,打包版高发,编辑器里几乎不出现。

死因:某个原生 C++ 指针成员(没挂 UPROPERTY)存着一个 UObject。对象被 GC 正常回收,内存块随后被别的对象复用,老指针成了野指针。读到的"虚表指针"其实是新住户的字段数据,一跳就死。崩溃点离案发现场远,是因为访问发生的时间和回收发生的时间毫无关联。开篇那个凌晨三点的事故,就是它。

预防:成员变量一律 UPROPERTY + TObjectPtr,没有例外。原生容器(std::vector<UObject*> 这类)想保活,宿主实现 FGCObject::AddReferencedObjects 申报引用。代码审计的最小手段:全工程正则搜裸指针成员 U[A-Z]\w+\* 落在非 UPROPERTY 字段上的,逐个过堂。

案件二:异步回调里的空世界(GC 误杀与弱引用未检查)

崩溃栈特征:栈非常干净,崩溃在回调第一行——GetWorld()-> 返回空后解引用,或 TWeakObjectPtr::Get() 之后没判空直接用。日志往前翻,能看到 LogStreaming 的卸载记录。必现于特定时序点:切地图后几秒、读档完成瞬间。

死因:两种变体。一是回调触发时目标对象已随世界一起卸载,弱引用该做的检查没做;二是 NewObject 出的临时对象只被栈上变量持有,异步操作横跨了几个 GC 周期,回调到达前对象已被收走——这就是"GC 误杀",杀得合法,死得冤枉。「远港 Online」早期版本里,玩家头像异步加载完成后回调一个已销毁的界面控件,崩得很有节奏感。

预防:回调入口第一行永远是 IsValid()Get() 判空,养成肌肉记忆。需要跨帧存活的对象,挂到稳定的 Outer 下(UGameInstance 是常用锚点)或由 UPROPERTY 成员持有。定时器回调用 FTimerManager 的弱对象绑定接口,对象死了定时器自动失效,比裸 lambda 安全。

案件三:两个管家抢一套房(所有权混乱)

崩溃栈特征:崩在 GC 扫描阶段,栈帧里能看到 FRealtimeGCMarkObjects,访问的是非法虚表。或者:某处 delete 了一个 UObject 之后,下一次 GC 必定崩溃。

死因:手动 delete 了 UObject,或把 UObject 交给 TUniquePtr/std::unique_ptr 托管。对象内存被提前释放,但 GUObjectArray 里的登记还在,GC 按名单来扫描时摸到的是尸体。等于物业 A(GC)还挂着门牌,物业 B(手动 delete)已经把房子拆了。

预防:铁律——UObject 的生死只归 GC,禁止 delete、禁止任何智能指针托管。想销毁 Actor,调 Destroy(),那是"申请回收",实际释放仍由 GC 走流程。想让普通 UObject 提前死,断掉所有引用等 GC 来收,或用 MarkAsGarbage()(5.x 里的正式接口,对应官方文档「Unreal Object Handling」词条)显式宣告放弃。

三份报告之外,留一套趁手的勘查工具。控制台 obj refs name=<对象名> 能打印一个对象当前被谁引用——查"为什么它没被回收"和"为什么它被回收了"都从这里下手;obj gc 手动触发一轮完整 GC,配合日志复现案件一的现场;memreport 导出内存报告,对象计数一节能看到各类 UObject 的数量水位。开发生成期(Development 配置)下引擎还会做额外的引用校验,很多悬空问题在这一档就提前炸出来——所以打包版复现的崩溃,回 Development 配置里重跑一遍,往往能把案发时间提前到最方便抓现行的位置。把这些命令用熟,下一次凌晨三点的电话,接通时心里就有勘查路线了。

小结

宏即登记:UCLASS/UPROPERTY/UFUNCTION 把类型信息登记给引擎,换来序列化、GC、编辑器集成、蓝图互通四样东西;漏登记的对象活在法治之外,出事就是深夜事故。UE 的 GC 是反射驱动的可达性分析,不是引用计数——引用图在编译期由 UHT 生成的元数据静态推导,运行期按图标记,这是它与 Java/C# 同宗不同路、与 Rust 各走一端的特异处。保活的唯一判据是"登记过的引用链可达",裸指针成员、栈外临时持有、未申报的原生容器,都是 GC 盲区。.uasset 即包,包即对象树;CDO 是所有实例的默认值模板;软引用解决"想用但不想加载"。指针选型先看世界:UObject 归 TObjectPtr/TWeakObjectPtr,非 UObject 归 TSharedPtr 家族,两套体系禁止跨界。

上手任务

  1. 建一个受管对象。 在「回响峡谷」工程里创建 UEchoInventoryItem(照 2.2 的示例),编辑器里基于它建一个数据资产。验收:细节面板能编辑 ItemId/MaxStack;PIE 中 NewObject 一个实例,用 TWeakObjectPtr 观察,置空全部引用后在控制台执行 obj gcIsValid() 应变为 false。
  2. 制造并侦破一次悬空指针。 故意写一个无 UPROPERTY 的裸指针成员持有该对象,强制 CollectGarbage() 后访问它。验收:复现到访问违例(或读到垃圾数据);补上 UPROPERTY 后循环强制 GC 100 次不再出错。
  3. 读懂 GC 的账单。 控制台分别执行 obj liststat gc,记录对象总数与标记/清除耗时。验收:能说清当前工程可达对象的数量级,以及一次 GC 的标记耗时是否超过 1ms;超过的话用 obj gc 手动触发对比增量清除的摊销效果。

下一章

UObject 是树根,下一章看这棵树上最忙的七根枝:GameMode、GameState、PlayerController、PlayerState、Pawn、Character、HUD——一局游戏的职责地图,90% 的新手 bug 来自把代码放错类。

延伸阅读

  • 官方文档:Unreal Engine → Programming →「Unreal Object Handling」与「Garbage Collection」词条,概念与本章一一对应。
  • 官方文档:Unreal Engine → Programming →「Smart Pointer Library」,TSharedPtr 家族的完整语义。
  • 源码:Engine/Source/Runtime/CoreUObject/Private/UObject/GarbageCollection.cpp,标记-清除的真实实现,对照 2.3 的伪代码读。
  • 源码:Engine/Source/Runtime/CoreUObject/Public/UObject/ObjectMacros.h,所有宏说明符的定义处,比任何二手整理都权威。
  • 源码:Engine/Source/Programs/UnrealHeaderTool/,反射元数据的出生地,想知道 UHT 生成了什么就从这里入手。