Hook是一种机制,通过拦截和勾取一些事件来实现自己需求的方式。不同于传统的底层Hook,本篇文章主要介绍在UE中如何使用类似Hook的这种机制来实现业务需求。
有些需求是要全局地修改某个类的所有对象,比如在UI中为某种类型的的Button播放统一的音效,如果在每个控件都需要监听它的OnClicked再去播放音效,会有大量的重复操作。所以,我想要找一种全局的方法,可以监听所有UButton的点击事件,然后统一来处理。再或者想要控制一个在蓝图中不可见的属性,如果只是一些简单的需求就要去修改引擎的代码,有点得不偿失。
可以通过UE的反射机制来实现这些需求,本篇文章来提供一种思路,做一个简单的实现分析。
监听对象创建执行操作
需求有了,要修改指定类型所有的对象的属性,那么要实现这样的需求大致的思路是这样的:
- 首先需要能够知道指定类型的对象被创建了
- 当对象被创建完成之后修改它的属性
能够实现这两点,就可以解决我们的需求,那么问题的关键就是要先找到知道对象被创建了的方法。
经过翻阅UE的代码,发现UE创建和销毁对象时都可以注册Listener
来接收通知:
1 | void FUObjectArray::AllocateUObjectIndex(UObjectBase* Object, bool bMergingThreads /*= false*/) |
调用栈:
既然知道了创建UObject的时候会调用到所有的Listener,那么就自己注册进去一个对象。
UObjectCreateListeners
的类型为:
1 | TArray<FUObjectCreateListener* > UObjectCreateListeners; |
FUObjectCreateListener
是个抽象类,定义了两个虚函数,当作接口使用:
1 | class FUObjectCreateListener |
同时,还有监听对象被删除的接口FUObjectDeleteListener
:
1 | /** |
我既想监听对象创建也想监听删除,所以我写了个类来同时继承它们两个:
1 | struct FUButtonListener : public FUObjectArray::FUObjectCreateListener, public FUObjectArray::FUObjectDeleteListener |
创建好了,关键的一步是要自己写的类注册到GUObjectArray
中,FUObjectArray
提供了两组Add
和Remove
的函数用来添加和移除Listener。
1 | /** |
OK,知道怎么添加了,那么什么时机来添加Listener呢?
当然需要在游戏运行时资源UObject创建之前,不然对象都已经创建了,再绑定也监听不到了。
因为区分了PIE和打包,所以PIE Play和打包运行需要分别处理:
在编辑器下可以通过监听以下两个事件来开始进行监听UObject创建的流程:
1 |
|
打包的流程不是这两个事件,可以使用以下两个代理替换:
1 |
|
这两个代理转发到的函数中可以进行处理添加Listener和删除的操作:
1 | void FHookerMisc::Init() |
之后就可以通过override
以下两个函数来获取Object的创建和删除事件了:
1 | void FHookerMisc::NotifyUObjectCreated(const UObjectBase* Object, int32 Index){} |
注意:当NotifyUObjectCreated
函数被调用,这里传递过来的UObject并不是最终创建完成的对象,因为该Object还没有被初始化,它的构造函数还没有被调用,所以,如果此时直接修改Object,是没有作用的,因为在引擎后续的流程中,在这个Object所在的内存上调用了它的构造函数。
调用栈:
1 | UObject* StaticConstructObject_Internal |
可以看到,是先通过StaticAllocateObject
进行创建UObject(其实是分配UObject的内存)在下面的流程中,通过UClass得到当前类的构造函数,并执行。
什么是构造函数?构造函数可以理解为模具,一块内存拿过来,通过这块模具生成出来一个具体的对象,构造函数就是一种初始化内存的方式——以什么样的形式来解释这块内存,并给它初始值。
构造函数会调用基类的构造函数、执行类内初始化、调用类成员的构造函数,把这块内存修改为对象默认的状态。
所以,不能直接在NotifyUObjectCreated
中对UObject进行修改,要等到它构造完成之后。
此时的Object具有RF_NeedInitialization
Flag,标记当前对象需要初始化,而我们可以通过这个FLAG的有无来决定是否修改它。
当NotifyUObjectCreated
事件调用时,可以把传递过来的Object存储到一个列表中,在下次创建事件过来以及下一帧时,对列表中的所有对象进行检测,是否还具有RF_NeedInitialization
Flag,如果没有,就表明该对象已经被初始化成功了,可以对其进行修改了,不用担心修改的数据被覆盖了。
在FObjectInitializer
的PostConstructInit
函数(由~FObjectInitializer
调用)中对该FLAG进行了清理:
1 | void FObjectInitializer::PostConstructInit() |
所以,只要当一个对象没有了RF_NeedInitialization
FLAG,就可以对它进行操作了。
修改UClass控制反射属性
有时候,想要在编辑器中控制一个对象的属性,但是虽然该属性是UProperty,但是没有标记为EditAnyWhere
,在编辑器中是不可见的。
如:
1 | class ANetActor:public AActor |
上述成员变量中ival
是可以在蓝图和编辑中访问的,因为它有EditAnywhere
属性,而ival2
没有,则不能。
对于我们自己创建的类,可以通过修改代码解决,但是对于引擎或者其他第三方模块的类,直接去改动相关的代码并不是一个好主意,会带来额外的限制:需要使用源码版引擎、自己管理修改的代码版本。
有没有一种方法,不修改引擎里的代码,来实现我们的需求呢?
有!因为在编辑器中是否显示是通过UE给对象和它的属性生成的反射信息来决定的,如果能够找到一种方法让引擎读取反射信息时认为ival2
也是可以在编辑器显示的就OK了。
思路有了,即:修改ival2
的反射信息,让编辑器认为它也需要在编辑器中显示。
通过代码分析,发现对象是否允许被显示在Details
中显示时通过对UProperty检测CPF_Edit
Flag实现的。
1 | enum EPropertyFlags : uint64 |
EPropertyFlags
中对CPF_Edit
的描述也正是如此。
可以看一下上面例子中ival
和ival2
生成反射代码的差异:
1 | 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)) }; |
可以看到,它们除了ival
((EPropertyFlags)0x0010000000000001
)/ival2
((EPropertyFlags)0x0010000000000000
)这两个FLag内容不一样之外,其他的部分完全相同,ival
的FLAG内容就是多了CPF_Edit
,它是EPropertyFlags
的第二个属性,它的值为0x01
,EPropertyFlags
的枚举值是按位来表示的。
UE的反射机制通过引擎启动时,读取这些生成的反射信息,为类内的反射属性生成FProperty对象,用来在运行时获取该属性的反射信息。
FProperty
类定义在Runtime/CoreUObject/Public/UObject/UnrealType.h
中,它记录了当前属性在类内的偏移值、元素大小、名字,以及我们需要的PropertyFlags
。
通过上面的分析,现在问题的关键是:如何在运行时(编辑器运行时),修改一个类反射属性的PropertyFlags
。
流程有以下几步(在属性窗口创建之前):
- 获取类的反射信息(UClass)
- 从反射信息获取指定的属性的反射信息(FProperty)
- 修改属性的反射信息,添加
CPF_Edit
实现代码如下:
1 | auto AddEditFlagLambda = [](UClass* Class,FName flagName)->bool |
使用上一节NotifyUObjectCreated
的方法,来实现修改,不过有区别的地方在于,不是针对某个实例,而是直接修改指定的UClass,所以也就不需要就对象进行初始化完成的判断。
运行起来的效果:
使用这种方式可以很简单地给引擎中的反射类的反射属性添加编辑器支持,如EditAnywhere
/Interp
等,从而实现不修改引擎,而修改引擎代码中的反射信息。
后记
使用反射的机制,可以很方便地修改反射类、反射属性,通过这样的形式来实现业务需求,可以避免修改引擎代码的行为。本篇文章只是开了一个脑洞,提供了一种思路,反射不仅仅能做这些事情,有时间分析一下UE的反射下实现以及它是如何使用这些反射信息的。