第12章 委托、结构体与反射系统的内存表示
本章目标:深入剖析UE委托(Delegate)系统的内存语义——单播/多播/动态委托的数据布局与Lambda捕获;理解UStruct和蓝图生成类的内存模型;揭示反射系统(UClass、FProperty链)的内存开销。
12.1 委托系统概述
UE的委托系统分为四种主要类型,每种有不同的内存特征:
12.2 单播委托——TDelegate
12.2.1 内存布局
// UE5 TDelegate核心存储template<typenameRetValType,typename...ParamTypes>classTDelegate{// 核心成员FDelegateHandle Handle;// 8字节:唯一标识符TDelegateInstanceInterface*Instance;// 8字节:指向实际绑定// 可能还包含Payload存储};// 实际上UE5使用了一种内联存储优化的设计classFDelegateBase{FDelegateAllocatorType::ForElementType<FAlignedInlineDelegateType>DelegateAllocator;uint32 DelegateSize;};12.2.2 不同绑定方式的内存开销
// 1. BindRaw — 原始成员函数指针Delegate.BindRaw(this,&MyClass::OnEvent);// 存储:对象指针(8B) + 函数指针(8B) = 16字节// 2. BindUObject — UObject成员函数Delegate.BindUObject(MyActor,&AMyActor::OnEvent);// 存储:UObject弱引用(8B) + 函数指针(8B) = 16字节// 会检查UObject是否存活(防止悬垂)// 3. BindSP — TSharedPtr绑定Delegate.BindSP(SharedObj,&FMyClass::OnEvent);// 存储:TWeakPtr(16B) + 函数指针(8B) = 24字节// 4. BindLambda — Lambda表达式Delegate.BindLambda([this,CapturedValue](int32 Param){// ...});// 存储:Lambda对象大小取决于捕获列表!12.2.3 Lambda捕获的内存
Lambda的内存大小 = 所有捕获变量的大小之和(对齐后):
// 空捕获:1字节(C++标准要求非零大小)autoEmpty=[](){};// sizeof = 1// 值捕获一个int:4字节int32 Val=42;autoCaptureVal=[Val](){};// sizeof = 4// 引用捕获一个int:8字节(指针大小)autoCaptureRef=[&Val](){};// sizeof = 8// 值捕获一个FString:16字节(TArray内存布局拷贝)FString Str=TEXT("Hello");autoCaptureStr=[Str](){};// sizeof = 16,且堆分配了字符串数据!// 值捕获this + 多个变量autoHeavy=[this,Str,Val,SomeVector](){};// ~50+字节陷阱:按值捕获FString、TArray等拥有堆资源的对象时,每次绑定都会触发深拷贝。在高频调用场景中,这成为隐性内存压力。
12.2.4 内联存储优化
UE对小型绑定使用内联存储(类似SBO——Small Buffer Optimization):当绑定数据不超过内联阈值(通常约48-64字节)时直接存储在Delegate对象内部,无需堆分配;超过阈值时才从堆上分配存储空间。大多数常规绑定(BindRaw/BindUObject)都在内联范围内。
12.3 多播委托——TMulticastDelegate
12.3.1 内存结构
template<typename...ParamTypes>classTMulticastDelegate:publicTMulticastDelegateBase<FWeakObjectPtr>{// 基类存储typedefTArray<TDelegateBase>InvocationList;InvocationList InvocationListArray;// 即:TArray<绑定存储>FDelegateHandle CompactThreshold;// 用于延迟压缩};解绑后的"空洞":Remove()不立即压缩数组,而是标记为无效,延迟到Broadcast()时清理,或达到 CompactThreshold 时压缩。
12.3.2 多播的内存增长
// 典型场景:Event DispatcherDECLARE_MULTICAST_DELEGATE_OneParam(FOnHealthChanged,float);FOnHealthChanged OnHealthChanged;// 多个监听者绑定OnHealthChanged.AddUObject(Widget1,&UWidget::OnHealthUpdate);OnHealthChanged.AddUObject(Widget2,&UWidget::OnHealthUpdate);OnHealthChanged.AddUObject(Widget3,&UWidget::OnHealthUpdate);// InvocationList增长,每个绑定~32-48字节// 内存估算// 100个监听者 × ~40字节 ≈ 4KB// 通常OK,但在大量Actor上的大量事件可能累积12.4 动态委托——Dynamic Delegate
12.4.1 基于FName的绑定
动态委托不存储函数指针,而是存储函数名称字符串(FName):
classFScriptDelegate{TWeakObjectPtr<UObject>Object;// 8字节:绑定对象FName FunctionName;// 8字节:函数名称// 总计:16字节};// 动态多播classFMulticastScriptDelegate{typedefTArray<FScriptDelegate>FInvocationList;FInvocationList InvocationList;// 16字节TArray头 + N×16字节};12.4.2 为什么用FName存函数名?
- 优点:可序列化(存档存储FName),可在蓝图中使用。
- 缺点:调用开销远大于直接函数指针——需要FName查找、ProcessEvent虚拟机分发。
12.4.3 动态 vs 静态委托内存对比
| 单播 | 多播(10个绑定) | |
|---|---|---|
| 静态委托 | ~32-64字节 | ~400-640字节 |
| 动态委托 | 16字节 | 176字节(16+10×16) |
动态委托实例更小(只存FName+指针),但调用更慢(间接查找)。
12.5 UStruct——结构体的内存布局
12.5.1 USTRUCT与原生struct的区别
// 原生C++ structstructFNativeData{floatX;// 4字节floatY;// 4字节int32 Count;// 4字节};// sizeof = 12字节(无额外开销)// USTRUCT版本USTRUCT(BlueprintType)structFReflectedData{GENERATED_BODY()UPROPERTY()floatX;// 4字节UPROPERTY()floatY;// 4字节UPROPERTY()int32 Count;// 4字节};// sizeof = 12字节(实例大小相同!)// 但 UScriptStruct 元数据额外占用内存12.5.2 关键区别:实例 vs 元数据
USTRUCT实例大小 = 与原生struct相同(GENERATED_BODY()不增加成员)。但引擎会为每个USTRUCT类型创建一个 UScriptStruct 对象。
UScriptStruct"FReflectedData"的元数据布局:
- StructSize: 12
- PropertiesSize: 12
- PropertyLink(FProperty链):
FFloatProperty("X", Offset=0)FFloatProperty("Y", Offset=4)FIntProperty("Count", Offset=8)
- SuperStruct: nullptr
- StructFlags: …其他元信息
UScriptStruct本身 ≈ 200-400 字节(每类型一个,非每实例),每个FProperty ≈ 100-200 字节。
12.5.3 FProperty链——属性的内存元数据
classFProperty{// 核心字段FName NamePrivate;// 8字节:属性名int32 ArrayDim;// 4字节:固定数组维度int32 ElementSize;// 4字节:单元素大小EPropertyFlags PropertyFlags;// 8字节:属性标志uint16 RepIndex;// 2字节:复制索引int32 Offset_Internal;// 4字节:在结构体中的偏移量FProperty*PropertyLinkNext;// 8字节:链表下一个FProperty*NextRef;// ... 更多元数据// 虚表指针:8字节};// 总计:基类 ~80+ 字节,派生类(如FStructProperty)更大12.5.4 内存偏移量的作用
FProperty中的Offset_Internal是GC和序列化的关键:
// GC如何通过反射遍历引用voidUClass::AssembleReferenceTokenStream(){for(FProperty*Prop=PropertyLink;Prop;Prop=Prop->PropertyLinkNext){if(Prop->ContainsObjectReference()){// 记录偏移量到TokenStreamEmitObjectReference(Prop->Offset_Internal,...);}}}// 运行时通过偏移量读取属性值void*PropAddr=(uint8*)ObjectPtr+Property->Offset_Internal;// 直接内存偏移,无需虚函数调用12.6 蓝图生成类的内存
12.6.1 UBlueprintGeneratedClass
蓝图编译生成的类继承自UClass,但有额外的内存开销:
UClass(C++反射元数据)——固定大小,加载时创建:
- FProperty链
- UFunction列表
UBlueprintGeneratedClass——包含UClass的所有数据,外加蓝图特有的额外内存:
- 蓝图新增属性的FProperty链
- 蓝图函数的UFunction(含字节码)← 额外内存
- 蓝图节点编译后的字节码 ← 额外内存
- 事件图的UFunction ← 额外内存
- 默认对象(CDO)
12.6.2 蓝图变量的内存
蓝图中定义的变量存储在实例的尾部(通过动态偏移量访问):
| 区域 | 内容 | 说明 |
|---|---|---|
| UObjectBase | 公共头 | 40字节(对象起始) |
| C++父类成员变量 | AMyActor自身成员 | sizeof(AMyActor) |
| 蓝图成员变量区域 | BP_Health (float) | 偏移 = sizeof(AMyActor)+0 |
| BP_Name (FString) | 偏移 = sizeof(AMyActor)+4 | |
| BP_Items (TArray) | 偏移 = sizeof(AMyActor)+20 |
蓝图成员通过FProperty::Offset_Internal访问,运行时按偏移读写,无需编译期类型信息。
12.6.3 蓝图的内存代价
一个蓝图类的额外内存开销(相比纯C++):
| 组成部分 | 典型大小 | 说明 |
|---|---|---|
| UBlueprintGenClass | ~2-5 KB | 反射元数据 |
| 蓝图函数字节码 | ~1-50 KB | 取决于图表复杂度 |
| CDO额外变量 | 按需 | |
| 蓝图变量FProperty | 每个~150B |
1000个不同蓝图类 × ~10KB/类 ≈ 10MB 元数据内存
12.7 反射系统的总体内存开销
12.7.1 反射元数据组成
UE反射系统在内存中的存在:
- 每个 UClass ≈ 500-2000 字节
- 每个 UFunction ≈ 200-800 字节
- 每个 FProperty ≈ 100-300 字节
- 每个 UEnum ≈ 200-500 字节
- 每个 UScriptStruct ≈ 200-400 字节
典型项目的反射元数据总量:
| 类别 | 数量 | 估算内存 |
|---|---|---|
| UClass | ~5,000 | ~5 MB |
| UFunction | ~20,000 | ~10 MB |
| FProperty | ~80,000 | ~16 MB |
| UEnum | ~2,000 | ~0.5 MB |
| UScriptStruct | ~3,000 | ~1 MB |
| 总计 | ~32 MB |
在大型项目中,反射元数据可能占到 30-50 MB
12.7.2 反射元数据的生命周期
12.8 FInstancedStruct——运行时多态结构体
UE5引入的FInstancedStruct允许在运行时持有不同类型的USTRUCT实例:
// FInstancedStruct可以存储任意USTRUCTFInstancedStruct Instance;Instance.InitializeAs<FMyStructA>();// 分配FMyStructA大小的内存// 稍后可以Instance.InitializeAs<FMyStructB>();// 重新分配为FMyStructB// 内部结构structFInstancedStruct{constUScriptStruct*ScriptStruct;// 8字节:类型信息uint8*StructMemory;// 8字节:堆上的实例数据// 总计:16字节头 + 堆上的实例数据};使用场景:数据驱动的能力系统、可配置的行为树节点参数等。
12.9 实践建议
委托选择
需要蓝图绑定? → 动态委托(DECLARE_DYNAMIC_...) 只需C++绑定? → 静态委托(DECLARE_DELEGATE/MULTICAST) 需要序列化? → 动态委托 性能敏感的高频事件? → 静态委托(避免FName查找开销)Lambda捕获注意事项
// ✗ 按值捕获大对象Delegate.BindLambda([BigArray,BigMap](){...});// 深拷贝!// ✓ 按引用捕获(确保生命周期安全)Delegate.BindLambda([&BigArray](){...});// 仅8字节指针// ✓ 或捕获指针/引用Delegate.BindLambda([Ptr=&BigArray](){...});减少反射开销
// 不需要蓝图访问的变量不加UPROPERTYstructFMyStruct{UPROPERTY()floatImportantValue;// 反射 + GC追踪floatCachedValue;// 无反射开销};12.10 小结
静态委托(~32-64字节/绑定)使用函数指针直接调用,性能最优;动态委托(16字节/绑定)使用FName间接查找,支持蓝图和序列化。
Lambda捕获直接影响委托大小——按值捕获大对象会导致深拷贝,应优先捕获引用或指针。
USTRUCT实例不增加开销(与原生struct相同大小),但每个类型会产生一个UScriptStruct元数据对象(~200-400字节)。
蓝图类在C++基类基础上增加了反射元数据、字节码和动态属性存储。大型项目中反射系统总计可占30-50MB内存。
FProperty::Offset_Internal是连接反射与内存的核心桥梁——GC、序列化、蓝图VM都通过偏移量直接访问对象内存。