ARTICLE DETAIL

资讯详情

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

UE5组件创建:CreateDefaultSubobject与NewObject的区别与使用

UE5组件创建:CreateDefaultSubobject与NewObject的区别与使用 1. 先把最反直觉的问题讲清楚为什么 UE 不允许在构造函数里 new 一个组件在 UE5 C 的日常开发里UObject::CreateDefaultSubobject几乎会出现在每一个拥有组件的 Actor 构造函数中。它长得也劝退templateclass R R* UObject::CreateDefaultSubobject(FName SubobjName, ...)后面还挂个省略号。很多刚入门的朋友会直接把它当成某种特殊的 new或者干脆死记硬背构造函数里创建组件就是这么写。短期能跑通但只要改一个名字、挪一次调用位置或者不小心在 BeginPlay 里用了一下知识盲区立刻暴露。我刚开始写 UE 的时候踩过这么一回觉得组件就是普通对象直接在构造函数里new UStaticMeshComponent()然后用一个裸指针存着。编译没问题一运行乱崩日志里全是 GC 和注册相关的报错。后面翻源码、查文档才搞清楚 UE 根本不允许你用普通 C 的方式 new 一个 UObject。这篇文章就从 UObject 初始化机制讲起把CreateDefaultSubobject的设计意图、参数含义、使用边界和最容易翻车的地方全部过一遍适合已经写 UE5 C 但还处在组件创建靠复读阶段的同学。1.1 构造函数里 new 出来的 UObject为什么是黑户要理解CreateDefaultSubobject先得理解 UE 里一个 UObject 是怎么诞生的。普通 C 里new Foo()就是分配内存、执行构造函数完事。UE 里不行。UObject 有一套自己的反射系统反射、GC、序列化全依赖它。一个对象在内存里出现之前必须先由引擎创建它的 Class、Outer、Flags、GUID 等元信息再调用构造函数。这个过程走的是StaticConstructObject_Internal最终封装成我们常用的NewObject函数。Actor 构造函数运行的时候Actor 本身正处于由引擎正在创建的阶段。如果在这个阶段直接 new 一个 UStaticMeshComponent这个组件没有经过 UObject 体系的标准创建流程没有正确的 Outer、没有登记到类默认子对象表、也没有和当前 Actor 建立引用关系。说得直白点它是一个不被引擎认识的黑户对象后面 GC 一跑随时可能被清掉编辑器里也不会把它识别成这个 Actor 的组件。CreateDefaultSubobject解决的就是这个问题。它本质上告诉引擎我在构造函数里要建一个子对象请你按 UObject 的标准流程创建并把这个子对象登记到当前类的默认子对象体系里。1.2 Default 这三个字母的分量函数名叫 CreateDefaultSubobject重点通常被放在 Subobject 上其实真正有信息量的是 Default。默认子对象不是普通的运行时对象它是挂在 UClass 上的对象模板。当引擎每次创建 Actor 实例时会检查这个类以及父类的默认子对象表然后把这些对象按模板重新创建出来。换句话说你在构造函数里用CreateDefaultSubobject创建的组件不只属于当前实例它会被记录到类的定义里以后每次实例化这个类组件都会被自动构建出来。这个机制带来的直接好处是蓝图子类可以在编辑器里看到 C 构造函数创建的组件可以修改默认属性、重新附加父子关系甚至可以引用它。父类 C 里创建了名为 MeshComp 的 UStaticMeshComponent蓝图子类什么都不用写打开蓝图编辑器就能在组件面板里看到它。这种从 C 继承到蓝图的可见性正是CreateDefaultSubobject区别于后面要讲的NewObject的最大价值。注意这里说的默认子对象表是理解后面很多坑的关键。组件名要在这张表里作为唯一标识所以重名会崩、在构造函数外调用会崩根本原因都出在这张表上。下一节逐个拆解函数签名。2. template R* 与 FName把函数签名里的每个字符都吃透很多教程会说这是模板函数创建什么组件就填什么类型。但为什么不能设计成不带模板参数返回 UObject* 让用户自己 Cast因为模板参数在这里有双重作用。第一编译期类型安全。调用CreateDefaultSubobjectUStaticMeshComponent(...)时返回的指针直接就是UStaticMeshComponent*不需要再强转。如果写错类型比如想创建 UStaticMeshComponent 却填了 USceneComponent大多数情况编译器会直接报错而不是等你运行到一半才发现指针类型不对。第二引擎内部需要知道你要创建的具体 UClass。函数内部要调用R::StaticClass()拿到这个类型的反射信息才能知道应该实例化哪个类并把对应 CDO 的属性复制到新对象上。R::StaticClass()在编译期就能确定整个创建流程是类型安全的。使用上C 不能从返回值反推模板参数所以R必须显式写出来。我见过有人尝试写auto MeshComp CreateDefaultSubobject(TEXT(MeshComp));编译直接报错——模板没有任何推导依据。记住这一点可以省不少编译时间。2.1 模板参数 R 是让编译器帮你把关不是摆设这里单独再强调一下模板参数的性质。当你写下CreateDefaultSubobjectUStaticMeshComponent时编译器知道你最终想要的是一个 UStaticMeshComponent 子对象它会做以下这些事情检查UStaticMeshComponent::StaticClass()是否合法确认 R 是 UObject 的子类否则编译失败在函数内部完成正确的对象实例化并把指针类型返回。如果模板参数填的是一个抽象类比如AActor编译期不会立刻报错但运行期会因为无法实例化抽象类而触发断言。这一点和普通 C 的纯虚类行为还不一样UE 的抽象 UClass 有自己一套标志CreateDefaultSubobject内部会检查CLASS_Abstract。还有一个很实际的细节如果你在头文件里只写了前置声明class UStaticMeshComponent;没有 include 它的头文件调用CreateDefaultSubobjectUStaticMeshComponent里使用R::StaticClass()时会报使用未定义的类型。所以不要在没 include 完整类型的地方写模板调用。很多新手编译报错根本不是模板写错而是少了一个 include。2.2 FName SubobjName子对象在类定义里的身份证FName 是 UE 的字符串系统和 FString 的区别简要说FName 是存储在全局名称表里的只读字符串比较速度极快适合作为标识符。在CreateDefaultSubobject里这个 FName 不是随便起的它是子对象在当前类的默认子对象表里的 key。这就延伸出两条硬性规则名字在当前类继承链上必须唯一。同一个类体系里不能有两个默认子对象叫同一个名字。注意 FName 的比较默认不区分大小写Mesh 和 mesh 很可能被视为同一个名字所以不要靠大小写区分。不能传NAME_None。引擎内部会直接检查名字为空意味着无法登记到默认子对象表。实战命名建议组件成员变量习惯用 Comp 后缀FName 参数用 PascalCase比如 MeshComp、CollisionComp。但别叫 RootComponent因为引擎默认的根组件可能占用这个名字。你自建的根组件叫 RootComp 通常没问题避免和AActor内部的默认命名冲突。另外FName 参数虽然用TEXT()宏包裹但它不是运行时拼接字符串。如果你在构造函数里写FName(*FString::Printf(TEXT(MeshComp_%d), Index))虽然语法能过但不推荐。默认子对象应该在类定义阶段稳定存在动态名字既可能破坏默认子对象表的一致性也会给调试增加负担。2.3 bTransient 以及其他被省略的参数标题里那个...不是 C 可变参数它代表CreateDefaultSubobject在实现上还有若干内部参数。在日常调用里你基本只需要关心第一个 FName 和第二个 bool。第二个参数bTransient默认 false意思是这个默认子对象是否是瞬态对象。如果传 true这个子对象不会参与某些持久化序列化流程常用于运行时临时组件、装饰性对象。大多数组件保持默认 false 就行让它正常保存到关卡和类默认配置中。除了这两个参数你不需要关心内部重载的那一串参数什么bCopyTransientsFromClassDefaults、ObjectInitializer之类是引擎源码实现细节。知道它们存在有助于日后看引擎源码时不懵但业务代码里别去动它们。3. 搭建一个完整 Actor 组件树从声明到运行一步步复现前面把原理讲透了现在动手写。第3节以一个自定义 Actor 为例完整走一遍用CreateDefaultSubobject搭建组件树的过程包含头文件声明、构造函数实现以及编辑器里的检验方法。3.1 头文件UPROPERTY 才是组件的户口本先看头文件// MyActor.h #pragma once #include CoreMinimal.h #include GameFramework/Actor.h #include MyActor.generated.h class USceneComponent; class UStaticMeshComponent; UCLASS() class MYPROJECT_API AMyActor : public AActor { GENERATED_BODY() public: AMyActor(); UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category MyActor) USceneComponent* RootComp; UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category MyActor) UStaticMeshComponent* MeshComp; };两个细节值得说第一前向声明class USceneComponent;和class UStaticMeshComponent;是好习惯避免在头文件里 include 过多组件头文件加快编译。但是如果你在 cpp 里调用CreateDefaultSubobjectUStaticMeshComponent必须在 cpp 顶部 includeComponents/StaticMeshComponent.h否则用不了R::StaticClass()。第二组件指针必须用 UPROPERTY 标记。虽然组件因为 Outer 是 thisGC 可以通过 Outer 找到它但你不加 UPROPERTY蓝图编辑器、序列化系统和反射系统都看不到这个引用。很多诡异问题都是组件没加 UPROPERTY 导致的。可见性选择上VisibleAnywhere适合组件因为你不会在细节面板给组件赋值新对象只是查看BlueprintReadOnly让蓝图侧能访问到。3.2 构造函数先建根再挂子组件接下来是构造函数// MyActor.cpp #include MyActor.h #include Components/SceneComponent.h #include Components/StaticMeshComponent.h AMyActor::AMyActor() { RootComp CreateDefaultSubobjectUSceneComponent(TEXT(RootComp)); RootComponent RootComp; MeshComp CreateDefaultSubobjectUStaticMeshComponent(TEXT(MeshComp)); MeshComp-SetupAttachment(RootComp); MeshComp-SetRelativeLocation(FVector(0.f, 0.f, 100.f)); MeshComp-SetRelativeRotation(FRotator::ZeroRotator); }顺序上有讲究先创建根组件然后把它赋给RootComponent再创建子组件。Actor 如果没有根组件在场景里就没有可靠的 transform 参考StaticMeshComponent 可以作为根但通常我们额外用一个 SceneComponent 当根便于统一做旋转、位移和层级管理。SetRelativeLocation设置的是相对于父组件的偏移这里的父组件是 RootComp所以 MeshComp 在根的正上方 100 个单位。刚创建的组件默认使用它所属类的 CDO 属性你可以在创建后覆盖任意属性这符合默认子对象的先有模板再改实例的逻辑。3.3 SetupAttachment 和 AttachToComponent 到底差在哪很多新人在这里踩坑。SetupAttachment是 SceneComponent 的构造阶段接口它不会立即执行附加只是把父子关系记下来等组件真正注册时再挂接。AttachToComponent则是运行时接口它要立即改变附加关系逻辑上组件应当处于已注册状态。构造函数里组件还没注册调用AttachToComponent经常会在日志里看到 not registered 之类警告附加失败。所以规则很简单构造函数里用SetupAttachmentBeginPlay、Tick 或事件处理里用AttachToComponent。如果某个组件是UActorComponent而不是 SceneComponent它没有位置概念也不需要 SetupAttachment创建出来之后主要设置属性、决定是否 Tick。// 运行时动态组件的正确附加写法 MeshComp-AttachToComponent(RootComp, FAttachmentTransformRules::KeepRelativeTransform);FAttachmentTransformRules里的选项决定附加时位置、旋转、缩放怎么处理构造阶段用SetupAttachment时还没有这些规则因为一切都是延迟到注册时计算的。3.4 编辑器里的表现把这些组件拖出来看看写完编译后把 AMyActor 拖进场景右侧 Details 面板的 Components 里能看到 RootComp 和 MeshComp。展开层次MeshComp 挂在 RootComp 下面相对位置是 Z 轴 100。在此基础上做一个蓝图子类打开蓝图编辑器组件面板显示的同样是这两个组件。你可以把 MeshComp 改成附加到其他组件也可以修改静态网格资源不需要写一行 C 就能对构造函数创建的组件做二次编辑。这就是默认子对象机制给到工作流的便利。我自己的习惯是C 构造函数只做必要初始化复杂的属性配置比如贴图、材质、碰撞预设放到蓝图子类里去做。这样团队里不碰 C 的同事也能参与调组件也让 C 层保持精简。4. 一写就炸的五个瞬间错误触发链路与排查方法这部分全是实战里反复出现的问题。我按症状、原因、排查方法的方式写方便你对照。4.1 错误一把 CreateDefaultSubobject 放进 BeginPlay 或自定义 Init 函数症状编译正常运行到该行时触发断言或 ensure 失败日志里出现类似CreateDefaultSubobject can only be called from within a constructor的提示。原因默认子对象的登记发生在 FObjectInitializer 这个构造上下文里。BeginPlay 阶段 Actor 已经构造完成上下文已经销毁函数自然没法再工作。排查链路先看报错调用栈找到对应行号确认它不在构造函数内。然后问自己我要的是默认创建还是动态创建默认创建就挪回构造函数如果是想随逻辑动态生成组件改用NewObject我在第5节细讲。4.2 错误二组件名重名症状构造函数里第二次调用CreateDefaultSubobject时触发断言报错信息明确指出A default subobject already exists with the name XXX。原因默认子对象表以 FName 为 key同一类继承链上不允许重复。最常见的重名场景有三种你自己创建了两个组件名字都叫 Comp你创建的名字和父类、引擎默认组件名冲突典型是 RootComponent名字只差大小写FName 比较默认不区分大小写。排查方法搜索当前类和所有父类的构造函数里所有CreateDefaultSubobject调用列出名字逐个比对。我自己的做法是尽量用常量或者集中管理组件名避免散落的字符串字面量。项目一旦大起来字符串重名很难肉眼查集中放置能省不少时间。4.3 错误三子类里试图重新创建父类组件症状自定义 AActor 子类父类构造函数创建了 MeshComp子类构造函数又写了一句CreateDefaultSubobjectUStaticMeshComponent(TEXT(MeshComp))运行崩溃。原因父类构造函数先执行默认子对象表里已经登记了 MeshComp子类重复登记同名 key必然冲突。这本质上还是4.2的重名问题只是发生在继承关系里。正确姿势如果子类确实需要替换组件类型应该让父类提供一个可覆写的函数比如父类有一个protected virtual UStaticMeshComponent* CreateMeshComp()默认返回CreateDefaultSubobjectUStaticMeshComponent子类覆写这个函数返回自己自定义的组件类型。这样既保留了默认子对象的登记机制又避免了重名冲突。如果没有替换需求只是在子类里修改父类组件的属性直接拿到父类组件指针调用SetStaticMesh、SetCollisionProfileName之类的方法即可完全不需要重新创建。4.4 错误四构造函数里调用 AttachToComponent症状运行时日志出现AttachToComponent failed because component ... is not registered组件在场景里没有挂到预期父级下面。原因如 3.3 所说构造阶段组件尚未注册。AttachToComponent处理的是已经存在、需要改变父级的情况它假定组件已注册。这个错误在复制粘贴代码时特别容易发生很多示例代码喜欢把AttachToComponent写在构造函数里一定要看仔细。排查方法把AttachToComponent换成SetupAttachment。如果确实需要运行时重新附加把调用放到 BeginPlay 之后的事件里。简单判断标准构造函数里出现的附加操作基本都应该是SetupAttachment。4.5 错误五用 CreateDefaultSubobject 创建非组件 UObject症状编译能过但创建的 UObject 在编辑器里不出现或者序列化行为很奇怪有些类甚至会直接触发断言提示不能创建默认子对象。原因CreateDefaultSubobject是为组件的默认子对象设计的。组件要参与 Actor 的注册流程、组件面板、蓝图覆写普通 UObject 比如 UDataAsset、工具类既没有 transform 也不需要注册你把它强行放进默认子对象表等于用一条不适合的通道去创建对象。引擎内部虽然会做类型检查但不同版本的表现不太一样与其依赖报错不如在代码设计上避开。正确姿势普通 UObject 的创建运行时直接用NewObject。资源类数据通常用 UPROPERTY 引用资产而不是动态实例化。很多人容易混淆是因为CreateDefaultSubobject从纯语法角度确实可以接受任意 UClass但合适不等于能用。5. CreateDefaultSubobject 与 NewObject一张表解决到底用哪个写到这里你已经清楚CreateDefaultSubobject是为构造函数阶段准备的默认子对象接口。但实际项目中动态创建组件的需求也一样频繁所以我把这两个函数的取舍讲透。5.1 核心差异默认子对象 vs 运行时对象维度CreateDefaultSubobjectNewObject调用时机构造函数内运行期可随时调用返回类型模板参数指定类型 R*模板参数指定类型 T*登记为默认子对象是否蓝图组件面板可见是否蓝图子类可覆写是否创建组件后是否已注册随着 Actor 构造流程自动处理未注册需要手动调用 RegisterComponent典型场景C 构造函数创建稳定组件骨架运行时按逻辑动态生成组件、普通 UObjectGC 方面两者都依赖引用链可达。所以创建的指针要存为 UPROPERTY 成员变量或者保存到被 UPROPERTY 标记的容器里否则存在被 GC 回收的风险。5.2 什么时候不该用 CreateDefaultSubobject如果你的组件是运行到一半才出现的比如玩家拾取武器武器网格才挂到角色挂点上这种情况没有默认子对象的概念。对象在每次游戏流程里都可能不存在把它硬塞进类默认子对象表不符合逻辑。这时正确选择是NewObject创建组件、设置属性、注册、附加到挂点。要注意NewObject出来的组件默认未注册必须手动调用RegisterComponent()让它进入场景更新逻辑。如果是 SceneComponent一般先设置相对变换再注册再附加到目标挂点或者按官方推荐顺序执行但至少不能漏掉RegisterComponent。5.3 动态创建武器的代码参考下面是一个运行时动态创建武器网格组件的例子UStaticMeshComponent* WeaponMesh NewObjectUStaticMeshComponent(this, UStaticMeshComponent::StaticClass(), TEXT(WeaponMesh_01)); if (WeaponMesh) { WeaponMesh-SetStaticMesh(WeaponMeshAsset); WeaponMesh-SetRelativeLocation(FVector::ZeroVector); WeaponMesh-RegisterComponent(); WeaponMesh-AttachToComponent(WeaponSocket, FAttachmentTransformRules::SnapToTargetNotIncludingScale); WeaponMeshComponent WeaponMesh; }这里几个关键点NewObject第一个参数是 Outer传入 this让 Actor 成为组件的归属者。GC 会通过 Outer 追踪它显式命名时避免和构造函数里的默认子对象重名否则可能造成混乱。如果省略名称引擎会自动生成唯一名但动态生成的组件建议还是给一个清晰的名字方便调试RegisterComponent让组件进入 Actor 的组件列表之后才能被 Tick、碰撞等系统正常处理。AttachToComponent放在注册后组件已经具备挂接条件动态武器组件要保存到 UPROPERTY 成员变量WeaponMeshComponent中否则下个 GC 周期对象可能就没了。5.4 我自己的一点习惯最后分享一个我在这两个函数之间划线的方法凡是需要在蓝图里被策划、美术直接调整的组件一律在构造函数用CreateDefaultSubobject凡是完全由 C 逻辑按需生成、生命周期很短的临时对象用NewObject。这样分界非常清楚代码里扫一眼就知道一个组件是稳定的骨架还是临时的挂件。如果你正在学 UE5 C不妨动手把前面示例里的 RootComp 改成 MeshComp 跑一次看看编辑器怎么报错再试着把CreateDefaultSubobject挪到 BeginPlay 里跑一次感受构造上下文的边界。这些报错信息看懂了比死记一百条规则都管用。
返回列表