《InsideUE4》UObject 类型系统信息收集

2017年03月29日 17:10 0 点赞 0 评论 更新于 2025-11-21 21:17
《InsideUE4》UObject 类型系统信息收集

本文将详细介绍类型信息的收集阶段,包括 C++ Static 自动注册模式、UE Static 自动注册模式,以及各类结构(Class、Enum、Struct、Function、UObject)信息的收集过程。

C++ Static 自动注册模式

在程序开发中,当需要在程序启动后往一个容器里注册一些对象或记录某些信息时,一种直接的方式是在程序启动后手动逐个调用注册函数,示例代码如下:

#include "ClassA.h"
#include "ClassB.h"

int main()
{
ClassFactory::Get().Register<ClassA>();
ClassFactory::Get().Register<ClassB>();
// ...
}

这种方式的缺点在于必须手动包含头文件并逐个注册,当需要添加新的注册项时,只能手动在文件中依次添加条目,可维护性较差。

根据 C++ static 对象会在 main 函数之前初始化的特性,可以设计出一种 static 自动注册模式。新增加注册条目的时候,只要包含相应的类头文件(.h)和实现文件(.cpp),就可以自动在程序启动 main 函数前执行一些操作。简化的代码示例如下:

// StaticAutoRegister.h
template <typename TClass>
struct StaticAutoRegister
{
StaticAutoRegister()
{
Register(TClass::StaticClass());
}
};

// MyClass.h
class MyClass
{
// ...
};

// MyClass.cpp
#include "StaticAutoRegister.h"
const static StaticAutoRegister<MyClass> AutoRegister;

这样,在程序启动时就会执行 Register(MyClass),将因新添加类而产生的改变行为限制在新文件本身。对于一些顺序无关的注册行为,这种模式尤为合适。利用这个 static 初始化特性,还有很多变种,比如可以把 StaticAutoRegister 声明为 MyClass 的一个静态成员变量。

需要注意的是,这种模式只能在独立的地址空间才能有效,如果该文件被静态链接且没有被引用到的话,则很可能会绕过 static 的初始化。不过 UE 采用的是 dll 动态链接,且不存在静态库引用其他库却不引用文件的情况,因此避免了该问题。也可以在某个地方强制包含该文件来触发 static 初始化。

UE Static 自动注册模式

UE 同样采用了这种 static 自动注册模式,以下是相关示例代码:

template <typename TClass>
struct TClassCompiledInDefer : public FFieldCompiledInInfo
{
TClassCompiledInDefer(const TCHAR* InName, SIZE_T InClassSize, uint32 InCrc)
: FFieldCompiledInInfo(InClassSize, InCrc)
{
UClassCompiledInDefer(this, InName, InClassSize, InCrc);
}
virtual UClass* Register() const override
{
return TClass::StaticClass();
}
};
static TClassCompiledInDefer<TClass> AutoInitialize##TClass(TEXT(#TClass), sizeof(TClass), TClassCrc);

// 或者
struct FCompiledInDefer
{
FCompiledInDefer(class UClass *(*InRegister)(), class UClass *(*InStaticClass)(), const TCHAR* Name, bool bDynamic, const TCHAR* DynamicPackageName = nullptr, const TCHAR* DynamicPathName = nullptr, void (*InInitSearchableValues)(TMap<FName, FName>&) = nullptr)
{
if (bDynamic)
{
GetConvertedDynamicPackageNameToTypeName().Add(FName(DynamicPackageName), FName(Name));
}
UObjectCompiledInDefer(InRegister, InStaticClass, Name, bDynamic, DynamicPathName, InInitSearchableValues);
}
};
static FCompiledInDefer Z_CompiledInDefer_UClass_UMyClass(Z_Construct_UClass_UMyClass, &UMyClass::StaticClass, TEXT("UMyClass"), false, nullptr, nullptr, nullptr);

