Doris232 发表于 2023-2-23 11:20

UnLua框架解析-UE引擎Lua框架解决方案 (下)

导语:
UnLua是针对UE引擎的Lua框架解决方案,功能强大,使用简单,零胶水代码实现C++与Lua之间的相互调用。
但是在使用UnLua的过程中,经常会因为一些使用不当导致各种问题,崩溃等。定位问题耗费大量时间,所以对UnLua框架及其原理进行深入的学习并记录。文章会按照自己的理解过程来做整理,尽可能阐述清楚。不会记录UnLua的基础使用教程,仅对其框架原理等做解析,UnLua的使用教程可以参考UnLua官网Git.
接上篇:UnLua框架解析-UE引擎Lua框架解决方案(上)

目录:

[*]五.UObject绑定Lua原理与实践
[*]5.1 UObject绑定Lua实践
[*]5.1.1静态绑定
[*]5.1.2动态绑定
[*]5.2 UObject绑定Lua流程
[*]5.2.1 Lua Function覆写BlueprintEvent/RepNotifyFunc
[*]5.2.2创建UObject对象实例,绑定Lua Module
[*]六.Lua访问UObject对象实例(绑定Lua)的属性/函数
[*]6.1访问UObject对象实例(绑定Lua)属性
[*]6.2访问UObject对象实例(绑定Lua)函数
[*]七.C++静态注册到Lua
[*]7.1收集阶段
[*]7.2注册阶段
[*]八.UnLua与UE4混合GC
[*]8.1 Lua GC与UE GC简单介绍
[*]8.2 Lua中如何持有UObject对象实例不被UE GC
[*]8.3 UE中如何持有Lua对象不被Lua GC
[*]8.4 UnLua与UE混合GC流程

五. UObject绑定Lua原理与实践

我们知道在Lua中UObject实例就是一个userdata,我们可以根据它的metatable去访问UObject实例中的函数和属性,但是访问的属性函数都是UObject对象在C++定义或蓝图中已有的。
然而,为了提高灵活性,UnLua又为我们提供了一个强大的特性,就是可以为UObject绑定一个Lua Module(一个lua table),这个特性非常的棒,棒的点我觉得有两个:
1. 它可以让我们在Lua中为这个UObject对象进行拓展,可以让我们自己定义一些属性,函数然后绑定到该Lua的UObject对象实例,就像该UObject既有的属性与函数一样通过UObject对象实例在Lua访问。
2. 它可以让我们在Lua中覆写UObject对象中的BlueprintEvent与RepNotifyFunc函数,也就是蓝图事件函数与属性同步回调函数,覆写后,当我们在C++中调用蓝图事件或者属性同步回调,会调用我们覆写的Lua函数。我们不需要在蓝图编辑器中使用蓝图脚本编写蓝图事件/属性同步回调,可以把这个工作在Lua中完成。这样做的好处一是蓝图脚本性能不如lua,尤其是规模大的逻辑。二是作为程序的角度,个人并不喜欢蓝图脚本开发,其图表式的编程语言一是不适应,再就是复杂的逻辑想编写图表优美,清晰,可读性强的代码不容易,多数时候是一团网(这里不是否定蓝图脚本,其强大不言而喻)。

5.1 UObject绑定Lua实践

为UObject对象绑定Lua Module有两种方式,静态绑定与动态绑定,先简单介绍下两种绑定的使用方式:
5.1.1 静态绑定

静态绑定是为一个UObject对象类型关联一个Lua文件,这个UObejct对象类型的所有实例创建时都会自动require该文件,为该实例绑定该Module。关联Lua文件可以在蓝图编辑器中关联,也可以在C++代码中,其原理就是派生UnLuaInterface接口类的GetModuleName方法。
举例:


创建了一个UMyObject类型,它的Example函数是一个蓝图事件,正常情况下我们需要使用蓝图脚本来定义它的实现。Print是一个用来验证的测试函数。


以UMyObject类型为基类,创建了一个BP_MyObject蓝图类,该蓝图类继承了UnLuaInterface,并重写了GetModuleName接口,在它的返回值中配置与BP_MyObject关联的Lua文件(相对于Script目录下),Test.lua。


Test.lua中返回了一个LuaUnrealClass类型的table,该table会与BP_MyObject对象实例绑定。它实现了一个Example函数,该函数就会覆盖UMyObject类型中的Example蓝图事件。
测试代码:


