Tizeng's blog Ordinary Gamer

UE源码学习——反射

2025-02-16
Tizeng

反射的定义是能让程序在运行时知道类型信息,而不限于编译期,具体实现方法牵扯的内容很多,大钊的专栏有一系列文章专门讲虚幻是如何生成和维护各个类型的反射代码的,我这里并不打算将这些过程都梳理一遍,而是想从日常开发中会遇到的一些问题入手,看看虚幻是怎么解决这些问题的。

UClass是怎么表现类型信息的?

StaticClass

获取当前UObject类对应的UClass,每个UObject都有,查看.generated.h文件可以找到是在DECLARE_CLASS宏中定义的,实现是调用了全局方法GetPrivateStaticClassBody,先通过FindObject看看能不能用提供的outer和name找到对应的UClass,如果找不到则new一个新的出来并初始化。

UClass自己调用StaticClass会返回什么?大钊的文章提到会引用到自身,这点还需要看下代码求证。

在引擎启动开始初始化时,会为每个UObject子类调用StaticClass创建UClass

UPROPERTY宏的作用

被这个宏标记的成员会在.gen.cpp中生成NewProp_开头的反射数据(包括名字、类型flag、地址偏移等),并被加入到PropPointers数组中,ConstructFProperties中会使用这些数据构造出对应类型的FProperty 如果是UObject成员变量会被当做被这个类引用?在gc时不会被删除 如果在外部被删除则会自动置空

如何通过名字拿到类中的值

直接看一种可行的方式:

void GetPropertyValueByName(UObject* Object, const FString& PropertyName)
{
    // Ensure the object is valid
    if (!Object) return;

    // Get the UClass object of the UObject instance
    UClass* ObjectClass = Object->GetClass();

    // Find the property by name
    FProperty* Property = ObjectClass->FindPropertyByName(*PropertyName);

    if (Property)
    {
        // Found the property
        if (FIntProperty* IntProperty = CastField<FIntProperty>(Property))
        {
            int32 PropertyValue = IntProperty->GetPropertyValue(Property->ContainerPtrToValuePtr<void>(Object));
            UE_LOG(LogTemp, Log, TEXT("Property '%s' value: %d"), *PropertyName, PropertyValue);
        }
        else if (FFloatProperty* FloatProperty = CastField<FFloatProperty>(Property))
        {
            float PropertyValue = FloatProperty->GetPropertyValue(Property->ContainerPtrToValuePtr<void>(Object));
            UE_LOG(LogTemp, Log, TEXT("Property '%s' value: %f"), *PropertyName, PropertyValue);
        }
    }
}

上面的代码做了以下几件事:

  1. 找到对应的UClass,调用FindPropertyByName通过名字拿到对应属性FProperty
  2. 通过ContainerPtrToValuePtr从属性中拿到对应实例的
  3. 确定属性的类型,然后调用FProperty中的GetPropertyValue,使用第二步得到的地址作为参数得到值

为了搞清楚上面发生的事情,首先要理解虚幻是如何处理反射数据的,4.25版本引入了FProperty代替UProperty类型,它继承自FFiled,不继承自UObject,让属性的保存更加轻量化,内存管理上也可以和UObject脱钩。属性通过链表保存在UStructUClass的基类)中,有一个ChildProperties成员作为表头,不过FindPropertyByName是通过另一个成员PropertyLink进行遍历查找,原理是一样的。

在生成反射数据代码时,有一个偏移值,通过STRUCT_OFFSET获取,它的实现如下:

#define offsetof(s,m) ((::size_t)&reinterpret_cast<char const volatile&>((((s*)0)->m)))

其中s是类,m是成员,这里用0地址转换成s类型的指针后,对m进行访问,然后将其转化为一个字节的char并取地址,得到的结果便是这个成员相对于所在类的偏移量。 第二步通过“容器”,也就是真正包含这个属性的对象的地址,和FProperty中储存的地址偏移成员Offset_Internal,便可以得到对应成员的地址,如果是数组,就再根据给定的下标和ElementSize进一步偏移。最后调用属性子类中的GetPropertyValue方法,将地址转换成所需要的指针,之所以不直接使用,一是为了避免用户进行指针类型的转换,二是可以在不同的属性子类中做一些特殊处理。

如何去遍历所有的成员变量

使用TFieldIterator,它会通过传入的是UField还是FField,调用不同特化版本的GetChildFieldsFromStruct模板函数,从UStruct中获取ChildrenChildProperties进行遍历。

如何通过名字调用函数