这些都是对该模式的应用,将 static 变量声明并用宏包装一层,就可以实现一个简单的自动注册流程。

信息收集

在上文中,我们详细介绍了 Class、Struct、Enum、Interface 的代码生成信息。显然,生成这些信息是为了后续使用。但在使用之前,需要将分散在各个 .h 和 .cpp 文件中的元数据收集到所需的数据结构中,以便后续阶段使用。

为了让新创建的类不修改既有的代码,我们选择了去中心化的方式,为每个新类生成自己的 cpp 生成文件。但这样会带来一个新问题:这些 cpp 文件中的元数据分散在各个模块的 dll 中,需要一种方法将这些数据重新整合,这就是前面提到的 C++ Static 自动注册模式。通过这种模式,每个 cpp 文件中的 static 对象在程序启动时都有机会执行一些操作,包括信息收集工作。

在 UE4 中,程序启动时利用 Static 自动注册模式将所有类的信息逐一登记。接下来就是顺序问题,众多类之间可能存在依赖关系,如何解决呢?众所周知,UE 以 Module 来组织引擎结构(关于 Module 的细节将在以后章节叙述),各个 Module 可以通过脚本配置选择性地编译加载。在游戏引擎的众多模块中,玩家自己的 Game 模块处于较高层次,依赖于引擎其他更基础的底层模块,其中最底层的是 Core 模块(C++ 的基础库),接着是 CoreUObject,它是实现 Object 类型系统的模块。因此,在类型系统注册过程中,不仅要注册玩家的 Game 模块,还要注册 CoreUObject 本身的一些支持类。

很多人可能担心多个模块的静态初始化顺序正确性如何保证。在 C++ 标准中,不同编译单元的全局静态变量的初始化顺序并没有明确规定,完全由编译器决定。解决该问题的最佳方法是尽可能避免这种情况,在设计上让各个变量不相互引用依赖,同时采用二次检测的方式避免重复注册,或者触发一个强制引用来确保前置对象已经初始化完成。目前在 MSVC 平台上,先注册玩家的 Game 模块,接着是 CoreUObject,然后是其他模块,但只要保证结果正确且不依赖顺序,顺序本身并不重要。

Static 的收集

在了解了收集的必要性和顺序问题的解决方法后,我们来分别看看各个类别的结构信息的收集过程。依然按照上文生成的顺序,从 Class(Interface 同理)开始,然后是 Enum,接着是 Struct。请读者对照上文的生成代码进行理解。

Class 的收集

对照上文的 Hello.generated.cpp 文件,我们可以看到以下代码:

static TClassCompiledInDefer<UMyClass> AutoInitializeUMyClass(TEXT("UMyClass"), sizeof(UMyClass), 899540749);
// ……
static FCompiledInDefer Z_CompiledInDefer_UClass_UMyClass(Z_Construct_UClass_UMyClass, &UMyClass::StaticClass, TEXT("UMyClass"), false, nullptr, nullptr, nullptr);

其定义如下:

// Specialized version of the deferred class registration structure.
template <typename TClass>
struct TClassCompiledInDefer : public FFieldCompiledInInfo
{
TClassCompiledInDefer(const TCHAR* InName, SIZE_T InClassSize, uint32 InCrc)
: FFieldCompiledInInfo(InClassSize, InCrc)
{
UClassCompiledInDefer(this, InName, InClassSize, InCrc); // 收集信息
}
virtual UClass* Register() const override
{
return TClass::StaticClass();
}
};

// Stashes the singleton function that builds a compiled in class. Later, this is executed.
struct FCompiledInDefer
{
FCompiledInDefer(class UClass *(*InRegister)(), class UClass *(*InStaticClass)(), const TCHAR* Name, bool bDynamic, const TCHAR* DynamicPackageName = nullptr, const TCHAR* DynamicPathName = nullptr, void (*InInitSearchableValues)(TMap<FName, FName>&) = nullptr)
{
if (bDynamic)
{
GetConvertedDynamicPackageNameToTypeName().Add(FName(DynamicPackageName), FName(Name));
}
UObjectCompiledInDefer(InRegister, InStaticClass, Name, bDynamic, DynamicPathName, InInitSearchableValues); // 收集信息
}
};

