Hook UObject

Hook 是一种机制,通过拦截和勾取一些事件来实现自己需求的方式。不同于传统的底层 Hook,本篇文章主要介绍在 UE 中如何使用类似 Hook 的这种机制来实现业务需求。

有些需求是要全局地修改某个类的所有对象,比如在 UI 中为某种类型的的 Button 播放统一的音效,如果在每个控件都需要监听它的 OnClicked 再去播放音效,会有大量的重复操作。所以,我想要找一种全局的方法,可以监听所有 UButton 的点击事件,然后统一来处理。再或者想要控制一个在蓝图中不可见的属性,如果只是一些简单的需求就要去修改引擎的代码,有点得不偿失。

可以通过 UE 的反射机制来实现这些需求,本篇文章来提供一种思路,做一个简单的实现分析。

监听对象创建执行操作

需求有了,要修改指定类型所有的对象的属性,那么要实现这样的需求大致的思路是这样的:

  1. 首先需要能够知道 指定类型的对象 被创建了
  2. 当对象被 创建完成之后 修改它的属性

能够实现这两点,就可以解决我们的需求,那么问题的关键就是要先找到 知道对象被创建了 的方法。

经过翻阅 UE 的代码,发现 UE 创建和销毁对象时都可以注册 Listener 来接收通知:

1
2
3
4
5
6
7
8
9
void FUObjectArray::AllocateUObjectIndex(UObjectBase* Object, bool bMergingThreads /*= false*/)
{
// ...
for (int32 ListenerIndex = 0; ListenerIndex < UObjectCreateListeners.Num(); ListenerIndex++)
{
UObjectCreateListeners[ListenerIndex]->NotifyUObjectCreated(Object,Index);
}
// ...
}

调用栈:

既然知道了创建 UObject 的时候会调用到所有的 Listener,那么就自己注册进去一个对象。

UObjectCreateListeners的类型为:

1
TArray<FUObjectCreateListener* > UObjectCreateListeners;

FUObjectCreateListener是个抽象类,定义了两个虚函数,当作接口使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class FUObjectCreateListener
{
public:
virtual ~FUObjectCreateListener() {}
/**
* Provides notification that a UObjectBase has been added to the uobject array
*
* @param Object object that has been destroyed
* @param Index index of object that is being deleted
*/
virtual void NotifyUObjectCreated(const class UObjectBase *Object, int32 Index)=0;

/**
* Called when UObject Array is being shut down, this is where all listeners should be removed from it
*/
virtual void OnUObjectArrayShutdown()=0;
};

同时,还有监听对象被删除的接口FUObjectDeleteListener

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* Base class for UObjectBase delete class listeners
*/
class FUObjectDeleteListener
{
public:
virtual ~FUObjectDeleteListener() {}

/**
* Provides notification that a UObjectBase has been removed from the uobject array
*
* @param Object object that has been destroyed
* @param Index index of object that is being deleted
*/
virtual void NotifyUObjectDeleted(const class UObjectBase *Object, int32 Index)=0;

/**
* Called when UObject Array is being shut down, this is where all listeners should be removed from it
*/
virtual void OnUObjectArrayShutdown() = 0;
};

我既想监听对象创建也想监听删除,所以我写了个类来同时继承它们两个:

1
2
3
4
5
6
7
8
9
10
11
12
struct FUButtonListener : public FUObjectArray::FUObjectCreateListener, public FUObjectArray::FUObjectDeleteListener
{
static FButtonListenerMisc* Get()
{
static FButtonListenerMisc StaticIns;
return &StaticIns;
}
// Listener
virtual void NotifyUObjectCreated(const class UObjectBase *Object, int32 Index);
virtual void NotifyUObjectDeleted(const class UObjectBase *Object, int32 Index);
FORCEINLINE virtual void OnUObjectArrayShutdown()override { }
};

创建好了,关键的一步是要自己写的类注册到 GUObjectArray 中,FUObjectArray提供了两组 AddRemove的函数用来添加和移除 Listener。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* Adds a new listener for object creation
* @param Listener listener to notify when an object is deleted
*/
void AddUObjectCreateListener(FUObjectCreateListener* Listener);

/**
* Removes a listener for object creation
* @param Listener listener to remove
*/
void RemoveUObjectCreateListener(FUObjectCreateListener* Listener);

/**
* Adds a new listener for object deletion
* @param Listener listener to notify when an object is deleted
*/
void AddUObjectDeleteListener(FUObjectDeleteListener* Listener);

/**
* Removes a listener for object deletion
* @param Listener listener to remove
*/
void RemoveUObjectDeleteListener(FUObjectDeleteListener* Listener);

OK,知道怎么添加了,那么 什么时机 来添加 Listener 呢?

当然需要在游戏运行时资源 UObject 创建之前,不然对象都已经创建了,再绑定也监听不到了。

因为区分了 PIE 和打包,所以 PIE Play 和打包运行需要分别处理:

在编辑器下可以通过监听以下两个事件来开始进行监听 UObject 创建的流程:

