UE Spawn出来的Actor的生命周期和管理方法

UE SpawnActor 的 Actor 生命周期全解析

本文目标 :彻底搞清楚一个问题------当你 SpawnActor 出来一个 Actor,它的生命由谁来管?

这个问题看似简单,但里面涉及 World 引用、GC、UPROPERTY、TWeakObjectPtr、PendingKill、关卡卸载等一串机制。本文以"层层追问"的方式逐步展开,每一节都对应一个真实会产生疑惑的场景。


目录

  1. [认知起点:Spawn 出来的 Actor 谁在持有它](#认知起点:Spawn 出来的 Actor 谁在持有它)
  2. [Actor 的完整生命周期全景图](#Actor 的完整生命周期全景图)
  3. [缓存 Actor 引用的三种写法------哪种对哪种错](#缓存 Actor 引用的三种写法——哪种对哪种错)
  4. [UPROPERTY 强引用遇上关卡卸载会发生什么](#UPROPERTY 强引用遇上关卡卸载会发生什么)
  5. [Spawn 出来的 Actor 什么时候该用 UPROPERTY 强引用](#Spawn 出来的 Actor 什么时候该用 UPROPERTY 强引用)
  6. [World 销毁时 Actor 必死------UPROPERTY 抢不回它](#World 销毁时 Actor 必死——UPROPERTY 抢不回它)
  7. [实战 Checklist 与总结](#实战 Checklist 与总结)

一、认知起点:Spawn 出来的 Actor 谁在持有它

1.1 先破除一个常见误解

很多人一开始都以为 Actor 和普通 UObject 一样------"没人引用就被 GC 掉"。这是错的。

真相是:

Actor 的生命周期由它所在的 World(关卡)负责管理。只要 Actor 还属于某个 World,它就不会被 GC 回收------哪怕你代码里没有任何变量引用它。

所以下面这段代码是安全的

cpp 复制代码
void SomeFunc(UWorld* World)
{
    AMyActor* Actor = World->SpawnActor<AMyActor>();
    // 函数结束,Actor 变量出作用域
    // 但 Actor 不会被 GC!因为 World 持有它
}

1.2 谁在持有 Actor?------ULevel.Actors 数组

这是 Actor 最根本的"户口"。SpawnActor 的关键一步是把 Actor 登记到当前 Level 的 Actors 数组里:

cpp 复制代码
// 简化伪代码
AActor* UWorld::SpawnActor(...)
{
    AActor* NewActor = 创建出来;

    ULevel* Level = GetCurrentLevel();
    Level->Actors.Add(NewActor);  // ★ 登记到关卡

    return NewActor;
}

ULevel::Actors 是带 UPROPERTYTArray<AActor*>------GC 扫到这里就认为数组里每个 Actor 都可达。

1.3 完整的保命引用链

复制代码
Root Set
   │
   ▼
GEngine / GWorld / GameInstance          ← 进程级根
   │ UPROPERTY
   ▼
UWorld                                    ← 当前世界
   │ UPROPERTY
   ▼
ULevel                                    ← 关卡
   │ UPROPERTY TArray<AActor*> Actors
   ▼
AMyActor 实例                             ← ✓ 你 Spawn 出来的 Actor
   │ UPROPERTY
   ▼
Actor 自己的各种 Component                ← ✓ 一起被保护

这条链的意义:你 Spawn 出来的 Actor 不需要用任何变量保存引用,也不会被回收------它有 World 做监护人。这是它和普通 UObject 最本质的差别。


二、Actor 的完整生命周期全景图

接下来看完整生命周期。Actor 一生要走四个阶段:

复制代码
┌──────────────────────────────────────────────────────────────────┐
│                     Actor 完整生命周期                            │
├──────────────────────────────────────────────────────────────────┤
│                                                                  │
│  1. SpawnActor()  ─────────────────────────────────────▶         │
│                        创建阶段                                   │
│          ├── PostSpawnInitialize                                │
│          ├── 注册组件                                            │
│          ├── 加入 ULevel.Actors 数组  ★ 关键                    │
│          └── 构造函数、PostInitializeComponents                 │
│                                                                  │
│  2. BeginPlay()  ──────────────────────────────────────▶         │
│                        进入游戏阶段                               │
│          (Tick、交互、复制、Ai、输入......)                            │
│                                                                  │
│  3. Destroy() 或 World 关闭 ───────────────────────────▶         │
│                        标记销毁阶段                               │
│          ├── 标记为 PendingKill                                 │
│          ├── 从 ULevel.Actors 移除  ★ 断开 World 引用            │
│          └── EndPlay(EndPlayReason)                             │
│                                                                  │
│  4. 下一帧或下次 GC  ──────────────────────────────────▶         │
│                        GC 回收阶段                                │
│          ├── 发现已无任何 UPROPERTY 可达路径                     │
│          ├── 所有 UPROPERTY 指向它的指针 → 自动置 nullptr        │
│          ├── BeginDestroy / FinishDestroy                       │
│          ├── ~AActor()                                          │
│          └── 内存释放回 GMalloc 池                              │
│                                                                  │
└──────────────────────────────────────────────────────────────────┘

两个关键分界点:

  • BeginPlay 之前:Actor 在创建流程里,尚未进入游戏
  • Destroy 调用后、GC 完成前:Actor 处于"业务已死、内存还在"的中间态

理解了这两个分界点,后面所有的疑问都能自洽地解释。


三、缓存 Actor 引用的三种写法------哪种对哪种错

日常写代码最常见的操作之一:把一个 Actor 指针存到自己的成员变量里。 这里有三种写法,看起来像,实际差别巨大。

3.1 先给速查表

方式 GC 能看见吗 对象是否被保护 销毁时指针变化 安全吗
裸指针 AEnemy* ❌ 看不见 ❌ 不保护 野指针 ❌ 危险
UPROPERTY 强引用 ✅ 看得见 阻止对方 GC 自动置 nullptr ✅ 但"延长寿命"
TWeakObjectPtr<AEnemy> ✅ 看得见 不阻止对方 GC IsValid 返回 false ✅ 不干涉寿命

三种方式的核心差别在两件事:

  1. GC 能不能扫到这条引用
  2. 这条引用算不算"强持有"(要不要阻止对方被 GC)

3.2 裸指针为什么不能用

cpp 复制代码
AEnemy* CachedEnemy;  // ❌ 没 UPROPERTY 的裸指针

问题有两层:

问题一:GC 看不见这条引用

GC 靠反射系统扫描 UPROPERTY 成员构建引用图。没 UPROPERTY,反射系统完全不知道这个指针存在。

后果一 :对方没人强引用时会被 GC 回收,你的裸指针变野指针。
后果二:即使对方被销毁,GC 也不会帮你把这个裸指针置空------因为它根本不知道这个指针存在。

问题二:你没任何手段知道对方还活没活

cpp 复制代码
SomeOtherActor->EnemyRef = Enemy;  // 别处 UPROPERTY 持有
Controller->CachedEnemy = Enemy;   // 裸指针也指过来

Enemy->Destroy();
// SomeOtherActor->EnemyRef → GC 后变 nullptr
// Controller->CachedEnemy → 还"指着"那块内存 → 崩

结论:裸指针持有 UObject = 定时炸弹,绝对不能这么写。

3.3 UPROPERTY 强引用------安全但"要负责"

cpp 复制代码
UPROPERTY()
AEnemy* CachedEnemy;

它解决了裸指针的两个问题:GC 能看见、对方销毁时会被自动置 nullptr。

但它带来一个新特性:阻止对方被 GC 回收。

UPROPERTY 强引用 = "我持有这个对象" 的宣言。只要你握着引用不放:

  • GC 标记时能从你这里到达 Enemy → Enemy 可达、不回收
  • 即使 Enemy 被 Destroy() 标记 PendingKill,只要你的 UPROPERTY 还在,它的内存就不会被释放

这是一把双刃剑:它保证了你访问的安全,但也"延长"了对象寿命。

3.4 TWeakObjectPtr 弱引用------纯观察者

cpp 复制代码
TWeakObjectPtr<AEnemy> CachedEnemy;

它和 UPROPERTY 的核心区别:不阻止对方被 GC

工作原理是"索引 + 序列号":

cpp 复制代码
// 简化伪代码
template<typename T>
class TWeakObjectPtr
{
    int32 ObjectIndex;         // 在 GUObjectArray 的位置
    int32 ObjectSerialNumber;  // 对象被分配时的序列号

    bool IsValid() const
    {
        auto* Slot = GUObjectArray.GetObjectByIndex(ObjectIndex);
        return Slot && Slot->GetSerialNumber() == ObjectSerialNumber;
    }
};

对方被 GC 掉后,序列号对不上了,IsValid() 返回 false。你完全不影响对方的生命周期,对方死了你自动知道。

3.5 三种方式的"哲学差异"

抛开技术细节,从设计语义上它们分别代表:

复制代码
┌─────────────────────────────────────────────────────┐
│  裸指针            "我以为我可以用,但 GC 不管我"      │
│  → 实质:未定义行为                                   │
├─────────────────────────────────────────────────────┤
│  UPROPERTY        "我持有它,它活不活我说了算"        │
│  → 所有权语义                                         │
├─────────────────────────────────────────────────────┤
│  TWeakObjectPtr   "我只是观察它,它死我跟着失效"       │
│  → 观察者语义                                         │
└─────────────────────────────────────────────────────┘

所以写代码前先问自己:

  • 这个对象没有我的引用就该被销毁吗?→ 弱引用
  • 这个对象就应该由我来持有、我死它才死吗?→ 强引用
  • 既不想持有也不想观察?→ 根本不该存这个指针

四、UPROPERTY 强引用遇上关卡卸载会发生什么

这是理解 Actor 生命周期最微妙的场景,也是很多新手栽跟头的地方。

4.1 关卡卸载时引擎做了什么

关卡卸载(切关卡、关 World、Level Streaming 卸载)时:

复制代码
关卡 Level 要卸载
    │
    ├── 遍历 Level.Actors 里每个 Actor
    │     ├── 对每个 Actor 调用 Destroy()
    │     │     ├── 标记 PendingKill
    │     │     ├── 从 Level.Actors 移除   ★ 断开 World 引用
    │     │     └── EndPlay(LevelTransition)
    │     ▼
    │  全部 Actor 进入 PendingKill 状态
    │
    ▼
Level 自己也被清理,World 对 Level 的引用断开

    ▼
下一次 GC:
    ├── 没人持有的 Actor → 回收
    └── ★ 被你 UPROPERTY 持有的 Actor → 不被回收

4.2 你的 UPROPERTY 会让 Actor 变成什么状态?

场景:

cpp 复制代码
class UMyManager : public UObject
{
    UPROPERTY()
    AEnemy* MyEnemy;   // 强引用持有
};

关卡卸载之后:

  • MyEnemy != nullptr ✓(指针还在)
  • IsValid(MyEnemy) ❌ 返回 false(已经 PendingKill 过了)
  • 内存没被释放(你的 UPROPERTY 还在强引用它)

这就是"僵尸 Actor"状态:内存还占着、业务已经死了。

cpp 复制代码
AEnemy* E = Manager->MyEnemy;

E == nullptr;              // false,指针不是空
IsValid(E);                // false,已经 PendingKill
E->IsPendingKill();        // true
E->GetWorld();             // ⚠️ 可能为 nullptr
E->Tick(DT);               // ❌ 不会自动 Tick,手动调也无意义

能读内存,但 Component 已注销、不在 World 里、不 Tick------它只是尸体。

4.3 内存什么时候真正释放?

只有当你主动清除 UPROPERTY 引用,下一次 GC 才会真正回收:

cpp 复制代码
Manager->MyEnemy = nullptr;  // ★ 清引用

// 下次 GC:
//   GC 扫描发现没人引用 Enemy → 标记不可达 → 回收内存

这意味着:如果你忘了清理 UPROPERTY,它会变成一个典型的内存泄漏模式。 每切一次关卡,你的 Manager 就"收藏"一具尸体,内存持续增长。

cpp 复制代码
class UMyManager : public UObject
{
    // ❌ 泄漏源:持有关卡里的 Actor,但没在切关卡时清理
    UPROPERTY()
    TArray<AEnemy*> AllEnemies;
};

本项目 ReferenceScanner 这类引用追踪工具,要定位的就是这种泄漏------追引用链追到某个 Manager / Subsystem 的容器里,找到忘了清空的那一行。

4.4 UE5 的 Garbage 机制(补充)

UE5 引入了新的 GC 模式:PendingKill(现在叫 Garbage)的对象,GC 会无视 UPROPERTY 直接回收它,相关 UPROPERTY 指针自动置 nullptr。 (合理推论,具体取决于 gc.PendingKillEnabled 等配置)

但这不影响你编码时的原则------缓存外部 Actor 就该用弱引用,这个做法在任何版本下都是对的。


五、Spawn 出来的 Actor 什么时候该用 UPROPERTY 强引用

看到这你应该发现------"用不用 UPROPERTY 强引用"不是个随意的技术选择,而是一个"所有权"声明。 那什么时候该强引用?

5.1 核心判断原则

问自己一句话:如果关卡没了、World 没了、但我还在------这个 Actor 我还想让它活着吗?

  • 想让它活着 → UPROPERTY
  • 不想让它活着(它该死) → TWeakObjectPtr

换个角度:

"销毁这个 Actor 的责任在不在我身上?"

  • 在 → UPROPERTY
  • 不在 → TWeakObjectPtr

5.2 五种典型"该用 UPROPERTY"的场景

场景一:我就是 Spawn 它的人,我负责它的生命周期

cpp 复制代码
class AWeapon : public AActor
{
    UPROPERTY()
    AMuzzleFlashActor* MuzzleFlash;

    void BeginPlay() override
    {
        Super::BeginPlay();
        MuzzleFlash = GetWorld()->SpawnActor<AMuzzleFlashActor>(...);
    }

    void EndPlay(EEndPlayReason::Type Reason) override
    {
        if (IsValid(MuzzleFlash)) MuzzleFlash->Destroy();
        MuzzleFlash = nullptr;
        Super::EndPlay(Reason);
    }
};

场景二:我 Spawn 了一批子 Actor,要级联管理

cpp 复制代码
class ABoss : public AActor
{
    UPROPERTY()
    TArray<AMinion*> OwnedMinions;  // Boss 死了小弟陪葬
};

场景三:跨关卡存活的"临时 Actor"

有些 Actor 不属于特定 Level(比如放在 PersistentLevel 的、由 Subsystem 管理的)。这些场景下 UPROPERTY 是必须的------没有它 GC 会误回收。

场景四:对象池里缓存的 Actor

池子的核心思路就是"Actor 用完不销毁、回收复用"------池子必须强引用所有池内 Actor。

cpp 复制代码
class UBulletPool : public UObject
{
    UPROPERTY()
    TArray<ABullet*> PooledBullets;  // 强引用,防止被 GC
};

场景五:业务上"必然存在不能是 null"的成员

载具必有驾驶舱相机、角色必有武器插槽------这种成员从设计上就由我掌管。

5.3 四种典型"不该用 UPROPERTY"的反例

反例 为什么不该用 正确做法
缓存外部 Actor 引用(AI 目标、锁定对象) 对方死亡跟我无关 TWeakObjectPtr
UI 引用 World 里的对象(血条追踪角色) 角色死血条该消失 TWeakObjectPtr
事件监听者保存对方引用 避免循环引用 TWeakObjectPtr
临时计算、短期使用的指针 根本不用存成员 局部变量

5.4 简单记忆法

复制代码
A Spawn 了 B,A 自己持有 B           → UPROPERTY
A 没 Spawn B,A 只是想引用 B         → TWeakObjectPtr
A Spawn 了 B,但让别人去管 B         → 根本不用存引用

六、World 销毁时 Actor 必死------UPROPERTY 抢不回它

6.1 一个常见的认知误区

看到这,你可能会产生这样的疑问:

"我 Spawn 了一个 Actor 并且 UPROPERTY 持有它,但 World 销毁时它不是也会被销毁吗?那这个 Actor 就不完全由我管了。怎么办?"

这个问题里藏着一个关键的认知错误,必须纠正:

World 销毁时,它只销毁"自己持有的 Actor"------并不是"所有 Actor 都被它销毁"。

换句话说,World 没那么霸道------它只清理自己账本上登记的东西。但 SpawnActor 出来的 Actor 默认就登记在 Level.Actors 里,所以你 Spawn 出来的 Actor 确实会被 World 关闭时清理。

6.2 UPROPERTY 真正的作用是什么

关键理解------UE 里其实存在两层生命周期权力:

复制代码
┌──────────────────────────────────────────────────────────────┐
│  第一层:World 的生命周期权力(不可争夺)                     │
│  World 关闭时,必然 Destroy() 所有它持有的 Actor              │
│  这是引擎规则,你的 UPROPERTY 改不了                          │
└──────────────────────────────────────────────────────────────┘
                          │
                          ▼
┌──────────────────────────────────────────────────────────────┐
│  第二层:UPROPERTY 的"保内存不释放"能力                      │
│  Actor 被 Destroy 后,内存释放时机取决于 UPROPERTY            │
│  你不清引用 → 内存被你留着                                    │
│  你清了引用 → GC 正常回收                                     │
└──────────────────────────────────────────────────────────────┘

所以问题的答案是:

  1. 事实接受 :Actor 的"业务销毁"(Destroy)由 World 决定,这个你抢不过来。
  2. UPROPERTY 的真实作用 :它决定内存释放的时机,不决定"业务销毁"时机。
  3. 如果你真想完全管理生命周期这东西不该是 Actor,应该是 UObject / Subsystem。
  4. 日常场景下 :Actor 跟着关卡走是正常的、符合设计的 ------你要做的只是在关卡卸载时清理自己的引用

6.3 想跨关卡长生?别用 Actor

这是最本质的答案 。UE 里 Actor 的定义就是"属于某个 World 的对象"------没有任何 Actor 能跨 World 存活

如果你想要"一个全程存在的音乐播放器"、"一个全程存在的配置管理器",正确做法是写成 UObject / Subsystem,而不是 Actor

cpp 复制代码
// ❌ 错误设计:跨关卡长生的 Actor(实际做不到)
UCLASS()
class AMusicPlayer : public AActor { ... };

// ✅ 正确设计:跨关卡长生的 Subsystem
UCLASS()
class UMusicSubsystem : public UGameInstanceSubsystem
{
    UPROPERTY()
    USoundBase* CurrentBGM;

    void PlayBGM(...) { ... }
    // GameInstance 活多久,它就活多久
};

UE 的职责分层很清晰:

复制代码
Actor          ──> 关卡中的实体,生死跟随 World
UObject        ──> 不属于 World 的纯数据对象,生死跟随引用
GameInstance   ──> 跨关卡长生的容器
Subsystem      ──> 跨关卡长生的功能模块

6.4 如果实在想让 Actor "跨关卡"------保留数据,重建实例

很多项目的真实做法:不是跨关卡保留 Actor,而是跨关卡保留"数据",新关卡里重建 Actor

cpp 复制代码
class UMyManager : public UGameInstanceSubsystem
{
    // 跨关卡保存的是数据,不是 Actor
    FMyActorSaveData SavedData;

    UPROPERTY()
    AMyActor* MyActor;  // 只在当前关卡有效,跨关卡会重建

    void OnPreLevelUnload()
    {
        if (IsValid(MyActor))
        {
            SavedData.Health = MyActor->Health;
            SavedData.Transform = MyActor->GetActorTransform();
        }
    }

    void OnPostLevelLoad()
    {
        MyActor = GetWorld()->SpawnActor<AMyActor>(
            Class, SavedData.Transform.GetLocation(), ...);
        MyActor->Health = SavedData.Health;
    }
};

七、实战 Checklist 与总结

7.1 Spawn Actor 并用 UPROPERTY 持有的标准模板

cpp 复制代码
UCLASS()
class AMyController : public AActor
{
    GENERATED_BODY()

public:
    UPROPERTY()
    AMyActor* MyActor;

    virtual void BeginPlay() override
    {
        Super::BeginPlay();
        MyActor = GetWorld()->SpawnActor<AMyActor>(...);
    }

    // ★ 关卡结束前主动清理
    virtual void EndPlay(const EEndPlayReason::Type Reason) override
    {
        if (IsValid(MyActor))
        {
            MyActor->Destroy();  // 确保销毁(如果还没被 World 销毁)
        }
        MyActor = nullptr;  // ★ 清空指针,让 GC 正常回收
        Super::EndPlay(Reason);
    }
};

两步永远别忘:

  1. EndPlay 里清理自己 UPROPERTY 持有的 Actor 引用
  2. 或者干脆用 TWeakObjectPtr,自动失效、什么都不用做

7.2 缓存 Actor 引用的决策流程

复制代码
要在成员变量里存一个 Actor 引用
         │
         ▼
  销毁这个 Actor 的责任在我身上吗?
         │
   ┌─────┴─────┐
   │           │
  在           不在
   │           │
   ▼           ▼
UPROPERTY   TWeakObjectPtr
  +           (无需 UPROPERTY)
记得在我结束时
 清理引用

7.3 三种常见误区

误区 真相
"Spawn 出来要保存引用否则被 GC 掉" ❌ 错。World 自动持有,不需要变量引用
"Destroy() 之后 Actor 立刻消失" ❌ 错。只是标记 PendingKill,真正销毁要等 GC
"Destroy() 之后裸指针就是野指针" 通常错。内存还在一段时间;但 UPROPERTY 会被 GC 自动置 nullptr,裸指针则无任何保护

7.4 核心结论三句话

结论一 :Actor 的生命周期是"World 持有 + GC 回收"双层机制。Spawn 的那一刻起 World 就接管了它,Destroy() 把它从 World 解绑并标记死亡,真正的内存回收由下一次 GC 完成。
结论二 :缓存 Actor 引用的标准姿势是------自己 Spawn 自己管的用 UPROPERTY、别人的只是看的用 TWeakObjectPtr、临时用的用局部变量、裸指针永远不用。
结论三 :Actor 永远跟随 World 死;UPROPERTY 只影响内存何时释放,不影响业务何时销毁。想跨关卡长生的东西,别用 Actor------用 UObject / Subsystem。


相关推荐
RPGMZ5 小时前
RPGMakerMZ 地图存档点制作 标题继续游戏直接读取存档
开发语言·javascript·游戏·游戏引擎·rpgmz·rpgmakermz
柚要做甚码7 小时前
godot-rust(gdext)2D游戏之旅【pong】 - 2
游戏·游戏开发
柚要做甚码7 小时前
godot-rust(gdext)2D游戏之旅【pong】 - 3
游戏·游戏开发
盼小辉丶8 小时前
PyTorch强化学习实战——构建生成对抗网络生成Atari游戏画面
pytorch·游戏·生成对抗网络
晴夏。9 小时前
UE垃圾回收的全方面讲解(通俗易懂)【底层实现、触发方式、引用保持、优化、工具】
ue5·游戏引擎·ue·垃圾回收
邪修king11 小时前
UE5:C++ 实现 游戏逻辑 ↔ UI 双向联动
c++·游戏·ue5
Avalon7121 天前
Unity3D响应式渲染UI框架UniVue
游戏·ui·unity·c#·游戏引擎
念威1 天前
弹幕互动游戏AI无人直播方案 - 可遇AI无人直播助手
人工智能·游戏
风酥糖1 天前
Godot游戏练习01-第33节-新增会爆炸的敌人
游戏·游戏引擎·godot