可以看到,TClassCompiledInDefer 调用了 UClassCompiledInDefer 来收集类名、类大小和 CRC 信息,并保存自身指针以便后续调用 Register 方法。而 UObjectCompiledInDefer(暂时不考虑动态类)最重要的是收集用于构造 UClass* 对象的函数指针回调。

进一步查看代码,会发现这两者实际上都是在一个静态数组中添加信息记录:

void UClassCompiledInDefer(FFieldCompiledInInfo* ClassInfo, const TCHAR* Name, SIZE_T ClassSize, uint32 Crc)
{
// ...
// We will either create a new class or update the static class pointer of the existing one
GetDeferredClassRegistration().Add(ClassInfo); // static TArray<FFieldCompiledInInfo*> DeferredClassRegistration;
}

void UObjectCompiledInDefer(UClass *(*InRegister)(), UClass *(*InStaticClass)(), const TCHAR* Name, bool bDynamic, const TCHAR* DynamicPathName, void (*InInitSearchableValues)(TMap<FName, FName>&))
{
// ...
GetDeferredCompiledInRegistration().Add(InRegister); // static TArray<class UClass *(*)()> DeferredCompiledInRegistration;
}

在整个引擎中,触发 Class 信息收集的有 UCLASSUINTERFACEIMPLEMENT_INTRINSIC_CLASSIMPLEMENT_CORE_INTRINSIC_CLASS。其中,UCLASSUINTERFACE 在上文已经介绍过,IMPLEMENT_INTRINSIC_CLASS 用于在代码中包装 UModelIMPLEMENT_CORE_INTRINSIC_CLASS 用于包装 UFieldUClass 等引擎内建的类,后两者内部都调用了 IMPLEMENT_CLASS 来实现功能。

思考:为何需要 TClassCompiledInDefer 和 FCompiledInDefer 两个静态初始化来登记?

我们观察到这两者是一一对应的,为什么需要两个静态对象分别收集信息,而不合并为一个呢?关键在于要理解它们的不同之处。TClassCompiledInDefer 的主要目的是为后续提供一个 TClass::StaticClassRegister 方法(该方法会触发 GetPrivateStaticClassBody 的调用,进而创建出 UClass 对象),而 FCompiledInDefer 的目的是在 UClass 上继续调用构造函数,初始化属性和函数等注册操作。可以简单理解为类似于 C++ 中创建对象的两个步骤:首先分配内存,然后在该内存上构造对象。我们将在后续的注册章节中继续讨论这个问题。

思考:为何需要延迟注册而不是直接在 static 回调里执行?

很多人可能会问,为什么 static 回调只是将信息注册到数组结构中,而不直接在回调中执行后续操作,这样结构会更简单。确实如此,但同时需要考虑一个问题:UE4 中大约有 1500 多个类,如果在 static 初始化阶段对这 1500 多个类进行收集注册操作,main 函数需要等待较长时间才能开始执行。表现为用户双击程序后,没有反应,过了一段时间窗口才打开。因此,在 static 初始化回调中尽量少做事情,是为了尽快加快程序启动速度。等窗口显示出来后,数组结构中已经有了数据,就可以采用多线程或延迟等方式进行处理,大大改善程序运行体验。

Enum 的收集

依旧对照上文的代码,UENUM 会生成以下代码:

static FCompiledInDeferEnum Z_CompiledInDeferEnum_UEnum_EMyEnum(EMyEnum_StaticEnum, TEXT("/Script/Hello"), TEXT("EMyEnum"), false, nullptr, nullptr);