在UHT收集反射信息时,会对每个被UFUNCTION标记的函数根据其参数、返回值等信息生成一个调用ConstructUFunction的函数(名字是根据类型和函数名拼接而成的,暂且叫它FuncA),然后和被标记函数的名字进行绑定,当ConstructUClass调用时,作为类中的一部分信息被传入,处理函数信息时便调用前面生成的FuncA生成UFunction实例,最后用函数的名字作为key放在UClass的FuncMap成员中。最后处理绑定和链接信息。

exec开头函数的作用

UHT为每个不是BlueprintImplementableEventUFUNCTION生成一个带exec前缀的静态函数,使用的宏定义如下:

// Blueprint VM intrinsic return value declaration.
#define RESULT_PARAM Z_Param__Result
#define RESULT_DECL void*const RESULT_PARAM

// This macro is used to declare a thunk function in autogenerated boilerplate code
#define DECLARE_FUNCTION(func) static void func( UObject* Context, FFrame& Stack, RESULT_DECL )
// This macro is used to define a thunk function in autogenerated boilerplate code
#define DEFINE_FUNCTION(func) void func( UObject* Context, FFrame& Stack, RESULT_DECL )

它的实现是从字节码中拿到该函数的参数,执行真正的实现,这为蓝图调用C++代码打好了基础。

IMPLEMENT_CLASS会使用名为StaticRegisterNatives##TClass的方法来注册当前类中所有Native函数(在c++中有实现的函数),这个函数会在gen.cpp中生成。 UClass在第一次调用GetPrivateStaticClassBody时会执行注册,注册的对象便是这些exec开头的函数,它们会按名字和地址被注册到UClass的NativeFunctionLookupTable成员中去。 在UFunction执行绑定时(UFunction::Bind),有两种情况:

  • 是native函数:会从lookup表中找到与函数名字对应的函数指针,赋值给成员Func
  • 不是native函数:将UObject::ProcessInternal赋值给Func

这种类型签名的函数引擎中有一个专门的类型定义,被用在各种反射函数调用上,上面提到的Func就是这个类型:

/** The type of a native function callable by script */
typedef void (*FNativeFuncPtr)(UObject* Context, FFrame& TheStack, RESULT_DECL);

ProcessEvent

参数为一个UFunction和一个void类型的指针用来传递参数结构。 它会根据UFunction上面的FunctionFlags判断是否是RPC函数,如果是则调用CallRemoteFunction。 如果需要本地调用,则创建一个FFrame,使用UFunction中的Script成员初始化字节码Code(UFunction继承的是UStruct,其中包含了字节码信息),然后通过Step解析字节码。

最后会通过UFunction调用Invoke,最终调用到Func

FFrame的作用

参考:南京周润发关于蓝图虚拟机的文章

这个其实是函数调用时栈帧的概念,可以理解为这次函数调用所需的上下文等信息,FFrame包含编译后函数对应的字节码,每次UFunction调用都会创建一个,在蓝图虚拟机中使用。 其成员函数Step通过字节码不停从GNatives数组(类型为FNativeFuncPtr)中拿到对应的函数进行调用,直到字节码的类型为EX_Return,即函数返回了,也就是说字节码中枚举了函数调用中的各种情况,在蓝图编译时按语句生成字节码供虚拟机执行。

C++如何调用蓝图实现的函数

被标记为可以被蓝图实现的函数,UHT会在gen.cpp中生成一份实现,其中调用的是ProcessEvent,下面是UserWidget.gen.cpp的两个例子:

struct UserWidget_eventOnFocusReceived_Parms
{
	FGeometry MyGeometry;
	FFocusEvent InFocusEvent;
	FEventReply ReturnValue;
};
static FName NAME_UUserWidget_OnFocusReceived = FName(TEXT("OnFocusReceived"));
FEventReply UUserWidget::OnFocusReceived(FGeometry MyGeometry, FFocusEvent InFocusEvent)
{
	UserWidget_eventOnFocusReceived_Parms Parms;
	Parms.MyGeometry=MyGeometry;
	Parms.InFocusEvent=InFocusEvent;
	ProcessEvent(FindFunctionChecked(NAME_UUserWidget_OnFocusReceived),&Parms);
	return Parms.ReturnValue;
}

static FName NAME_UUserWidget_OnInitialized = FName(TEXT("OnInitialized"));
void UUserWidget::OnInitialized()
{
	ProcessEvent(FindFunctionChecked(NAME_UUserWidget_OnInitialized),NULL);
}

可以看到除了UFunction指针之外,参数通过预先定义好的结构体(安排的明明白白)作为指针传入,如果没有参数则直接传入NULL,这里不需要知道类型,因为它在作为函数的一部分信息调用ConstructUFunction的时候,已经按照其结构体大小分配了空间。

蓝图如何调用C++实现的函数

通过生成的字节码调用exec函数,其通过解析字节码的数据,将参数还原出来,然后调用C++的实现。


下一篇 Lua实现总结

Comments

Content