测试结果:


我们可以看到在C++中的调用Example最终调用的是绑定Lua中的Example函数。
除了这种方式,还是可以直接在C++中继承UnLuaInterfance,重写GetModuleName函数,其效果是一样的。


以上是静态绑定的方式。
5.1.2 动态绑定

静态绑定的方式,是对UObject对象类型继承UnLuaInterface,实现GetModuleName接口,这种方式绑定的Lua文件会写死,并且所有该UObject对象所有实例创建时都会自动绑定。UnLua又提供了一种动态绑定的方式,绑定的类型可以不用继承UnLuaInterface。但是也有限制,只有在Lua中调用NewObject和SpawnActor时创建的UObject才能动态绑定。
举例:


同样使用UMyObject类型,然后继续使用之前创建BP_MyObject,但是这次无论是C++中还是蓝图都不在继承UnLuaInterface。
测试用例:


在调用NewObject时,将绑定的Lua文件路径作为参数传入。
测试结果:


结果是一样的。
在了解UObject对象绑定Lua的方式与好处后,再来具体分析下UObeject对象是如何绑定Lua的。

5.2 UObject绑定Lua流程



首先UnLua会监听UObject创建事件,在每个UObject创建后,都会转发调用到该接口,然后在该接口中去对每个UObject尝试绑定Lua文件。尝试绑定:


在尝试绑定Lua接口中,主要判断该UObject对象类型是否继承UnLuaInterface,并实现了函数GetModuleName,如果有,说明符合静态绑定的条件,UObject对象类型关联了Lua文件。如果不满足静态绑定,会判断该类型是否满足动态绑定。对于符合绑定Lua条件的UObject对象实例会继续进行绑定,不符合的就忽略了。
绑定原理:


UUnLuaManager::Bind接口是绑定Lua的实际入口,在绑定Lua Module的过程中主要做了两个事情

[*]Lua Function覆写BlueprintEvent/RepNotifyFunc
[*]创建UObject对象实例,绑定Lua Module
5.2.1 Lua Function覆写BlueprintEvent/RepNotifyFunc

函数入口:


Lua函数覆写从代码上看很清晰,主要流程就3个:


遍历了要绑定的Lua Module(table表),将所有的Lua函数名收集到一个TSet中。


遍历UObject对象的UClass中的所有UFucntion,收集BlueprintEvent函数。遍历UClass中所有的RepNotify属性,如果该属性绑定了回调函数,也收集起来。


有了Lua Function列表和被替换的UFunction列表,就可以使用同名的Lua Function做替换,下面具体看下Lua Fucntion实现替换C++ Native Fucntion/蓝图脚本的细节:


在替换之前做了一些准备工作,UnLua并没有直接覆盖替换C++ Native Fucntion,而是将原UFucntion做了一个备份,将其命名为<FunctionName>Copy,然后加入到UClass中。通过备份的方式,在Lua中依旧可以访问到原有的C++ Native版本。如果UFunction绑定的是蓝图脚本,会缓存字节码。通过备份数据,后面对替换的UFunction还可以进行复原。
Lua Function替换入口:


先说下Lua函数替换UFucntion的原理,其主要还是依赖了UE的反射机制,我们知道UFunction能反射调用C++ Native函数/蓝图脚本,是因为它的成员(FNativeFuncPtr)Func绑定了调用函数的函数指针。对于C++ Native函数,会生成辅助函数来支持反射调用,在gen.cpp文件中我们可以看到函数原型,在UFucntion构造时会进行绑定。对于蓝图脚本,会绑定UObject::ProcessInternal,来辅助调用蓝图脚本的字节码。所以要想一个UFunction反射调用我们自定义的Lua Fucntion,就需要将UFunction的Func绑定到我们的Lua Fucntion,这也就是Lua函数覆写BlueprintEvent/RepNotifyFunc的原理。
然后我们看下代码细节,首先做的就是使用FLuaInvoker::execCallLua替换的UFunction绑定的Func,让所有通过反射机制调用的UFucntion都转发调用FLuaInvoker::execCallLua,通过这个函数,就可以转发调用到我们自定义的Lua Fucntion。当然光函数自身还不行,还需要UFucntion反射数据,这里使用FFunctionDesc包了一层,将FFucntionDesc存到UFunction的Script变量中,这个Script本身是用来存储蓝图字节码的,这里UnLua按照自己的协议来存储调用数据,后面调用时在按照协议取出数据。
那么execCallLua是如何转发调用到我们自定义的Lua Fucntion的呢,看代码:


调用execCallLua时都是通过反射调用进来的,所以会传入调用UObject对象,FFrame调用的上下文环境,包括UFucntion,引用参数地址,返回值地址等。
在调用中按照之前替换Lua Function时,在Script附带的函数反射数据,以相同的协议取出FFunctionDesc,这里首先忽略了EX_CallLua字节码,因为该字节码本身就标识执行execCallLua,我们已经绑定Func调用ExecCallLua,所以这个字节码这里没用。
然后通过FFunctionDesc调用CallLua,这里就不详细展开了,其流程与上篇第3节中讲述的Lua调用UFucntion类似,简单说下它是怎么调用到Lua函数的。

[*]找到绑定UObejct对象实例的Lua Module,在Lua Module中找到与FFucntionDesc的同名Lua函数,找到了函数压入到Lua栈中。
[*]将反射调用的上下文环境FFrame中的参数内存,按照参数顺序从内存中读取参数,然后将函数参数按顺序压入栈中。
[*]调用Lua函数
[*]将调用后的栈中返回值,回写到反射调用的参数内存中。这里对于Lua中的基础类型不支持引用,以多返回值形式实现。
以上这个过程其实与上篇中所提到的Lua反射调用UFunction的操作,正好是一个逆操作。其原理是一样的。
简单图解(以int32 Example(int32, FString&)为例):


以上就是Lua Function覆写BlueprintEvent/RepNotifyFunc的流程与原理,UObject实例绑定Lua就完成一半儿了,接下来就是在Lua中创建UObject实例并绑定Lua。
5.2.2 创建UObject对象实例,绑定Lua Module

代码入口:


对于一个UObject对象实例,在不绑定Lua Module的情况下,它是一个userdata在Lua中存在,但是在绑定Lua Module的UObject实例却不是userdata,而是一个lua table,拓展性更强更灵活。具体看下绑定Lua的UObject对象实例的创建流程:

[*]创建空lua table,作为UObject对象实例,记为LuaInstance
[*]将UObject对象压入栈,并存储到LuaInstance.Object
[*]压入Lua Module,压入UObject对象类型的metatable
[*]设置metatable到LuaModule.Overriden
[*]将UObject对象类型的metatable设置为LuaModule的metatable
[*]将LuaModule设置为LuaInstance的metatable
[*]将LuaInstance存入ObjectMap
简单图解:


如图,对于一个绑定Lua的UObject对象实例,在Lua中的存在就是Lua Instance。

六. Lua访问UObject对象实例(绑定Lua)的属性/函数

上篇讲述过在Lua中访问一个没有绑定Lua的UObject对象实例属性/函数的原理,这里继续看下对于绑定Lua的UObject对象实例是如何访问属性或者函数的。
6.1 访问UObject对象实例(绑定Lua)属性

先在Lua Instance这个table表查找属性,如果没有就会触发元方法,也就是Lua Module中的__Index,看下Lua Module的__Index定义:



[*]我们可以看到元方法首先获取Lua Instance的metatable,也就是Lua Module,在Lua Module中查找,是否有我们自定义拓展的属性,没找到会对它的Super表递归查找。
[*]如果Lua Module中没找到,会继续触发绑定它的metatable(Lua中UObject对象类型)中的__Index,转发调用到C++函数Class_Index,上篇中有讲述它的访问流程,但是这里还有一点不同,源码:


GetField会将属性的反射描述信息FPropertyDesc压入栈中,然后根据偏移地址去ContainerPtr中获取值,但是该Class_Index是根据Lua Module触发的,所以它的栈中的第一个参数是Lua Module,只根据Lua Module是获取不到C++ UObject对象指针的,所以这里的ContainerPtr是nullptr,所以mt返回值p的是一个lightuserdata,实际是访问属性的反射描述数据FPropertyDesc指针

[*]以LuaInstance和FPropertyDesc指针作为参数调用GetUProperty,转发调用到C++函数Global_GetUProperty。这样又有了UObject对象指针,以及属性的反射信息,访问属性就简单了,流程参考上篇第4节。
6.2 访问UObject对象实例(绑定Lua)函数