// 其定义:
struct FCompiledInDeferEnum
{
FCompiledInDeferEnum(class UEnum *(*InRegister)(), const TCHAR* PackageName, const TCHAR* Name, bool bDynamic, const TCHAR* DynamicPackageName, const TCHAR* DynamicPathName)
{
if (bDynamic)
{
GetConvertedDynamicPackageNameToTypeName().Add(FName(DynamicPackageName), FName(Name));
}
UObjectCompiledInDeferEnum(InRegister, PackageName, DynamicPathName, bDynamic);
//  static TArray<FPendingEnumRegistrant> DeferredCompiledInRegistration;
}
};

在 static 阶段,会向内存注册一个构造 UEnum 的函数指针用于回调。需要注意的是,这里不需要像 UClassCompiledInDefer 一样先生成一个 UClass,因为 UEnum 不是一个 Class,没有 Class 那么多的功能集合,所以相对简单。

Struct 的收集

对于 Struct,先看上文生成的代码:

static FCompiledInDeferStruct Z_CompiledInDeferStruct_UScriptStruct_FMyStruct(FMyStruct::StaticStruct, TEXT("/Script/Hello"), TEXT("MyStruct"), false, nullptr, nullptr); // 延迟注册
static struct FScriptStruct_Hello_StaticRegisterNativesFMyStruct
{
FScriptStruct_Hello_StaticRegisterNativesFMyStruct()
{
UScriptStruct::DeferCppStructOps(FName(TEXT("MyStruct")), new UScriptStruct::TCppStructOps<FMyStruct>);
}
} ScriptStruct_Hello_StaticRegisterNativesFMyStruct; // static 注册

同样是两个 static 对象,前者 FCompiledInDeferStruct 继续向数组结构中登记函数指针,后者比较特殊,在一个结构名和对象的映射表中登记“Struct 相应的 C++ 操作类”(后续解释)。

struct FCompiledInDeferStruct
{
FCompiledInDeferStruct(class UScriptStruct *(*InRegister)(), const TCHAR* PackageName, const TCHAR* Name, bool bDynamic, const TCHAR* DynamicPackageName, const TCHAR* DynamicPathName)
{
if (bDynamic)
{
GetConvertedDynamicPackageNameToTypeName().Add(FName(DynamicPackageName), FName(Name));
}
UObjectCompiledInDeferStruct(InRegister, PackageName, DynamicPathName, bDynamic);
// static TArray<FPendingStructRegistrant> DeferredCompiledInRegistration;
}
};

void UScriptStruct::DeferCppStructOps(FName Target, ICppStructOps* InCppStructOps)
{
TMap<FName, UScriptStruct::ICppStructOps*>& DeferredStructOps = GetDeferredCppStructOps();

if (UScriptStruct::ICppStructOps* ExistingOps = DeferredStructOps.FindRef(Target))
{
#if WITH_HOT_RELOAD
if (!GIsHotReload) // in hot reload, we will just leak these...they may be in use.
#endif
{
check(ExistingOps != InCppStructOps); // if it was equal, then we would be re-adding a now stale pointer to the map
delete ExistingOps;
}
}
DeferredStructOps.Add(Target, InCppStructOps);
}

此外,查看引擎代码会发现,对于 UE4 里内建的结构,如 Vector,其 IMPLEMENT_STRUCT(Vector) 也会相应地触发 DeferCppStructOps 的调用。

这里的 Struct 和 Enum 同理,因为不是一个 Class,所以不需要繁琐的两步构造,凭借 FPendingStructRegistrant 就可以后续一步构造出 UScriptStruct 对象。对于内建的类型(如 Vector),由于它们完全不是“Script”类型,所以不需要构建 UScriptStruct,其如何向 BP 暴露将在后续详细介绍。