1
2
3
4
#if WITH_EDITOR
FEditorDelegates::PreBeginPIE.AddStatic(&PreBeginPIE);
FGameDelegates::Get().GetEndPlayMapDelegate().AddRaw(FHookerMisc::Get(), &FHookerMisc::Shutdown);
#endif

打包的流程不是这两个事件,可以使用以下两个代理替换:

1
2
3
4
#if !WITH_EDITOR
FCoreDelegates::OnPostEngineInit.AddRaw(FHookerMisc::Get(),&FHookerMisc::Init);
FCoreDelegates::OnPreExit.AddRaw(FHookerMisc::Get(),&FHookerMisc::Shutdown);
#endif

这两个代理转发到的函数中可以进行处理添加 Listener 和删除的操作:

1
2
3
4
5
6
7
8
9
10
void FHookerMisc::Init()
{
GUObjectArray.AddUObjectCreateListener(FHookerMisc::Get());
GUObjectArray.AddUObjectDeleteListener(FHookerMisc::Get());
}
void FHookerMisc::Shutdown()
{
GUObjectArray.RemoveUObjectCreateListener(FHookerMisc::Get());
GUObjectArray.RemoveUObjectDeleteListener(FHookerMisc::Get());
}

之后就可以通过 override 以下两个函数来获取 Object 的创建和删除事件了:

1
2
void FHookerMisc::NotifyUObjectCreated(const UObjectBase* Object, int32 Index){}
void FHookerMisc::NotifyUObjectDeleted(const UObjectBase* Object, int32 Index){}

注意 :当NotifyUObjectCreated 函数被调用,这里传递过来的 UObject 并不是最终创建完成的对象,因为该 Object 还没有被初始化,它的构造函数还没有被调用,所以,如果此时直接修改 Object,是没有作用的,因为在引擎后续的流程中,在这个 Object 所在的内存上调用了它的构造函数。

调用栈:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
UObject* StaticConstructObject_Internal
(
const UClass* InClass,
UObject* InOuter /*=GetTransientPackage()*/,
FName InName /*=NAME_None*/,
EObjectFlags InFlags /*=0*/,
EInternalObjectFlags InternalSetFlags /*=0*/,
UObject* InTemplate /*=NULL*/,
bool bCopyTransientsFromClassDefaults /*=false*/,
FObjectInstancingGraph* InInstanceGraph /*=NULL*/,
bool bAssumeTemplateIsArchetype /*=false*/
)
{
// ...

bool bRecycledSubobject = false;
Result = StaticAllocateObject(InClass, InOuter, InName, InFlags, InternalSetFlags, bCanRecycleSubobjects, &bRecycledSubobject);
check(Result != NULL);
// Don't call the constructor on recycled subobjects, they haven't been destroyed.
if (!bRecycledSubobject)
{
STAT(FScopeCycleCounterUObject ConstructorScope(InClass, GET_STATID(STAT_ConstructObject)));
(*InClass->ClassConstructor)(FObjectInitializer(Result, InTemplate, bCopyTransientsFromClassDefaults, true, InInstanceGraph) );
}

// ...
return Result;
}

可以看到,是先通过 StaticAllocateObject 进行创建 UObject(其实是分配 UObject 的内存)在下面的流程中,通过 UClass 得到当前类的构造函数,并执行。

什么是构造函数?构造函数可以理解为 模具,一块内存拿过来,通过这块模具生成出来一个具体的对象,构造函数就是一种初始化内存的方式——以什么样的形式来解释这块内存,并给它初始值。

构造函数会调用基类的构造函数、执行类内初始化、调用类成员的构造函数,把这块内存修改为对象默认的状态。

所以,不能直接在 NotifyUObjectCreated 中对 UObject 进行修改,要等到它构造完成之后。

此时的 Object 具有RF_NeedInitializationFlag,标记当前对象需要初始化,而我们可以通过这个 FLAG 的有无来决定是否修改它。

NotifyUObjectCreated 事件调用时,可以把传递过来的 Object 存储到一个列表中,在下次创建事件过来以及下一帧时,对列表中的所有对象进行检测,是否还具有RF_NeedInitializationFlag,如果没有,就表明该对象已经被初始化成功了,可以对其进行修改了,不用担心修改的数据被覆盖了。

FObjectInitializerPostConstructInit函数 (由~FObjectInitializer 调用)中对该 FLAG 进行了清理:

1
2
3
4
5
6
void FObjectInitializer::PostConstructInit()
{
// ...
Obj->ClearFlags(RF_NeedInitialization);
// ...
}

所以,只要当一个对象没有了RF_NeedInitializationFLAG,就可以对它进行操作了。

修改 UClass 控制反射属性

有时候,想要在编辑器中控制一个对象的属性,但是虽然该属性是 UProperty,但是没有标记为EditAnyWhere,在编辑器中是不可见的。

如:

1
2
3
4
5
6
7
8
9
class ANetActor:public AActor
{
GENERATE_BODY()
public:
UPROPERTY(EditAnywhere)
int32 ival;
UPROPERTY()
int32 ival2;
};

