UE SpawnActor 的 Actor 生命周期全解析
本文目标 :彻底搞清楚一个问题------当你
SpawnActor出来一个 Actor,它的生命由谁来管?这个问题看似简单,但里面涉及 World 引用、GC、UPROPERTY、TWeakObjectPtr、PendingKill、关卡卸载等一串机制。本文以"层层追问"的方式逐步展开,每一节都对应一个真实会产生疑惑的场景。
目录
- [认知起点:Spawn 出来的 Actor 谁在持有它](#认知起点:Spawn 出来的 Actor 谁在持有它)
- [Actor 的完整生命周期全景图](#Actor 的完整生命周期全景图)
- [缓存 Actor 引用的三种写法------哪种对哪种错](#缓存 Actor 引用的三种写法——哪种对哪种错)
- [UPROPERTY 强引用遇上关卡卸载会发生什么](#UPROPERTY 强引用遇上关卡卸载会发生什么)
- [Spawn 出来的 Actor 什么时候该用 UPROPERTY 强引用](#Spawn 出来的 Actor 什么时候该用 UPROPERTY 强引用)
- [World 销毁时 Actor 必死------UPROPERTY 抢不回它](#World 销毁时 Actor 必死——UPROPERTY 抢不回它)
- [实战 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 是带 UPROPERTY 的 TArray<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 | ✅ 不干涉寿命 |
三种方式的核心差别在两件事:
- GC 能不能扫到这条引用
- 这条引用算不算"强持有"(要不要阻止对方被 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 正常回收 │
└──────────────────────────────────────────────────────────────┘
所以问题的答案是:
- 事实接受 :Actor 的"业务销毁"(
Destroy)由 World 决定,这个你抢不过来。 - UPROPERTY 的真实作用 :它决定内存释放的时机,不决定"业务销毁"时机。
- 如果你真想完全管理生命周期 :这东西不该是 Actor,应该是 UObject / Subsystem。
- 日常场景下 :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);
}
};
两步永远别忘:
EndPlay里清理自己 UPROPERTY 持有的 Actor 引用- 或者干脆用
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。