需要注意的是,UStruct 类型会配套一个 ICppStructOps 接口对象来管理 C++ struct 对象的构造和析构工作。其目的在于,如果有一块已经擦除了类型的内存数据,如何在其上正确地构造或析构结构对象数据。此时,如果能够得到一个统一的 ICppStructOps 指针指向类型安全的 TCppStructOps<CPPSTRUCT> 对象,就可以通过接口函数动态、多态、类型安全地执行构造和析构工作。

Function 的收集

在介绍完 Class、Enum、Struct 之后,我们还需要收集一些引擎内建函数的信息。前文未提及这一点,是因为 UE 提供了 BlueprintFunctionLibrary 类来注册全局函数。而引擎内部定义的函数分散在各处,也需要收集起来。主要有以下两类:

  • IMPLEMENT_CAST_FUNCTION:定义一些 Object 的转换函数。
    IMPLEMENT_CAST_FUNCTION( UObject, CST_ObjectToBool, execObjectToBool );
    IMPLEMENT_CAST_FUNCTION( UObject, CST_InterfaceToBool, execInterfaceToBool );
    IMPLEMENT_CAST_FUNCTION( UObject, CST_ObjectToInterface, execObjectToInterface );
    
  • IMPLEMENT_VM_FUNCTION:定义一些蓝图虚拟机使用的函数。
    IMPLEMENT_VM_FUNCTION(EX_CallMath, execCallMathFunction);
    IMPLEMENT_VM_FUNCTION( EX_True, execTrue );
    // ……
    

    查看其定义:

    #define IMPLEMENT_FUNCTION(cls,func) \
    static FNativeFunctionRegistrar cls##func##Registar(cls::StaticClass(),#func,(Native)&cls::func);
    

define IMPLEMENT_CAST_FUNCTION(cls, CastIndex, func) \

IMPLEMENT_FUNCTION(cls, func); \ static uint8 cls##func##CastTemp = GRegisterCast( CastIndex, (Native)&cls::func );

define IMPLEMENT_VM_FUNCTION(BytecodeIndex, func) \

IMPLEMENT_FUNCTION(UObject, func) \ static uint8 UObject##func##BytecodeTemp = GRegisterNative( BytecodeIndex, (Native)&UObject::func );

/ A struct that maps a string name to a native function / struct FNativeFunctionRegistrar { FNativeFunctionRegistrar(class UClass Class, const ANSICHAR InName, Native InPointer) { RegisterFunction(Class, InName, InPointer); } static COREUOBJECT_API void RegisterFunction(class UClass Class, const ANSICHAR InName, Native InPointer); // overload for types generated from blueprints, which can have unicode names: static COREUOBJECT_API void RegisterFunction(class UClass Class, const WIDECHAR InName, Native InPointer); };

可以发现,有 3 个 static 对象收集这些函数的信息并登记到相应的结构中。其中,`FNativeFunctionRegistrar` 用于向 `UClass` 中添加 Native 函数(区别于蓝图里定义的函数),另一方面,在 `UClass` 的 `RegisterNativeFunc` 相关函数中,也会将相应的 Class 内定义的函数添加到 `UClass` 内部的函数表中。

### UObject 的收集
如果读者自己剖析源码,可能会有一个疑问:作为 Object 系统的根类,`UObject` 是如何在最开始触发相应 `UClass` 的生成的呢?答案在于最初的 `IMPLEMENT_VM_FUNCTION(EX_CallMath, execCallMathFunction)` 调用,其内部会紧接着触发 `UObject::StaticClass()` 的调用。作为最初的调用,当检测到 `UClass` 尚未生成时,会转发到 `GetPrivateStaticClassBody` 中生成一个 `UClass*`。

## 总结
因篇幅有限,本文紧接着上文,讨论了代码生成的信息是如何一步步收集到内存里的数据结构中的。UE4 利用了 C++ 的 static 对象初始化模式,在程序最初启动时,即在 main 函数之前,完成了信息收集工作。

作者信息

孟子菇凉

孟子菇凉

共发布了 3994 篇文章