UnLua框架解析-UE引擎Lua框架解决方案 (上)
导语:
UnLua是针对UE引擎的Lua框架解决方案,功能强大,使用简单,零胶水代码实现C++与Lua之间的相互调用。
但是在使用UnLua的过程中,经常会因为一些使用不当导致各种问题,崩溃等。定位问题耗费大量时间,所以对UnLua框架及其原理进行深入的学习并记录。文章会按照自己的理解过程来做整理,尽可能阐述清楚。不会记录UnLua的基础使用教程,仅对其框架原理等做解析,UnLua的使用教程可以参考UnLua官网Git.
理解UnLua框架需要一定的Lua与UE基础,建议在理解UE反射机制,GC机制后再深入学习UnLua框架会有更好的理解,可以参考作者前两篇文章。
目录:
- 一.Lua访问C++类型
- 1.1注册UClass描述信息FClassDesc
- 1.2注册Lua Metatable
- 二.Lua访问UObject对象类型中的函数
- 2.1注册FPropertyDesc/FFunctionDesc
- 2.2反射描述信息入栈
- 三.Lua调用UObject对象类型中的函数
- 3.1获取UFunction调用UObject实例
- 3.2准备UFunction调用参数
- 3.3 UFunction反射调用
- 3.3.1 反射调用C++ Native函数
- 3.3.2 反射调用UE蓝图脚本
- 3.4引用参数回写,返回值入栈
- 四.Lua访问UObject对象实例(不绑定Lua)及其属性/函数
- 4.1 Lua访问UObject对象实例
- 4.2 Lua调用UObject对象实例成员函数
- 4.3 Lua访问UObject对象实例成员属性
一. Lua访问C++类型:
通过UnLua可以直接在Lua访问UE4中支持反射的对象类型,UClass,UScriptStruct,UEnum.
举例:
在Lua中想通过调用UBlueprintPathsLibrary::ProjectLogDir获取日志路径,首先访问了UBlueprintPathsLibrary类型,那么Lua中通过调用UE4.UBlueprintPathsLibrary是如何访问到这个C++类型的,这个C++类型在Lua中又是以什么方式存在的。
访问C++类型流程:
在Lua中访问UE4的类型,UnLua会先将C++类型数据注册到UnLua中,按照UnLua自定义的格式存储起来,对于UObject, Actor,Struct类型通过Global_RegisterClass,Enum枚举通过Global_RegisterEnum注册。
这里记录UObject类型的注册,Struct注册流程大同小异,UObject类型注册主要分为两个流程
- 注册UClass的描述信息FClassDesc
- 注册Metatable
1.1 注册UClass类型描述信息FClassDesc
在Lua中可以对C++中对象类型,属性,函数进行无胶水代码进行访问调用,主要依赖了UE4提供的反射机制,然而对UE4反射数据的访问离不开UClass, UScriptStruct, UEnum, UFunction, FProperty等对象,UnLua为了更为方便的对反射数据进行访问处理,对这些反射数据对象以及处理函数进行封装。其中将UClass与UScriptStruct封装为FClassDesc,UEnum为FEnumDesc, UFunction为FFunctionDesc,FProperty为FPropertyDesc,类图如下:
在Lua中访问C++类型的第一步就是注册FClassDesc,将访问的UClass类型包装成FClassDesc,这是UnLua用到的UObject对象类型核心反射数据,然后存储到UnLua的存储全局反射数据的GReflectionRegistry对象中。
注册流程:
先在UE中找到C++类型的反射数据,UObject对象的反射类型为UClass,Struct对象为UScriptStruct,它们都继承了UStruct,所以直接获取UStruct对象,然后依据UStruct对象注册FClassDesc。
先在GReflectionRegistry对象中查找是否已经注册过UStruct对应的FClassDesc数据,如果不存在再继续注册,注册入口:
这里是FClassDesc的实际注册入口,根据UStruct对象,类型名,以及Class类型(是UClass还是UScriptStruct)创建FClassDesc,并将其存储到GReflectionRegistry对象的Struct2Classes中,这是一个Map,key为UStruct指针,注册这一次后下一次再访问该UObject类型的反射数据UStruct时,直接从GReflectionRegistry对象中根据指针映射获取对应的描述信息FClassDesc。
这里又调用了ClassDesc->GetInheritanceChain(StructChain)获取继承链,是循环将该类型的所有父类的UStruct存到一个列表中,然后递归注册父类的FClassDesc,这么做是后面注册该类型的lua metatable表时,需要递归创建父类的metatable,需要用到父类FClassDesc,因此在这里直接提前创建。
1.2 注册Metatable
注册完FClassDesc后,需要根据该数据为其在Lua中注册metatable,这个metatable就是访问的C++对象类型在Lua中的元数据,所有在Lua中对该C++对象类型及其数据的访问都需要经过该metatable,metatable注册流程:
根据该UObject对象类型的继承链,将自身以及父类所有对应的FClassDesc加入到一个列表中,然后递归去为自身以及父类创建metatable,这里注意顺序,会先注册父类,后注册子类确保注册子类metatable关联父类时,父类已经有了metatable。这段代码中还涉及到C++类型的静态注册,后面会详细介绍。Metatable注册入口:
函数RegisterClassCore是注册metatable的实际入口,代码稍长,但是内容不多,这里我们以UBlueprintPathsLibrary类型为例,具体看下是如何为该类型的metatable是如何注册的,又注册了哪些内容。
Metatable注册函数分析:
- 根据类型名”UBlueprintPathsLibrary”在Lua中创建一个空的metatable.
- 如果该类型有父类,那么把父类的metatable存储到ParentClass字段中(父类的metatable的创建先于子类)
- 添加字段__Index=Class_Index,添加读数据元方法,Lua中读取类型成员数据都会通过该元方法
- 添加__newindex=Class_NewIndex,添加写数据原方法,Lua中写入成员数据都会通过该元方法
- 添加TypeHash字段,以UBlueprintPathsLibrary类型的反射类型UStruct的地址作为该类型的Hash.
- 添加ClassStruct字段,存储了类型的反射数据UStruct
- 添加ClassDesc字段,存储了FClassDesc,这是反射数据UStruct的封装结构
- 添加StaticClass函数,Lua中获取UClass类型接口
- 添加字段__eq=UObject_Identical,添加了Lua中判断两个UObject对象是否相同的元方法
- 添加字段__gc=UObject_Delete,添加Lua GC元方法,在Lua对象被GC回收时会触发。
- 最后,把自己设置为了自己的metatable,为了在Lua中可以直接根据类型直接访问成员数据。同时,又把metatable存到了G表一份,可以直接从G表中访问。
整个metatable注册的内容就这些,看看最后这个metatable的样子:
所以,当我们在Lua中执行UE4.UBlueprintPathsLibrary时,就是去G表中找UBlueprintPathsLibrary变量,如果没有,UnLua就会先注册反射数据描述信息FClassDesc,在注册metatable,然后将metatable设到G表里,所以C++类型UBlueprintPathsLibrary在Lua中存在的方式就是一个metatable,访问UE4.UBlueprintPathsLibrary时就是在访问一个metatable表
二. Lua访问UObject对象类型中的函数
当我们在Lua中取到UObject对象类型数据后,可以在Lua中根据类型访问成员数据,这一点就像在C++中我们根据类型访问其成员变量与函数,如Class::StaticProperty,Class::StaticFunction,在C++中我们是可以直接根据类型对静态成员变量和函数进行访问的,但是在Lua中不支持根据类型对静态成员变量的访问,因为UE4的反射机制不支持static变量,这样就拿不到成员变量地址,就无法直接根据类型访问static成员变量,不过可以通过static函数包装下获取。所以Lua中根据类型只能访问成员函数,那么Lua是如何根据类型访问成员函数的呢
举例:
通过UBlueprintPathsLibrary.ProjectLogDir函数获取日志路径,首先了解UBlueprintPathsLibrary.ProjectLogDir是如何访问这个函数的,它在Lua中又是如何存在的。根据上面介绍UBlueprintPathsLibrary在Lua中是一个metatable,我们直接访问ProjectLogDir成员,但是它里面是没有该变量的,这就触发了__index元方法,转发调用到C++接口Class_Index,看看通过Class_Index是如何访问函数成员的:
首先转发调用GetField():
GetField函数主要目的是根据成员名,将访问类型中成员的反射数据压入栈中,它的实现中主要做了两个事情:
- 在对应的反射数据描述类型FClassDesc中注册访问成员的描述信息FFieldDesc以及反射描述信息FPropertyDesc/FFunctionDesc
- 将成员数据的反射描述信息压入栈中,并缓存到metatable中。
2.1 注册FPropertyDesc/FFunctionDesc
先获取访问类型的metatable,根据metatable拿到对应类型的FClassDesc反射数据描述信息,然后根据访问的成员名,在FClassDesc中获取对应的成员反射描述信息,如果没有该成员名的,那么就需要注册一个,成员反射描述信息注册入口:
注册流程:根据成员名,在UStruct中查找是否有对应的反射变量数据FProperty,会去遍历基类的属性列表。没有则查找是否有反射函数数据UFunction,无论是变量还是函数在查找的过程中,都会去遍历基类成员。
如果找到了对应成员名的FProerpty或UFunction,那么需要根据它们的outer(也就是属性或者函数所在的类型)去注册成员描述信息FFieldDesc,以及反射数据描述信息FPropertyDesc/FFunctionDesc。这里需要注意,如果访问类型的UStruct与找到的属性Outer的UStruct不是同一个,那就说明访问的成员是基类的成员,那么需要在基类中注册成员的描述信息,不在当前类注册。
FPopertyDesc的注册: FPropertyDesc是对反射数据FProperty的封装,FPropertyDesc::Create会根据FProperty表示的变量类型来创建对应的描述类型,主要是对FProperty指针以及一系列操作函数进行封装处理,最后存放到所属类型的FClassDesc的Properties中,如图:
FFunctionDesc的注册:FFunctionDesc是对UFunction反射数据的封装,会将UFucntion以及对应的函数默认参数构造一个FFunctionDesc,存储到所属类型的FClassDesc的Functions中,FFunctionDesc构造函数中主要对UFunction的调用进行一些准备工作。如准备函数调用参数的内存空间,创建函数参数的反射描述信息FPropetyDesc,记录返回值的属性索引,引用属性的传回地址等。
UnLua支持在Lua中调用具有默认参数的C++函数,其原理是UnLua实现了一个UHT的拓展Plugin,在UHT生成反射数据时,会对每个UClass进行导出,遍历其中的UFucntion,如果UFunction中的参数含有默认参数,那么就会记录下来,最终收集到文件DefaultParamCollection.inl中。在lua state启动后会将收集的所有UClass中的函数默认参数注册。
FFieldDesc的注册:FFieldDesc是对FFunctionDesc与FPropertyDesc的统一描述,根据FFiledDesc可以准确的找到FClassDesc中的成员数据的反射信息。其中FiledIndex > 0表示该Field是变量,FieldIndex < 0 表示该Filed是函数。
2.2 反射描述信息入栈
注册成员反射描述信息后,我们已经有了对应成员名的反射数据FFunctionDesc/FPropertyDesc,这样我们无论是访问成员变量还是函数都方便了许多,先将反射数据压入栈中,函数入口:
对于访问成员变量,会将属性反射数据描述信息FPropertyDesc压入栈中,后面会进一步对FPropertyDesc处理得到对应的成员变量的属性值。
对于访问成员函数,会将函数反射数据描述信息FFunctionDesc与函数Class_CallUFunction打包成一个lua的closure压入栈中,在Lua中调用UObject成员函数都是转发调用Class_CallUFunction来辅助实现的。Unlua也支持了对UE含有LatentInfo特性的函数做了支持,含有LatentInfo标识的函数会通过Class_CallLatentFunction辅助实现。
将反射数据压入栈后,会将该数据缓存到metatable中一份,下次访问该成员数据,可以直接从缓存中获取。
至此,回归到最初我们在Lua中执行UBlueprintPathsLibrary.ProjectLogDir去访问UBlueprintPathsLibrary类型中的ProjectLogDir函数时,我们就可以知道,我们先去名为UBlueprintPathsLibrary的metatable中的找key为ProjectLogDir的成员,如果没有找到触发调用了Class_Index,在UBlueprintPathsLibrary对应的FClassDesc反射数据中去找ProjectLogDir成员,如果没有则注册成员函数的反射数据FFunctionDesc,最后将Class_CallUFunction函数与反射数据FFunctionDesc打包成一个lua closure压入栈中,所以Lua中根据类型去直接访问其成员函数,最终会把一个lua closure压到lua中,后续调用也是在调用该closure。图解:
三. Lua调用UObject对象类型中的函数
当我们在Lua中根据UObject对象类型访问到了类型中的函数,通常我们都会对函数进行调用,例如:
调用UE4.UBlueprintPathsLibrary.ProjectLogDir()来获取项目日志路径,那么在Lua中是如何根据一个UObject类型来调用成员函数的?
我们已经知道UObject对象类型中的函数在Lua中是一个lua closure,它是C++函数Class_CallUFunction与成员函数的反射信息FFunctionDesc组成的,所以在Lua中调用UObject类型成员函数就相当于调用这个lua closure,最终会转发调用Class_CallUFunction。
Class_CallUFunction会获取绑定的lua upvalue,也就是FFunctionDesc,这个包含了调用函数反射信息的结构,有了它,通过反射机制来调用UFunction函数就成了可能。Lua调用UFunction通过FFunctionDesc::CallUE函数实现,该函数根据UE反射机制实现Lua中对绑定的UFunction调用,主要分为4个步骤:
- 获取UFunction调用的UObject实例
- 准备UFunction调用的函数参数
- 根据UFunction完成反射函数调用
- 引用参数回写,返回值入栈
接下来具体分析每一个步骤是如何实现的
3.1 获取UFunction调用的UObject实例
关键代码:
UE的反射机制,在生成UObject成员函数反射调用的辅助代码时,并没有区分静态函数与非静态函数,它们的函数声明,都用了同一个宏定义展开,这样就导致它们的反射函数参数类型个数相同,所以静态成员函数也需要一个UObject对象实例。虽然对于static静态函数,这个UObject对象实例并没有用到。
对于静态函数的反射调用,UObject实例直接使用UClass的CDO对象。
对于非静态函数,根据Lua函数传入参数的第一个参数来获取UObejct对象实例,这个参数可能是一个lua table也可能是一个userdata,后面会对UObject对象实例的访问具体讲解,先了解就好。
3.2 准备UFunction调用的函数参数
代码入口:
FFunctionDesc::PreCall进行了一些Lua调用UFunction的准备工作,其中最主要的就是准备UFunction调用的函数参数。
首先准备函数参数的内存空间Params,FFunctionDesc会准备ParmsSize大小的内存空间,用来存储UFucntion调用参数。UFunction调用反射函数需要的普通参数,引用参数,返回值参数会统一放置在Param这一块内存中。(UE的Script VM会依赖反射机制按照FProperty的属性类型,偏移地址获取到正确的参数值,然后去传参调用反射函数,最后得到的返回值在写入到Param内存块的返回值偏移地址中。)
然后遍历FFunctionDesc的参数列表Properties,会依赖每个参数的FProperty将Params内存块中每个参数初始化,然后将Lua栈中的函数参数逐个取出并拷贝到Params内存块中,填充Params内存,如果Lua传入的函数参数数量小于UFunction的参数个数,可能是该C++函数有默认函数,Lua传参时使用C++默认函数,这时就需要去FFunctionDesc构造时传入的默认参数数据中查找,如果有就继续填充Params内存,没有就说明Lua传入参数错误。
至此,UFucnton的反射调用需要的参数数据就准备好了,接下来具体看下如何实现UFucntion的反射调用。
3.3 UFunction反射调用
关键代码:
有了函数参数,UFucntion就可以完成反射调用,这里简单说下UE是如何通过UFunction来实现对反射函数的调用的。
UE中反射调用函数有两种情况,一种是反射调用C++ Native函数,另一种是调用UE的蓝图脚本。
3.3.1 反射调用C++ Native函数
UE在编译时会为UFUNCTION()标识的UObject成员函数,生成反射调用的辅助函数,举例:
这个函数UHT会为其生成反射调用的辅助代码:
对其进行展开:
这就是反射调用C++ Native函数的辅助函数,我们可以看到,UHT生成的辅助代码用exec作为前缀,将我们实际的C++ Native函数Example进行了封装,使用了统一的函数参数,可以方便反射调用,Context就是UObject对象实例,对于静态函数,这个参数并没有用处。Stack参数是FFrame结构,该结构可以理解为反射函数一次调用所需要的上下文环境,所有执行调用工作依赖的数据都存储在这个结构中,包括UFunction,函数参数内存块,函数参数反射信息等。根据这个结构可以准确的获取到每个函数的传入参数的数值,以及引用参数的传出。Z_Param__Result参数是返回值地址,C++ Native函数返回值写到该地址中。
反射调用C++ Native函数首先需要获取函数参数,在上面例子生成的反射调用辅助代码中,我们可以看到Example有两个参数。
一个是int32的普通类型,语句Stack.StepCompiledIn<FIntProperty>(&Z_Param_param1)它会将参数内存块中第一个参数值拷贝到Z_Param_param1。
一个是FString的引用类型,语句FString& Z_Param_Out_param2 = Stack.StepCompiledInRef<FStrProperty, FString>(&Z_Param_Out_param2Temp)会将引用变量Z_Param_Out_param2使用参数内存块中的第二个参数初始化,这样使用Z_Param_Out_param2变量会直接作用于参数内存块,来实现引用参数回写。
最后将这两个参数传入函数Example进行调用,这样就完成了对C++ Native函数的调用。
注:在UE启动时,在为每个UClass注册反射信息,生成UFucntion时,如果该函数存在Native版本,那么就会将UFunction与其绑定,将exec<Function>反射函数存储到UFunction中的Func函数指针变量中,UFunction实现反射调用的最后都会转发调用Func来完成。
3.3.2 反射调用UE的蓝图脚本
对于是在蓝图编辑器中写的蓝图脚本,UE会将其解释为字节码,存储到UFunction的Script中,这是一个字节流,它的每一个字节码都会对应一个C++函数来完成操作。蓝图脚本的反射调用统一使用UObejct::ProcessInternal实现(被绑定到UFucntion的Func指针上),其核心逻辑就是遍历字节码,根据每一个字节码进行对应的C++函数调用,其参数也是由FFrame上下文环境提供,最终完成蓝图脚本的执行。
至此我们回归到UnLua对UFucntion的反射调用,在我们准备好函数参数后,就可以构造反射调用依赖的上下文环境FFrame,以及返回值地址,完成UFunction反射调用。
3.4 引用参数回写,返回值入栈
在我们完成了UFunction的反射调用,我们的Params参数内存中的引用参数以及返回值参数就已经是调用后的结果,但是这些结果目前还在C++中,所以还需要将结果传回Lua中,这样来完成Lua调用的最后一步。
参数回写代码入口:
首先是函数返回值,如果该函数有返回值,那么就从Params参数内存中的返回值地址中取出返回值并压入到lua栈中,对于bool,int,FString等基础类型可以直接转换为lua的bool,number,string然后入栈,对于TArray,TMap,TSet等复合类型额外做了一层封装,使用userdata绑定metatable的方式,将userdata压入栈中。
然后是引用参数,我们知道C++函数参数支持引用传递,但是Lua函数参数的基础类型bool,number,string是值传递,table,userdata是引用传递,所以Unlua对于Lua中调用C++带有引用参数的函数做了不一样的支持。对于Lua中支持引用传递的参数类型userdata,UnLua从Params参数内存中的引用参数地址取出数值,封装转换,回写到Lua栈的userdata内存块中,这种userdata的函数参数就只有UObject,Struct,TArray,TMap,TSet这几种类型。对于bool,number,string这几种只支持值传递的类型,UnLua利用了Lua支持多返回值的特性,将Params参数内存中取出引用参数数值,压入Lua栈中,以多返回值的方式来实现。
最后,将Params内存中的传入参数进行销毁析构,至此就完成了Lua反射调用UFucntion的整个流程。
Lua反射调用UFucntion简单图解(以上面Example函数为例):
四. Lua访问UObject对象实例(不绑定Lua)及其函数/属性
上面我们已经清楚如何在Lua中访问一个UObject对象类型,并且根据类型直接访问调用其成员函数。我们在Lua中调用C++函数经常会返回一些UObejct对象实例,我们需要在Lua中对UObject对象实例进行一些操作,从而达到我们的目的。那么我们在Lua中如何去访问一个UObject对象实例呢,UObject对象实例在Lua中是以什么形式存在的?又如何在Lua中去访问UObject对象实例的成员函数/属性呢?
4.1 Lua中访问UObject对象实例
举例:
示例中,使用Lua根据一个UMG BP资源路径,创建了一个UMG实例UMGInstance,然后将该UMG添加到视口中。
创建实例的语句正是使用了上面介绍的根据UObject对象类型,调用成员函数完成的。这个UE4.UWidgetBlueprintLibrary.Create会反射调用到C++中同名函数,最终将返回值压入到Lua栈中。该返回值在C++中是UUserWidget*类型,是一个UObject对象,所以属性的反射描述信息为FObjectPropertyDesc,该类型会将返回值从参数内存块中取出返回值,并完成Lua压栈操作。关键代码:
转发调用到
将UObject对象实例压入Lua栈中会先去注册表中的ObjectMap去查找Lua中是不是已经有了个UObject对象实例,如果没有就要新建一个,新建完成后会缓存到ObjectMap中方便下次取用,同时还会将UObject对象实例加入到GObjectReferencer保持该对象在C++侧一直被引用从而不被自动GC回收掉,导致Lua侧访问出错。
Lua中新建UObject对象实例,入口代码:
在Lua中新建UObejct对象实例,主要做了两个事情,一是创建了一个userdata,该userdata内存占用为一个指针变量的大小,然后将UObject对象指针存储下来。二是注册该UObject对象类型到Lua中,流程如第一节讲述的一样,先注册FClassDesc反射描述信息,在注册metatable到lua中。然后将该metatable表设置为该userdata的metatable。
所以,在Lua中一个UObject对象实例,就是一个绑定了metatable的userdata,所有对该实例的访问都需要经过metatable来完成。
图解(以UUserWidget为例):
4.2 Lua中访问调用UObject对象实例中的函数
在Lua中获取UObject对象实例后我们通常会去访问调用其中的函数,如上面示例中将UMG添加视口调用AddToViewport。对UObject对象实例中的函数的调用其实与对UObject对象类型中的函数调用流程是一样的,上面第2,3节已经讲述,这里不再赘述。但是有一点不同,就是根据类型调用成员函数,只能调用静态成员函数,非静态成员函数都需要对象实例。
调用UObject对象实例中的函数,第一个参数就是UObject对象实例,就是将代表UObject对象实例的userdata压入栈中,UnLua会根据该userdata其中存储的C++ UObject对象指针,从而来完成反射调用。
关键代码:
对UObject对象实例调用成员函数,会先根据lua userdata获取full userdata中存储的C++ UObject对象指针,然后对该UObejct执行反射调用UFunction。调用流程与2,3节记录的根据类型调用成员函数相同。
4.3 Lua中访问UObject对象实例中的属性
Lua中有了UObject对象实例,可以直接访问,对其进行读写,举例:
我们知道Lua中的UObject对象实例是一个userdata绑定了metatable,所以对其属性的访问读写,都通过元方法来完成,读取数据都通过Class_Index完成,写数据通过Class_NewIndex完成,这里只记录读取数据,写数据与读数据大同小异。
代码入口:
首先转发调用GetFiled,在第2节详细讲述过GetField主要目的是将成员属性/函数的反射描述信息压入栈中,对于成员属性,压入的就是FPropertyDesc描述数据,该数据包含访问成员属性的反射信息。
有了属性反射数据后,根据传入的第一个参数,也就是lua userdata,获取userdata中存储的C++ UObject对象指针。
然后就可以根据成员变量的反射信息,成员在UObject对象中的偏移地址,成员属性类型,从UObejct对象指针中获取到成员对应的值,将值压入到Lua栈中,这样就完成了属性访问。(对于基础类型,bool,int,FString等直接简单转换入栈,对于UObject,Struct等结构类型,使用userdata绑定metatable入栈)
至此,对UObject对象实例的以及函数/属性的访问流程就基本完成了。