0. 先从 new 这个习惯说起:UE 里的对象不是你想的那样
如果你跟我一样是从传统 C++ 转过来写 UE5 的,第一次接触 UObject 体系时,总会有一个下意识的念头:搞个对象,new一下就完了嘛。但实际跑起来你会发现,要么编译期就报错,要么运行时 UObject 的名字是空的、Outer 是乱的、GC 像没看见它一样,最后在某个角落静悄悄被回收。原因就一句话:UE5 里的 UObject 不是普通 C++ 对象,它运行在反射、蓝图、序列化和垃圾回收的完整框架里。而标题里这两个函数——UClass* C::StaticClass()和T* NewObject<T>(UObject* Outer, UClass* Class, ...)——恰恰是这个框架中最核心的“类和对象”入口。
这篇笔记我不会去复述官方文档,只讲我实际写代码时怎么理解、怎么用、踩过哪些坑。文章会先从 UClass 和 StaticClass 的作用说起,然后把 NewObject 的每个参数拆开讲清楚,再给几个可以直接抄的动态生成对象场景,最后分享一个我在项目里封装的安全创建工厂。适合刚学 UE5 C++、对反射和对象系统还处于“看得懂代码但不知道背后原理”阶段的读者。
1. UClass 和 StaticClass():为什么“类”本身也是个对象
1.1 UClass 不是 C++ 的 type_info
传统 C++ 里,类型信息并不参与运行时的对象创建。你要 new 一个对象,编译之前就得把类型写死,typeid能告诉你类型是什么,但不能反过来按类型字符串造一个对象出来。UE5 的反射机制改变了这件事:任何用UCLASS()宏标记的类,编译时 UHT 都会生成对应的反射数据,运行时引擎会为这个类建立一个UClass对象,这个对象本身也是 UObject。
你可以把 UClass 理解成“类档案”。档案上记录了类的名字、父类、有哪些 UPROPERTY 属性、有哪些 UFUNCTION、类的默认对象 CDO 在哪、这个类能不能实例化、是不是蓝图类。UClass继承自UStruct,而UStruct又继承自UField、UObject,所以只要你持有一个 UClass 指针,就等于拿到了这个类的完整“使用说明书”。
CDO 是另一个要一起理解的概念,全称 Class Default Object。UClass 对象上会挂着一个GetDefaultObject()返回的默认实例。它是在引擎启动期生成好的,用来保存这个类的属性默认值。蓝图里的“类默认值”面板,读的就是这个 CDO。我们在代码里访问MyClass->GetDefaultObject<UMyData>(),拿到的不是随随便便一个对象,而是这个类的默认蓝图。NewObject 在创建新对象时,也经常需要用一个 Template 参数来从 CDO 拷贝属性,这个后面细讲。
1.2 StaticClass() 与 GetClass() 的区别
C::StaticClass()是一个静态函数,它直接返回当前这个 C++ 类对应的 UClass 对象。Obj->GetClass()是一个实例方法,返回的是这个实例的真实类对应的 UClass。关键区别在继承场景里非常明显:你手里有一个APawn*指针,但指针实际指向的可能是AMyPawn的实例,GetClass()会返回AMyPawn::StaticClass(),而APawn::StaticClass()永远返回 APawn 自己的 UClass。
| 调用方式 | 返回内容 | 常见用途 |
|---|---|---|
APawn::StaticClass() | APawn 这个类本身的 UClass | 写工具函数、创建对象、传给 SpawnActor |
SomePawn->GetClass() | SomePawn 实例的真实类 UClass | 运行时判断对象实际类型 |
SomePawn->IsA<APawn>() | 判断实例是否继承自 APawn | 做类型兼容检查 |
SomePawn->GetClass()->IsA(APawn::StaticClass()) | 判断实际 UClass 是否继承自 APawn | 在一些泛型容器里手动检查 |
拿代码举例:
APawn* SomePawn = GetWorld()->GetFirstPlayerController()->GetPawn(); UClass* RealClass = SomePawn->GetClass(); // 假设 RealClass 是 AMyPawn if (RealClass == AMyPawn::StaticClass()) { // 精确匹配:这个 Pawn 就是 AMyPawn,不是它的子类 } if (RealClass->IsA(APawn::StaticClass())) { // 兼容匹配:AMyPawn 因为继承自 APawn,所以这里也会成立 }很多项目里写敌人类型判断,习惯用两个StaticClass()做相等比较来区分小怪、精英怪、Boss,这类逻辑其实应该优先用IsA,除非你真的只想匹配“完全等于”这个类型。用StaticClass()相等比较的坑是:一旦你把敌人拆成多级子类,判断就会漏。
1.3 StaticClass() 在代码里最常见的几个用途
第一,动态创建对象时作为参数传给 NewObject 或 SpawnActor。比如NewObject<UMyData>(this, UMyData::StaticClass())。第二,配合 TSubclassOf 使用,把某个类作为 UPROPERTY 暴露给蓝图设计师去指定,比如“这个任务系统使用哪种子任务类”,底层存的就是一个 UClass 指针。第三,在编辑器工具和运行时做类型判断,比如遍历所有 Actor,找出所有挂载了指定组件的 Actor。第四,在构造函数里用ConstructorHelpers::FClassFinder加载蓝图类,这个FClassFinder返回结果本质上就是某个 UClass 指针。
还有个不起眼但很好用的功能:UClass上可以直接拿这个类的名字、父类、默认对象。调试时我经常打日志:
UE_LOG(LogTemp, Warning, TEXT("Class: %s, SuperClass: %s"), *GetNameSafe(SomeActor->GetClass()), *GetNameSafe(SomeActor->GetClass()->GetSuperClass()));这样能很快看出一个 Actor 的真实类层级,比看蓝图断点直观得多。
2. NewObject 的完整拆解:Outer、Class、Name、Flags 到底怎么填
2.1 两个重载怎么选:模板参数 T 和运行时 UClass*
NewObject 最常见的用法是这种:
template<typename T> T* NewObject(UObject* Outer, UClass* Class = T::StaticClass(), FName Name = NAME_None, EObjectFlags Flags = RF_NoFlags, UObject* Template = nullptr);实际写代码时,两种是最常用的:
// 方式一:编译期确定类型,不需要传 Class UMyTaskData* Task = NewObject<UMyTaskData>(this); // 方式二:运行时想要指定子类 UMyTaskData* Task = NewObject<UMyTaskData>(this, SubClass);第二种方式的价值在于,SubClass可以是一个通过蓝图配置的 TSubclassOf 变量,也可能是在代码里随便算出来的一个 UClass 指针。比如我们做一个成就系统,奖品的类型是从一个 DataTable 读出来的,运行时才知道要创建哪一种任务对象,这时候模板参数仍然写成基类UMyTaskData,但实际创建的类由第二个参数指定。NewObject 内部会校验这个 Class 是不是模板参数 T 的子类,传错了会直接触发断言,这一点我后面讲。
2.2 Outer 的深层含义:所有权、生命周期、GC
Outer 大概是新手最容易忽略但又最影响稳定性的参数。Outer 翻译成中文可以理解成“外部所有者”,它决定了这个新对象挂在哪,归谁管。首先,对象在内存里会有一个基于 Outer 的父子关系,很多查找接口都是按Outer + Name去找对象的。其次,Outer 关系到 GC:对象要存活,不仅要有人引用它,它的 Outer 链路也不能断。如果你把对象创建在一个GetTransientPackage()下面,又没有任何 UPROPERTY 变量持有它,那这个对象等于“无根浮萍”,下个 GC 周期就可能没了。
更直观的理解方式:Outer 就是这个对象生活的“容器”。如果你在AGameMode里NewObject<UMyData>(this),那么这份任务数据就跟着 GameMode 走,GameMode 被关了,这对象也被连带回收。如果你想让任务数据跨关卡存活,Outer 应该选 GameInstance、Subsystem 这类长生命周期的对象,或者干脆挂在默认包下并且把引用 AddToRoot。
我经常在编辑器插件里创建临时对象,这种对象不参与存档,生命周期也不长,那就直接:
UPackage* Transient = GetTransientPackage(); UMyEditorData* TmpData = NewObject<UMyEditorData>(Transient);注意创建临时对象时仍然需要一个非空的 Outer,传 nullptr 会触发问题,通常用 TransientPackage 作为兜底。
2.3 Name 和 Flags:命名规则与对象标记
Name 参数如果不填,默认是NAME_None,引擎会拿类的名字作为对象名。比如NewObject<UMyTaskData>(this),生成出来的对象名很可能就是MyTaskData或MyTaskData_0、MyTaskData_1,因为同一个 Outer 下对象名不能重复,引擎会自动加后缀来保证唯一。这个行为对大多数情况足够,但调试时名字一多就分不清谁是谁了。如果每个任务有明确的 ID,我就喜欢显式传名字:
FString NameStr = FString::Printf(TEXT("Task_%d"), TaskId); UMyTaskData* Task = NewObject<UMyTaskData>(this, UMyTaskData::StaticClass(), FName(*NameStr));对象名在运行时不参与逻辑的话其实无所谓,但在编辑器里、在日志输出里,有可读的名字能省很多事。
Flags 参数是个位掩码,默认RF_NoFlags什么都不带。常用的是RF_Transient和RF_Transactional。RF_Transient表示这是一个临时对象,不会随关卡、资产序列化保存到磁盘,非常适合运行时才存在的缓存、计算结果。RF_Transactional表示对象参与编辑器事务系统,比如你在编辑器里用脚本改动对象属性,它可以被 Undo/Redo 记录。绝大多数运行时创建对象用不到这些标记,保持 RF_NoFlags 就好,但如果你在做编辑器工具或者自动化测试脚本,Flags 就很关键。
2.4 Template 和其他冷门参数
Template 参数用来传一个“模板对象”,新建对象时会从这个模板对象拷贝一系列属性值。最常见的模板就是Class->GetDefaultObject(),也就是使用蓝图默认值。实际上 NewObject 有一个简化版本,当你只传 Outer 的时候,内部会默认拿 CDO 作为模板。但如果我们希望新对象从某个已有实例拷贝属性,那 Template 就有用了。我记得有一个编辑器场景:从“当前选中的对象”复制一份新的资产对象,让它保留源对象的所有属性值,再改个别字段做变体。这时候就可以把选中对象作为 Template 传给 NewObject。
还有bCopyTransientsFromClassDefaults和更底层的FInstanceGraph,日常业务代码基本碰不到,我就不展开篇幅了。你只要知道 NewObject 不只是“新造一个对象”,它还会根据模板对象/类默认值完成一系列初始化动作。
3. 实际代码:在 Game 逻辑里动态生成 UObject 的几种姿势
3.1 生成一个普通 UObject 数据容器(任务、背包、统计)
最常见的需求:你有一个 UObject 子类,里面存任务或背包格子数据,不需要挂到场景里,也不需要蓝图实例。定义一个类:
UCLASS(BlueprintType) class UMyTaskData : public UObject { GENERATED_BODY() public: UPROPERTY(EditAnywhere, BlueprintReadWrite) FText TaskName; UPROPERTY(EditAnywhere, BlueprintReadWrite) int32 Reward = 0; };然后在某个管理类里动态创建:
UMyTaskData* NewTask = NewObject<UMyTaskData>(this, UMyTaskData::StaticClass(), FName(TEXT("Task_01"))); if (NewTask) { NewTask->TaskName = FText::FromString(TEXT("收集三把钥匙")); NewTask->Reward = 500; TaskList.Add(NewTask); }这里有几个关键点为什么这么写。Outer 传this,让任务对象的管理权归当前对象。TaskList必须是一个 UPROPERTY 引用的数组,否则这个新对象只被一个裸指针指着,GC 不会把它当成有效引用,某次垃圾回收后数组里就会出现野指针:
UPROPERTY() TArray<UMyTaskData*> TaskList;如果你希望任务系统里的对象在 GameInstance 层面共享,那就把创建逻辑放到一个 GameInstanceSubsystem 里,Outer 传this(Subsystem 自己),生命周期跟 GameInstance 走。这是“为什么这样设计”里比较容易理解的一点:Outer 的选择直接决定对象寿命。
3.2 生成 UActorComponent 不能只 NewObject,还要 RegisterComponent
很多人第一次尝试用 NewObject 创建组件,写出类似下面的代码,发现组件压根不生效:
UMyComponent* Comp = NewObject<UMyComponent>(this, UMyComponent::StaticClass(), TEXT("MyComp"));NewObject 确实创建了一个 UObject 层面的组件对象,但它并没有被注册到 Actor 的组件系统里。UActorComponent 不是随便一个 UObject,它需要走注册流程才能接收 Tick、才能被事件系统找到。普通组件的标准做法是用AddComponentByClass或者NewObject + RegisterComponent:
UMyComponent* Comp = NewObject<UMyComponent>(this, UMyComponent::StaticClass(), TEXT("MyComp")); if (Comp) { Comp->RegisterComponent(); }RegisterComponent会通知引擎这个组件现在属于当前 Actor 了,后续框架才会对它进行初始化、注册和网络复制。如果你想创建的是一个 SceneComponent,还要记得挂到某个根组件下,否则虽然有注册,但 Transform 不会跟随 Actor。这里特别容易踩的坑是:NewObject创建组件时会在内存里把这个对象当作普通 UObject 处理,但只有注册之后才是“真正的组件”。
3.3 生成 UUserWidget:CreateWidget 底层也是 NewObject
UI 也是 UObject 体系里的一部分,动态创建一个 UMG 控件,标准入口是CreateWidget:
UMyWidget* Widget = CreateWidget<UMyWidget>(this, MyWidgetClass); if (Widget) { Widget->AddToViewport(); }CreateWidget内部本质上就是NewObject<UUserWidget>的封装,但它多做了一件重要的事:把各平台输入、焦点、动画相关上下文设置好。另外CreateWidget会在 UUserWidget 类里维护一个PlayerContext,如果你用裸的 NewObject 生成 UUserWidget 再AddToViewport,某些情况下会缺失必要的 World 和 LocalPlayer 信息,轻则按钮没音效,重则视口添加失败。所以我自己的规则是:UI 一律走 CreateWidget;纯数据对象、非组件对象才直接 NewObject。虽然 NewObject 能创建一切 UObject 子类,但“能”不代表“应该”,引擎高层封装往往隐藏了关键初始化。
3.4 想生成 Actor 怎么办:SpawnActor 与 NewObject 的关系和差异
如果你是想在世界里生成一个 Actor,比如敌人、掉落物、弹幕,别直接用 NewObject。Actor 的创建流程远比普通 UObject 复杂,生成位置、旋转、Owner、碰撞处理、初始蓝图设置、网络复制这些都需要引擎参与。标准接口是:
FActorSpawnParameters Params; Params.Owner = this; Params.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn; AMyActor* NewActor = GetWorld()->SpawnActor<AMyActor>( AMyActor::StaticClass(), SpawnLocation, SpawnRotation, Params );我把普通 UObject 和 Actor 的创建差异整理了一张表:
| 创建目标 | 推荐方式 | 原因 |
|---|---|---|
| 纯数据对象 UObject | NewObject | 不关心场景和网络,生命周期由 Outer 控制 |
| 组件 UActorComponent | NewObject + RegisterComponent | 组件需要注册后才参与 Actor 的框架流程 |
| UI 控件 UUserWidget | CreateWidget | 会正确初始化 World、PlayerContext、焦点管理 |
| Actor | SpawnActor | 涉及生成参数、碰撞、初始化、网络、Tick 等全套流程 |
这里必须解释一个现象:SpawnActor 内部最终也会走对象创建,但它创建完 Actor 后还会执行PostSpawnInitialize、FinishSpawning这一整套生命周期。直接 NewObject 一个 Actor 类,你会发现这个 Actor 既没有世界位置,也不会执行 BeginPlay,更不能被关卡管理。如果你在代码里看到有人用 NewObject 生成 Actor,那基本是写错了逻辑,除非他在做一个编辑器非常特殊的工具。
4. NewObject 踩坑实录:对象凭空消失、名字冲突、空指针
4.1 踩坑 1:直接 new UObject,崩溃还是没名字
我知道不少人刚接触时试过直接new UMyData()。我最早也这么干过,对象貌似构造出来了,但一调用 UE 的对象工具函数就各种异常。原因是 UObject 的内部状态,比如 Outer、FName、类型 flags、GC 链表,都需要由引擎的对象创建管线来初始化。直接 new 出来的东西在这套体系里是“黑户”,没有名字、没有 Outer、不在 GC 管辖范围内。后期想让它进对象系统,已经晚了。
正确做法永远是 NewObject。如果你实在想在普通 C++ 环境里创建一个临时 UObject 而不想让它进入 GC,可以用NewObject<UObject>(GetTransientPackage()),用完置空引用,让它被回收。这样至少生命周期是可控的。
4.2 踩坑 2:Outer 选错,对象在 GC 后“消失”
这个坑我在多人背包项目里真实遇到过。当时在客户端本地创建了一个临时数据对象,Outer 传了GetTransientPackage(),然后又用一个非 UPROPERTY 的 C++ 指针把它存到了 TMap 里。一开始测试没问题,玩家切图之后 TMap 项还在,但访问对象属性时突然崩溃。查了半天发现是 GC 跑过后把对象回收了,而 TMap 里的指针没被置空,成了悬垂指针。
这个教训有两点。第一,只要你还想继续使用这个对象,就必须有一处 UPROPERTY 引用,或者调用AddToRoot()让 GC 无法回收。第二,Outer 不背这个锅,真正的问题是引用不是强引用。GC 的规则是:对象必须能从“根集合”出发被引用到,且它的 Outer 链路也不能断。哪怕你有一个 TArray<UMyData*> 的 UPROPERTY 成员,但数组本身如果属于一个已经被 GC 的对象,那引用也无效。
所以动态生成对象后,我习惯立刻把新对象塞进一个 UPROPERTY 容器里,再用这个容器统一管理:
UPROPERTY() TArray<UMyTaskData*> ActiveTasks; UMyTaskData* Task = NewObject<UMyTaskData>(this); ActiveTasks.Add(Task);只要 ActiveTasks 所在对象活着,任务对象就活着,不需要额外 AddToRoot。
4.3 踩坑 3:类不能实例化,NewObject 返回 nullptr
NewObject 返回 nullptr 的情况很多新人没意识到。比较常见的是这个类带上了 Abstract 标记、Deprecated 标记,或者是一个被引擎判定为“不允许动态创建”的类。蓝图类如果父类被删过、重定向失败,也可能在运行时拿到一个悬空 UClass。例如我在做敌人配置时,策划在 DataTable 里把一个 EnemyClass 配成了某个抽象基类,运行时 NewObject 直接返回空指针,后续代码访问空对象就崩了。
防御性写法很简单:
UClass* InClass = /* 从外部拿到的类 */; if (!InClass || InClass->HasAnyClassFlags(CLASS_Abstract | CLASS_Deprecated)) { return nullptr; } UMyEnemyData* Data = NewObject<UMyEnemyData>(this, InClass); if (!Data) { return nullptr; }还有一个容易忽略的:如果你把TSubclassOf<UMyData>暴露给蓝图,但又允许设计师选“无”,NewObject 之前一定要判空。很多时候空指针不是 NewObject 本身的问题,而是传给它的 Class 参数本来就无效。
4.4 踩坑 4:命名冲突引发的诡异问题
NewObject 多次创建同类对象,不指定名字时引擎会自动加后缀,这没问题。但如果你在创建时指定了一个名字,而同一个 Outer 下已经存在同名对象,引擎不会直接覆盖,它会自动帮你重新生成一个唯一名字。表面看好像“名字没生效”,实际上是对你的过度保护。
我的习惯是:只要后续需要按名字查找对象,就用一个固定前缀加自增 ID,而不是单纯依赖类名。不然从代码角度很难判断当前对象是哪个 ID。比如:
FName ObjName = MakeUniqueObjectName(this, UMyTaskData::StaticClass()); UMyTaskData* Task = NewObject<UMyTaskData>(this, UMyTaskData::StaticClass(), ObjName);MakeUniqueObjectName会基于 Outerl 和类名生成一个唯一合法的名字。虽然它并不是所有场景必须的,但能减少很多调试困惑。
4.5 踩坑 5:Native 构造函数与蓝图默认值不一致
最后一个坑跟 CDO 和 Template 有关。如果你在 UObject 的 C++ 构造函数里给 UPROPERTY 变量设了初值,同时又想在蓝图里让设计师改这些默认值,那么 NewObject 从 CDO 拷贝属性的行为会直接影响结果。创建的新对象会以 CDO 为模板,把蓝图里配置的默认值带过来。但如果你没把 UPROPERTY 暴露出来,而只在 C++ 构造函数里赋值,NewObject 创建的对象用的就是构造函数的初值,不会主动去读蓝图里的子类重写。
这个现象在数据资产类对象里尤其明显。所以一个原则是:C++ 构造函数里只放“最基本的默认值”,所有与关卡、玩法、策划配置相关的默认值尽量通过蓝图类默认值、DataAsset、DataTable 去覆盖。这样 NewObject 配合 CDO 才能完整还原出你预期的配置。
5. 再进一步:用 NewObject 封装一个更安全的对象工厂
5.1 为什么值得封装
动态创建对象的代码写多了之后,我发现每次都要做一遍差不多的脏活:判断 Class 是不是空、是不是 Abstract、是不是 Deprecated、是不是目标类的子类,Outer 为空时要不要兜底,要不要从 CDO 拷贝默认值。手动到处写其实是把风险散落在各个业务模块里。与其这样,不如做一个静态工厂方法,把 NewObject 的调用收敛到一个地方。
这个工厂还有一个好处:让它变成蓝图也能调用的工具。比如策划想在蓝图里按运行时配置的类创建任务对象,直接调用这个工厂函数就行,不需要在蓝图里暴露 NewObject 节点。对于非程序员团队,这一步能减少很多“为什么生成不出来”的沟通成本。
5.2 封装代码与使用
首先定义一个公共基类,比如所有任务对象的父类:
UCLASS(Blueprintable, Abstract) class UMyObjectBase : public UObject { GENERATED_BODY() // 公共接口可以放这里 };然后写一个静态工厂,用 UBlueprintFunctionLibrary 包装:
UCLASS() class UMyObjectFactory : public UBlueprintFunctionLibrary { GENERATED_BODY() public: UFUNCTION(BlueprintCallable, Category = "ObjectFactory", meta = (DeterminesOutputType = "InClass")) static UMyObjectBase* CreateMyObjectByClass(UClass* InClass, UObject* Outer = nullptr) { if (!InClass || !InClass->IsChildOf(UMyObjectBase::StaticClass())) { return nullptr; } if (InClass->HasAnyClassFlags(CLASS_Abstract | CLASS_Deprecated)) { return nullptr; } UObject* PacakgeOuter = Outer ? Outer : GetTransientPackage(); return NewObject<UMyObjectBase>(PacakgeOuter, InClass, NAME_None, RF_NoFlags, InClass->GetDefaultObject()); } };这个工厂做了什么?第一,IsChildOf保证外部传入的类一定是任务对象的子类,避免拿个完全不相关的类来创建。第二,过滤抽象类和废弃类,避免 NewObject 返回空。第三,Outer 为空时自动兜底到 TransientPackage,至少不会因为 Outerl 无效而崩溃。第四,用InClass->GetDefaultObject()作为 Template,这样创建出来的对象会带齐蓝图默认属性。
使用示例就清爽多了:
// C++ 侧 UMyTaskData* Task = UMyObjectFactory::CreateMyObjectByClass(TaskDataClass, this); if (Task) { // 继续初始化 } // 蓝图侧 // 节点:Create My Object By Class // InClass 传一个 TSubclassOf<UMyObjectBase>,Outer 传 self5.3 配合 TSubclassOf 和 TSoftClassPtr 的资产化用法
工厂函数如果只接收一个运行时 UClass 指针,还有一个问题:蓝图里你怎么把这个 Class 配进去?我见过比较多的做法是在一个管理类身上做 UPROPERTY 配置:
UPROPERTY(EditDefaultsOnly, Category = "Tasks") TSubclassOf<UMyTaskData> DefaultTaskClass;然后在代码里从 TSubclassOf 取出 UClass* 再传给工厂。编辑器加载资产时,更推荐 TSoftClassPtr,它允许类资产不驻留内存,由你在需要的时候异步加载:
UPROPERTY(EditDefaultsOnly, Category = "Tasks") TSoftClassPtr<UMyTaskData> SoftTaskClass; // 使用 UClass* TaskClass = SoftTaskClass.LoadSynchronous(); UMyTaskData* Task = UMyObjectFactory::CreateMyObjectByClass(TaskClass, this);这样配出来的对象生成逻辑非常统一:策划配类 -> 工厂校验 -> NewObject 创建 -> 业务方拿到对象继续填充数据。项目的动态对象创建不会再散落一地。
我个人在实际项目里用这一套做了任务、成就、掉落物三个模块,后来新同事接手时也不需要读太多文档,跟着工厂函数走就能明白对象是从哪来的、生命周期归谁管。NewObject 本身不难,难的是把创建入口管起来,让整个团队都在同一套规则下做事。如果你现在只是在自己的 demo 里练习,先把 Outer 和 UPROPERTY 引用这两件事刻在脑子里,比背再多参数签名都管用。