访问函数与属性流程相同,不同的点是通过Lua Module触发Class_Index时并不需要UObject对象指针,GetFiled会直接压入lua closure(Class_UFunction+FFunctionDesc)。

七. C++静态注册到Lua

UnLua可以零胶水代码实现Lua访问UE的UObject对象等,主要依赖了UE的反射机制,但是这样有个前提,就是访问的这些类,方法需要被UCLASS,UPROPERTY等标识,只有被标识的类,属性才会导出反射数据。所以如果我们想要在Lua中访问那些不支持反射的类,属性,就无法访问了。比如引擎的某些类,方法默认不导出,我们又不想修改引擎。UnLua提供了C++静态注册Lua。
C++静态注册到Lua是一个强大的功能,UnLua使用类模板,抽象出了一套通用的C++静态注册的宏定义。这里只用一个简单的例子来讲述,其中的原理大同小异。
举例:


FColor是UE的一个常用的基础类型,所以在Lua中也是需要经常使用的,但是UE对FColor只导出了基础属性。所以UnLua使用静态导出拓展了FColor。
首先自定义了一系列lua_CFunction用来作FColor的元方法,让FColor在Lua中更方便操作,然后用宏定义对FColor的导出拓展。
静态导出强大的点就在于将复杂的注册流程抽象成了简单的宏定义,我们可以看出示例中的FColor将操作符函数FColor::operator+=注册为Add,FColor::ReinterpretAsLinear注册为ToLinearColor,以及将一系列自定义lua_CFunction注册。将示例中的宏定义展开:


C++类型静态注册主要使用了TexportedClass模板类来实现,其注册流程主要分为两个步骤:收集与注册。
7.1 收集阶段

UnLua利用了static变量在程序Main入口前初始化,将所有的要静态导出的变量都收集起来,如上图所示,FExportedFColorHelper构造时会创建TExportedClass<true, FColor>对象,该类型是静态导出的辅助类。然后将对象加入到LuaContext的ExportedReflectedClasses中,该Map收集了所有支持反射的类型(静态导出的类型分支持反射与不支持反射,其区别在于注册的类型是不是已经UE反射机制自动导出)。我们可以看到FColor想要静态注册的函数名Add,ToLinearColor以及对用的C++函数指针,都添加到了TexportedClass<true,FColor>对象中。
因为静态注册都是不支持反射的函数/属性,所以UnLua静态注册手动的去将函数名与函数指针关联,属性名与属性偏移地址关联,然后在收集阶段存放到TexportedClass导出类中,后面统一注册到Lua中。
7.2 注册阶段

对于已经反射导出的类型,在Lua中第一次访问该类型,或者该类型的实例,会触发该类型的metatable注册,在注册该类型的lua metatable时,会将静态导入的类型中的函数,属性,以及lua_CFunction一起注册到metatable中。注册入口:


关键代码:




在注册类型的lua metatable时对该类型静态注册,将静态注册辅助类中收集的注册属性,函数,lua_CFunction都注册到metatable中。
注:对于不支持反射的类型,静态注册会在lua state创建后统一注册,而不是在Lua中访问时触发。

八. UnLua与UE4混合GC

8.1 Lua GC与UE GC简单介绍

Lua GC:Lua语言自动管理内存,开发不需要考虑内存分配,对于在Lua中不再使用得Lua对象会被自动GC。Lua使用的GC算法是标记清理算法,Lua在创建可被回收的对象时(string,table,userdata,function,thread类型),都会自动将它们串到一个global_State::allgc全局链表上。标记阶段从ROOT开始遍历对象引用,将能遍历到的对象标记为活对象。清理阶段遍历通过分配器分配出来的对象全局链表,将没有标记为活对象都删除。Lua的ROOT包括注册表,G表,主线程。然而Lua运行时的对象,要么存在于注册表或G表直/间接引用中,要么存在于Lua执行栈上(执行栈被线程结构引用),所以当我们不再使用的对象(无法通过引用找到的对象)都会被Lua GC自动回收。
简单图解:


UE GC:C++语言自身不提供GC机制,需要开发自行处理内存申请与回收,而UE在UObject对象基础上实现了一套自动垃圾回收机制,使用的也是标记清理算法,标记指的是它以所有在ROOT上的UObject列表为根,去递归遍历所有这些根UObject的引用链,所有能访问到的UObject就标记为可达的。清理阶段会在标记完后,收集所有这些UnReachable UObjects,对其进行清理回收。其算法的核心思想与Lua GC算法的核心思想是相同的。一个UObject对象想纳入UE GC机制管理,其需要被UPROPERTY标识修饰,或者实现了UObject/FGCObject对象的派生接口AddReferencedObjects,手动加入到引用链。所以当我们不再使用一个UObject对象时,我们可以将所有引用UObject的对象置nullptr断开引用,让UE GC标记时找不到它,或者对UObject对象手动调用MarkPendingKill,下一次UE GC会自动标记该对象为UnReachable,进而被回收。
简单图解:


8.2 Lua中如何持有UObject对象实例不被UE GC

我们知道UObject对象实例在Lua中有两种存在形式,一种是UObject对象没绑定Lua,其在Lua中是以userdata形式存在。另一种是UObject对象绑定了Lua,其在Lua中是以table形式存在。无论以那种形式存在,在Lua中对UObject对象实例进行访问时,都需要通过Lua实例找到其相对应的C++ UObject对象指针,然后对其进行操作。但是UE的GC是自动进行的,所以我们在Lua中如果想正确访问UObject对象实例,就要确保它的C++ UObject对象指针没有被GC。
UnLua对于没绑定Lua的UObject对象实例,在Lua运行时对其进行访问时,在它入栈时(PushUObject)会将其关联的C++ UObject对象指针加入到FGCObject对象的ReferencedObjects中,确保UE GC时不会对访问UObject对象进行回收。


UnLua对于绑定Lua的UObject对象实例,会对其进行绑定中,在创建Lua Instance实例后将关联的C++ UObject对象指针加入到FGCObject对象的ReferencedObjects:




这样,确保了Lua中访问的UObject对象实例不会被UE GC自动回收。
8.3 UE中如何持有Lua对象不被Lua GC

在UE中访问Lua对象时如table,function等,要确保访问的Lua对象不会被Lua GC自动回收掉,所以UnLua对于UE中访问的Lua对象,将其关联到Registry表,确保Lua GC时标记时通过引用链中找到该Lua对象,防止被回收


8.4 UnLua与UE混合GC流程

Lua GC与UE GC是两个独立的GC流程,但是当Lua中持有UObject对象实例时,UE侧也会持有该UObject对象指针,当Lua中申请的临时对象越来越多,UE侧持有的UObject对象也会越来越多不能释放,所以要确保Lua侧不在使用的UObject对象实例可以被Lua GC,同时关联的C++ UObject对象指针可以被UE GC,所以这就涉及到了UnLua与UE的混合GC。
对于不绑定Lua的UObject对象实例,userdata绑定的metatable中会设置__gc元方法,在Lua侧如果该userdata不再使用,即无法通过强引用关系找到该userdata,Lua GC会在GC流程中回收该userdata,回收前会触发该userdata的__gc元方法,在__gc中会将该UObject对象指针从FGCObject对象的ReferencedObjects中移除,表示Lua侧不在引用该UObject对象,如果UE侧没有其他对象引用该对象,后续会被UE自动GC回收。






简单图解:


对于绑定Lua的UObject对象实例,因为创建Lua Instance时将其关联到了Registry表,所以它会一直被注册表引用,Lua GC无法自动将其回收,我们需要手动调用Destroy将其释放




至此UnLua与UE混合GC流程交互就完成了。
但是也存在问题,因为Lua与UE GC触发的时机并不一致,Lua中持有UObject对象实例只占用一个userdata存储一个指针的大小,但是指向的UE侧UObject对象可能很大,所以如果频繁在Lua中创建临时的UObject对象,就会出现Lua侧对象占用的内存不多,没有达到Lua GC自动触发的条件,而这时UE侧对象占用的内存很大,却不能释放,所以不能只依赖Lua GC自动触发,可以定时手动触发Lua GC或者在UE GC触发前,触发Lua GC释放关联的UObject对象,来避免这种情况发生。

七彩极 发表于 2023-2-23 11:22

你好,我被诈骗犯勒索,看了你的关于qq定位的回答,能咨询你这方面的事情吗[大哭][大哭]

jquave 发表于 2023-2-23 11:27

太强了
页: [1]
查看完整版本: UnLua框架解析-UE引擎Lua框架解决方案 (下)