上述成员变量中 ival 是可以在蓝图和编辑中访问的,因为它有 EditAnywhere 属性,而 ival2 没有,则不能。

对于我们自己创建的类,可以通过修改代码解决,但是对于引擎或者其他第三方模块的类,直接去改动相关的代码并不是一个好主意,会带来额外的限制:需要使用源码版引擎、自己管理修改的代码版本。

有没有一种方法,不修改引擎里的代码,来实现我们的需求呢?

有!因为 在编辑器中是否显示 是通过 UE 给对象和它的属性生成的反射信息来决定的,如果能够找到一种方法让引擎读取反射信息时认为 ival2 也是可以在编辑器显示的就 OK 了。

思路有了,即:修改 ival2 的反射信息,让编辑器认为它也需要在编辑器中显示。

通过代码分析,发现对象是否允许被显示在 Details 中显示时通过对 UProperty 检测CPF_EditFlag 实现的。

1
2
3
4
5
6
enum EPropertyFlags : uint64
{
CPF_None = 0,
CPF_Edit = 0x0000000000000001, ///< Property is user-settable in the editor.
// ...
};

EPropertyFlags中对 CPF_Edit 的描述也正是如此。

可以看一下上面例子中 ivalival2生成反射代码的差异:

1
2
const UE4CodeGen_Private::FIntPropertyParams Z_Construct_UClass_ANetActor_Statics::NewProp_iVal2 = { "iVal2", nullptr, (EPropertyFlags)0x0010000000000000, UE4CodeGen_Private::EPropertyGenFlags::Int, RF_Public|RF_Transient|RF_MarkAsNative, 1, STRUCT_OFFSET(ANetActor, iVal2), METADATA_PARAMS(Z_Construct_UClass_ANetActor_Statics::NewProp_iVal2_MetaData, UE_ARRAY_COUNT(Z_Construct_UClass_ANetActor_Statics::NewProp_iVal2_MetaData)) };
const UE4CodeGen_Private::FIntPropertyParams Z_Construct_UClass_ANetActor_Statics::NewProp_ival = { "ival", nullptr, (EPropertyFlags)0x0010000000000001, UE4CodeGen_Private::EPropertyGenFlags::Int, RF_Public|RF_Transient|RF_MarkAsNative, 1, STRUCT_OFFSET(ANetActor, ival), METADATA_PARAMS(Z_Construct_UClass_ANetActor_Statics::NewProp_ival_MetaData, UE_ARRAY_COUNT(Z_Construct_UClass_ANetActor_Statics::NewProp_ival_MetaData)) };

可以看到,它们除了 ival((EPropertyFlags)0x0010000000000001)/ival2((EPropertyFlags)0x0010000000000000) 这两个 FLag 内容不一样之外,其他的部分完全相同,ival的 FLAG 内容就是多了 CPF_Edit,它是EPropertyFlags 的第二个属性,它的值为 0x01EPropertyFlags 的枚举值是按位来表示的。

UE 的反射机制通过引擎启动时,读取这些生成的反射信息,为类内的反射属性生成 FProperty 对象,用来在运行时获取该属性的反射信息。

FProperty类定义在 Runtime/CoreUObject/Public/UObject/UnrealType.h 中,它记录了当前属性在类内的偏移值、元素大小、名字,以及我们需要的PropertyFlags

通过上面的分析,现在问题的关键是:如何在运行时(编辑器运行时),修改一个类反射属性的PropertyFlags

流程有以下几步(在属性窗口创建之前):

  1. 获取类的反射信息(UClass)
  2. 从反射信息获取指定的属性的反射信息(FProperty)
  3. 修改属性的反射信息,添加CPF_Edit

实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
auto AddEditFlagLambda = [](UClass* Class,FName flagName)->bool
{
bool bStatus = false;
if(Class)
{
for(TFieldIterator<FProperty> PropertyIter(Class);PropertyIter;++PropertyIter)
{
FProperty* PropertyIns = *PropertyIter;
if(PropertyIns->GetName().Equals(flagName.ToString()))
{
if(!PropertyIns->HasAnyPropertyFlags(CPF_Edit))
{
PropertyIns->SetPropertyFlags(CPF_Edit);
bStatus = true;
}
}
}
}
return bStatus;
};

AddEditFlagLambda(ANetActor::StaticClass(),TEXT("ival2"));

使用上一节 NotifyUObjectCreated 的方法,来实现修改,不过有区别的地方在于,不是针对某个实例,而是直接修改指定的 UClass,所以也就不需要就对象进行初始化完成的判断。

运行起来的效果:

使用这种方式可以很简单地给引擎中的反射类的反射属性添加编辑器支持,如 EditAnywhere/Interp 等,从而实现不修改引擎,而修改引擎代码中的反射信息。

后记

使用反射的机制,可以很方便地修改反射类、反射属性,通过这样的形式来实现业务需求,可以避免修改引擎代码的行为。本篇文章只是开了一个脑洞,提供了一种思路,反射不仅仅能做这些事情,有时间分析一下 UE 的反射下实现以及它是如何使用这些反射信息的。