ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

UE5 UObject创建机制:StaticClass与NewObject核心用法与踩坑解析

UE5 UObject创建机制:StaticClass与NewObject核心用法与踩坑解析 0. 先从 new 这个习惯说起UE 里的对象不是你想的那样如果你跟我一样是从传统 C 转过来写 UE5 的第一次接触 UObject 体系时总会有一个下意识的念头搞个对象new一下就完了嘛。但实际跑起来你会发现要么编译期就报错要么运行时 UObject 的名字是空的、Outer 是乱的、GC 像没看见它一样最后在某个角落静悄悄被回收。原因就一句话UE5 里的 UObject 不是普通 C 对象它运行在反射、蓝图、序列化和垃圾回收的完整框架里。而标题里这两个函数——UClass* C::StaticClass()和T* NewObjectT(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-GetDefaultObjectUMyData()拿到的不是随随便便一个对象而是这个类的默认蓝图。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写工具函数、创建对象、传给 SpawnActorSomePawn-GetClass()SomePawn 实例的真实类 UClass运行时判断对象实际类型SomePawn-IsAAPawn()判断实例是否继承自 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。比如NewObjectUMyData(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 最常见的用法是这种templatetypename T T* NewObject(UObject* Outer, UClass* Class T::StaticClass(), FName Name NAME_None, EObjectFlags Flags RF_NoFlags, UObject* Template nullptr);实际写代码时两种是最常用的// 方式一编译期确定类型不需要传 Class UMyTaskData* Task NewObjectUMyTaskData(this); // 方式二运行时想要指定子类 UMyTaskData* Task NewObjectUMyTaskData(this, SubClass);第二种方式的价值在于SubClass可以是一个通过蓝图配置的 TSubclassOf 变量也可能是在代码里随便算出来的一个 UClass 指针。比如我们做一个成就系统奖品的类型是从一个 DataTable 读出来的运行时才知道要创建哪一种任务对象这时候模板参数仍然写成基类UMyTaskData但实际创建的类由第二个参数指定。NewObject 内部会校验这个 Class 是不是模板参数 T 的子类传错了会直接触发断言这一点我后面讲。2.2 Outer 的深层含义所有权、生命周期、GCOuter 大概是新手最容易忽略但又最影响稳定性的参数。Outer 翻译成中文可以理解成“外部所有者”它决定了这个新对象挂在哪归谁管。首先对象在内存里会有一个基于 Outer 的父子关系很多查找接口都是按Outer Name去找对象的。其次Outer 关系到 GC对象要存活不仅要有人引用它它的 Outer 链路也不能断。如果你把对象创建在一个GetTransientPackage()下面又没有任何 UPROPERTY 变量持有它那这个对象等于“无根浮萍”下个 GC 周期就可能没了。更直观的理解方式Outer 就是这个对象生活的“容器”。如果你在AGameMode里NewObjectUMyData(this)那么这份任务数据就跟着 GameMode 走GameMode 被关了这对象也被连带回收。如果你想让任务数据跨关卡存活Outer 应该选 GameInstance、Subsystem 这类长生命周期的对象或者干脆挂在默认包下并且把引用 AddToRoot。我经常在编辑器插件里创建临时对象这种对象不参与存档生命周期也不长那就直接UPackage* Transient GetTransientPackage(); UMyEditorData* TmpData NewObjectUMyEditorData(Transient);注意创建临时对象时仍然需要一个非空的 Outer传 nullptr 会触发问题通常用 TransientPackage 作为兜底。2.3 Name 和 Flags命名规则与对象标记Name 参数如果不填默认是NAME_None引擎会拿类的名字作为对象名。比如NewObjectUMyTaskData(this)生成出来的对象名很可能就是MyTaskData或MyTaskData_0、MyTaskData_1因为同一个 Outer 下对象名不能重复引擎会自动加后缀来保证唯一。这个行为对大多数情况足够但调试时名字一多就分不清谁是谁了。如果每个任务有明确的 ID我就喜欢显式传名字FString NameStr FString::Printf(TEXT(Task_%d), TaskId); UMyTaskData* Task NewObjectUMyTaskData(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 NewObjectUMyTaskData(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() TArrayUMyTaskData* TaskList;如果你希望任务系统里的对象在 GameInstance 层面共享那就把创建逻辑放到一个 GameInstanceSubsystem 里Outer 传thisSubsystem 自己生命周期跟 GameInstance 走。这是“为什么这样设计”里比较容易理解的一点Outer 的选择直接决定对象寿命。3.2 生成 UActorComponent 不能只 NewObject还要 RegisterComponent很多人第一次尝试用 NewObject 创建组件写出类似下面的代码发现组件压根不生效UMyComponent* Comp NewObjectUMyComponent(this, UMyComponent::StaticClass(), TEXT(MyComp));NewObject 确实创建了一个 UObject 层面的组件对象但它并没有被注册到 Actor 的组件系统里。UActorComponent 不是随便一个 UObject它需要走注册流程才能接收 Tick、才能被事件系统找到。普通组件的标准做法是用AddComponentByClass或者NewObject RegisterComponentUMyComponent* Comp NewObjectUMyComponent(this, UMyComponent::StaticClass(), TEXT(MyComp)); if (Comp) { Comp-RegisterComponent(); }RegisterComponent会通知引擎这个组件现在属于当前 Actor 了后续框架才会对它进行初始化、注册和网络复制。如果你想创建的是一个 SceneComponent还要记得挂到某个根组件下否则虽然有注册但 Transform 不会跟随 Actor。这里特别容易踩的坑是NewObject创建组件时会在内存里把这个对象当作普通 UObject 处理但只有注册之后才是“真正的组件”。3.3 生成 UUserWidgetCreateWidget 底层也是 NewObjectUI 也是 UObject 体系里的一部分动态创建一个 UMG 控件标准入口是CreateWidgetUMyWidget* Widget CreateWidgetUMyWidget(this, MyWidgetClass); if (Widget) { Widget-AddToViewport(); }CreateWidget内部本质上就是NewObjectUUserWidget的封装但它多做了一件重要的事把各平台输入、焦点、动画相关上下文设置好。另外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()-SpawnActorAMyActor( AMyActor::StaticClass(), SpawnLocation, SpawnRotation, Params );我把普通 UObject 和 Actor 的创建差异整理了一张表创建目标推荐方式原因纯数据对象 UObjectNewObject不关心场景和网络生命周期由 Outer 控制组件 UActorComponentNewObject RegisterComponent组件需要注册后才参与 Actor 的框架流程UI 控件 UUserWidgetCreateWidget会正确初始化 World、PlayerContext、焦点管理ActorSpawnActor涉及生成参数、碰撞、初始化、网络、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可以用NewObjectUObject(GetTransientPackage())用完置空引用让它被回收。这样至少生命周期是可控的。4.2 踩坑 2Outer 选错对象在 GC 后“消失”这个坑我在多人背包项目里真实遇到过。当时在客户端本地创建了一个临时数据对象Outer 传了GetTransientPackage()然后又用一个非 UPROPERTY 的 C 指针把它存到了 TMap 里。一开始测试没问题玩家切图之后 TMap 项还在但访问对象属性时突然崩溃。查了半天发现是 GC 跑过后把对象回收了而 TMap 里的指针没被置空成了悬垂指针。这个教训有两点。第一只要你还想继续使用这个对象就必须有一处 UPROPERTY 引用或者调用AddToRoot()让 GC 无法回收。第二Outer 不背这个锅真正的问题是引用不是强引用。GC 的规则是对象必须能从“根集合”出发被引用到且它的 Outer 链路也不能断。哪怕你有一个 TArrayUMyData* 的 UPROPERTY 成员但数组本身如果属于一个已经被 GC 的对象那引用也无效。所以动态生成对象后我习惯立刻把新对象塞进一个 UPROPERTY 容器里再用这个容器统一管理UPROPERTY() TArrayUMyTaskData* ActiveTasks; UMyTaskData* Task NewObjectUMyTaskData(this); ActiveTasks.Add(Task);只要 ActiveTasks 所在对象活着任务对象就活着不需要额外 AddToRoot。4.3 踩坑 3类不能实例化NewObject 返回 nullptrNewObject 返回 nullptr 的情况很多新人没意识到。比较常见的是这个类带上了 Abstract 标记、Deprecated 标记或者是一个被引擎判定为“不允许动态创建”的类。蓝图类如果父类被删过、重定向失败也可能在运行时拿到一个悬空 UClass。例如我在做敌人配置时策划在 DataTable 里把一个 EnemyClass 配成了某个抽象基类运行时 NewObject 直接返回空指针后续代码访问空对象就崩了。防御性写法很简单UClass* InClass /* 从外部拿到的类 */; if (!InClass || InClass-HasAnyClassFlags(CLASS_Abstract | CLASS_Deprecated)) { return nullptr; } UMyEnemyData* Data NewObjectUMyEnemyData(this, InClass); if (!Data) { return nullptr; }还有一个容易忽略的如果你把TSubclassOfUMyData暴露给蓝图但又允许设计师选“无”NewObject 之前一定要判空。很多时候空指针不是 NewObject 本身的问题而是传给它的 Class 参数本来就无效。4.4 踩坑 4命名冲突引发的诡异问题NewObject 多次创建同类对象不指定名字时引擎会自动加后缀这没问题。但如果你在创建时指定了一个名字而同一个 Outer 下已经存在同名对象引擎不会直接覆盖它会自动帮你重新生成一个唯一名字。表面看好像“名字没生效”实际上是对你的过度保护。我的习惯是只要后续需要按名字查找对象就用一个固定前缀加自增 ID而不是单纯依赖类名。不然从代码角度很难判断当前对象是哪个 ID。比如FName ObjName MakeUniqueObjectName(this, UMyTaskData::StaticClass()); UMyTaskData* Task NewObjectUMyTaskData(this, UMyTaskData::StaticClass(), ObjName);MakeUniqueObjectName会基于 Outerl 和类名生成一个唯一合法的名字。虽然它并不是所有场景必须的但能减少很多调试困惑。4.5 踩坑 5Native 构造函数与蓝图默认值不一致最后一个坑跟 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 NewObjectUMyObjectBase(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 传一个 TSubclassOfUMyObjectBaseOuter 传 self5.3 配合 TSubclassOf 和 TSoftClassPtr 的资产化用法工厂函数如果只接收一个运行时 UClass 指针还有一个问题蓝图里你怎么把这个 Class 配进去我见过比较多的做法是在一个管理类身上做 UPROPERTY 配置UPROPERTY(EditDefaultsOnly, Category Tasks) TSubclassOfUMyTaskData DefaultTaskClass;然后在代码里从 TSubclassOf 取出 UClass* 再传给工厂。编辑器加载资产时更推荐 TSoftClassPtr它允许类资产不驻留内存由你在需要的时候异步加载UPROPERTY(EditDefaultsOnly, Category Tasks) TSoftClassPtrUMyTaskData SoftTaskClass; // 使用 UClass* TaskClass SoftTaskClass.LoadSynchronous(); UMyTaskData* Task UMyObjectFactory::CreateMyObjectByClass(TaskClass, this);这样配出来的对象生成逻辑非常统一策划配类 - 工厂校验 - NewObject 创建 - 业务方拿到对象继续填充数据。项目的动态对象创建不会再散落一地。我个人在实际项目里用这一套做了任务、成就、掉落物三个模块后来新同事接手时也不需要读太多文档跟着工厂函数走就能明白对象是从哪来的、生命周期归谁管。NewObject 本身不难难的是把创建入口管起来让整个团队都在同一套规则下做事。如果你现在只是在自己的 demo 里练习先把 Outer 和 UPROPERTY 引用这两件事刻在脑子里比背再多参数签名都管用。
返回列表