UE 新手最常困惑的现象之一:在编辑器里改了一个 Blueprint 类的默认值,场景中已有的几十个实例跟着变了;但改过的实例又不受新默认值影响。另一个常见困惑:构造函数里 CreateDefaultSubobject 创建的组件,在编辑器里被拖来拖去,再 new 一个实例时组件位置为什么不是构造函数里的值而是编辑器里的值。这两个问题的答案都指向同一个对象——ClassDefaultObject(CDO)。下面基于 UE5.3 把它的机制拆开。
CDO 是类的"出厂快照"
每个 UClass 都有一个 CDO,存在 UClass::ClassDefaultObject 上。访问入口是 UClass::GetDefaultObject(bool bCreateIfNeeded)(UObject/Class.h)。CDO 的创建发生在类首次被"需要默认值"的时候——首次 GetDefaultObject() 调用、首次 NewObject、或编辑器打开类属性面板,都会触发。创建路径在 UObject/UObjectGlobals.cpp 的 StaticAllocateObject,带 RF_ClassDefaultObject 标记。
CDO 的属性值来自三层的叠加,顺序很关键:
- C++ 构造函数:父类构造函数先跑,子类后跑。这里设的值是最底层默认。
- 配置文件的
[ClassName]节:config=XXX标记的属性从 ini 读值覆盖构造函数结果。 - 蓝图 / 编辑器序列化值:如果类是 Blueprint 生成类,CDO 的属性还来自 SCS(SimpleConstructionScript)和 Details 面板的序列化数据。
这意味着 CDO 不是"构造函数的结果",而是"构造函数 + 配置 + 编辑器数据"三者叠加后的最终快照。修改其中任何一层,CDO 都会变。
NewObject 怎么用上 CDO
NewObject 创建实例时,属性初始化阶段(上一篇讲的 StaticConstructObject_Internal 第 4 步)做了一件关键的事:以 CDO 的内存为模板,把新对象的属性区整体拷贝一遍,再处理指针属性的深拷贝。源码里这个函数是 UClass::InstanceObjectGraph,调用链是 StaticConstructObject_Internal → InitializeStruct(在 UObject/Class.cpp)。
拷贝分两步:
- 浅拷贝:POD 属性(int、float、FVector 等)直接 memcpy。
- 深拷贝:
UPROPERTY标记的对象指针、FString、TArray 等需要逐个处理。对象指针类属性会触发"实例化子对象"——给新实例 new 一份子对象的副本(Outer 指向新实例),而不是共享 CDO 的那个。
为什么深拷贝子对象?因为如果实例间共享同一个子对象,A 实例改了子对象的属性,B 实例也会变——这显然不对。CDO 的子对象只作为"模板"存在,每个实例拿到的是独立副本。
为什么改 CDO 能影响所有实例
理解了拷贝机制,这个问题就清楚了。NewObject 时属性是从 CDO 拷贝来的,所以:
- 没被实例改过的属性:实例里的值就是 CDO 拷贝来的值。改 CDO 之后再 NewObject,新实例拿到新值;但已存在的实例不会变——它们的属性区是独立的拷贝。
- 编辑器里"已有实例跟着变":这是编辑器额外做的事,不是 CDO 机制本身。编辑器在 CDO 变更后会对场景里所有实例做
RerunConstructionScripts,把那些没有被实例覆盖过的属性重新同步成新 CDO 值。判断"是否被覆盖"用的是FProperty的差异比较(Identical函数),值和 CDO 一致就视为未覆盖。
所以"改 CDO 影响所有实例"的精确表述是:改 CDO 影响之后 NewObject 的实例,以及编辑器中未覆盖过该属性的现有实例(通过重新同步实现)。运行时 SpawnActor 出来的实例,Spawn 之后改 CDO 不会影响它。
构造函数的特殊地位
CDO 的构造函数在 CDO 创建时跑一次。之后的每个实例 NewObject 时也跑构造函数,但此时构造函数设的默认值会被 CDO 拷贝覆盖——因为属性初始化(拷贝 CDO)发生在 C++ 构造之后。这就是为什么"构造函数里设的值在编辑器里改了之后,新实例拿到的不是构造函数的值":构造函数设的值在拷贝阶段被覆盖了。
推论:构造函数里只能做那些不会被属性初始化覆盖的事。安全的做法:
CreateDefaultSubobject:登记子对象,属性由后续流程处理。- 设置非 UPROPERTY 的普通 C++ 成员:这些成员不参与属性拷贝,构造函数值就是最终值。
- 初始化
bReplicates这类在构造时就要确定的 flag。
危险的做法:
- 在构造函数里读
GetWorld():CDO 构造时没有 World,直接崩溃或 ensure。 - 在构造函数里设 UPROPERTY 的值并期望它是"最终默认":蓝图可以覆盖它。
类默认对象的层级链
CDO 支持继承链。UMyChildClass 的 CDO 创建时,先以父类 CDO 为模板初始化,再叠加自己的构造函数和配置。这就是"子类不改就用父类默认"的实现机制。GetArchetype() 返回的其实就是这条链:对于类默认对象,archetype 是父类的 CDO;对于普通实例,archetype 是自己类的 CDO。UObject::GetArchetype 在 UObject/UObject.cpp:
UObject* UObject::GetArchetype() const
{
UObject* Archetype = GetClass()->GetDefaultObject();
// ...
}
编辑器里 Details 面板的"重置为默认值"按钮,调的就是把属性重置回 archetype 值——也就是 CDO 值。
运行时修改 CDO 的正确方式
GetDefault<T>() 返回 const 指针是有意的——CDO 原则上只读。运行时确实需要改默认值(比如难度系统调整所有敌人的基础血量),用 GetMutableDefault<T>():
UMyEnemySettings* MutableCDO = GetMutableDefault<UMyEnemySettings>();
MutableCDO->BaseHealth = 200.f; // 影响之后 NewObject 的实例
要守住两条规矩:
- 只改不影响内存布局的属性。改默认值是安全的;CDO 上挂着类的属性链信息,乱动非属性成员会破坏反射数据。
- 改完自己负责同步存量实例。引擎不会帮你遍历已存在的实例,要么自己维护实例列表逐个刷,要么接受"只对新实例生效"。
多人游戏里还要注意:CDO 的属性如果是 Replicated,改服务端的 CDO 不会自动同步到客户端的 CDO——复制只覆盖实例属性。两端默认值要一致,得靠数据资产或 RPC 显式同步。
CDO 与热重载 / Live Coding
编辑器下重编译 C++ 类时,旧的 UClass 被替换(HotReload / Live Coding 会生成 REINST_ 前缀的过渡类),CDO 也要重建。重建过程中旧 CDO 的属性值会通过序列化"搬运"到新 CDO——这就是重编译后"默认值还在"的原因。但构造函数里设的非 UPROPERTY 成员不会搬运,会回到新构造函数的初始值。如果重编译后某些运行时状态"丢了",先怀疑它是不是存在了非 UPROPERTY 成员里。
实战结论
- 改 CDO 的最佳时机是模块启动或类注册后尽早,晚了已存在的实例不会自动跟进(运行时)。
- 构造函数里给 UPROPERTY 赋值只影响 CDO 本身,不影响蓝图里改过的值——要改"硬编码默认"就改构造函数,要改"可被覆盖的默认"就改蓝图或 ini。
GetDefault<T>()是访问 CDO 的便捷模板,等价于T::StaticClass()->GetDefaultObject<T>()。拿它来读默认值是安全的,不要改它——改了影响所有后续实例。- CDO 本身是个完整 UObject,也占内存、也参与 GC(但加了 root 标记不会被回收)。类的内存预算里要算上它。
CDO 是 UE"数据驱动 + 继承"设计的地基。理解了它,Blueprint 的"默认值"机制、编辑器的属性同步、配置文件的覆盖顺序就都说得通了。