|
Lua是一种嵌入式脚本语言,可以方便的与c/c++进行相互调用。但是Unity中主要是用c#进行开发的,因此在Unity中使用Lua通常有以下两种方案:
使用c#实现一个lua虚拟机基于原生的c lua api做一个封装,让c#调用
从性能上考虑,当前主流方案都是第二种。
基于第二种方案实现的框架目前主要有xLua,sLua,uLua,NLua(+KeraLua)。在这些方案中,都能找到一个相关的类,封装了c#对lua c api的调用。例如在xlua中是XLua.LuaDLL.Lua这个类,在slua中是SLua.LuaDll这个类。
所以在Unity里执行Lua是以c作为中间媒介的:
C# <=> C <=> LuaLua与宿主语言(这里以c#为例)最基础的两种交互模式即:
这种交互是通过一个栈结构进行的。
为了更清楚的理解和阐述这个交互过程,本文将使用KeraLua来写一些用例代码。为什么使用KeraLua呢? 因为相比较xLua、sLua、uLua,KeraLua是一个纯粹的c#对lua c api的封装,没有多余东西。
KeraLua的Git项目 => https://github.com/NLua/KeraLua
Lua的所有的C API可以在官方手册中看到 => Lua Manual 5.4
(PS: 我只make了KeraLua的OSX库,其他平台的请自行编译)
本文中将会阐述的交互用例罗列如下:
初始化lua栈c#执行lua代码c#调用lua全局函数lua注册并调用c#静态函数lua注册c#类型注入c#类的静态函数注入c#类的构造函数注入c#类成员函数GC管理c#引用lua中的临时函数无法解决的循环引用问题
1. 栈的结构索引
Lua与宿主语言是通过栈进行交互的。在c中通常以lua_State* L的形式表示指向栈的一个指针,在c#中以System.IntPtr L的形式存在。
栈的元素用过index进行索引。以负数表示从顶向底索引,以正数表示由底向顶索引。如下图所示:
因此-1表示表示栈顶元素,1表示栈底元素。在许多api中,都需要通过索引来读取栈中数据、或者向栈中指定位置填充数据。
2. 创建Lua栈
var L = Lua.luaL_newstate();
Lua.lua_close(L);
luaL_newstate可以创建一个虚拟栈,返回的L为System.IntPtr类型,代表了栈的指针lua_close用于关闭释放栈
这个创建的栈,将用作c#与lua进行数据交互
3. c#执行lua代码
这里将分三个步骤:
加载lua代码到vm中,对应api - luaL_loadbufferluaL_loadbuffer会同时在栈上压入代码块的指针执行lua代码,对应api - lua_pcalllua_pcall会从栈上依次弹出{nargs}个数据作为函数参数,再弹出函数进行执行,并将结果压入栈如果lua代码有返回值,那么通过lua_toXXX相关api从栈上获取结果
完整的代码如下:
private bool DoLuaCode(System.IntPtr L,string luaCode){
//加载lua代码
if(Lua.luaL_loadbuffer(L,luaCode,&#34;&#34;) == 0){
//执行栈顶的函数
if(Lua.lua_pcall(L,0,1,0) == 0){
//函数执行完成后,返回值会依次依次押入栈
return true;
}else{
Debug.LogError(&#34;pcall failed!&#34;);
return false;
}
}else{
Debug.LogError(&#34;load buffer failed&#34;);
return false;
}
}
假如我们有一段lua代码:
return &#39;hello, i am from lua&#39;这段lua仅仅返回一段字符串,那么利用DoLuaCode去执行就是:
//lua代码
string luaCode = @&#34;return &#39;hello, i am from lua&#39;&#34;;
if(DoLuaCode(L,luaCode)){
Debug.Log(Lua.lua_tostring(L,-1));
//lua_toXXX不会出栈,需要lua_pop才能出栈
Lua.lua_pop(L,1);
}
由于此处lua代码返回的是字符串,因此使用lua_tostring(L,-1)来将栈顶的元素转为字符串并返回,相应的我们还能看到有lua_tonumber,lua_toboolean等等.
4. c#调用lua全局函数
接下来的例子将说明一下c#端如何执行lua中的全局函数。
假设现在我们有一段lua代码如下:
function addSub(a,b)
return a + b, a-b;
end通过DoLuaCode来运行以上的lua代码,就得到了一个全局的addSub函数,这个函数会返回a,b相加和相减的结果。
为了在c#端执行以上的lua函数,需要按以下步骤进行:
将全局函数压入栈中, 对应api - lua_getglobal将函数所需的参数依次压入栈中,对应api - lua_pushnumber执行栈中函数,对应api - lua_pcall获取函数返回结果,对应api - lua_tonumber
完整c#代码如下:
//从全局表里读取addSub函数,并压入栈
Lua.lua_getglobal(L,&#34;addSub&#34;);
//压入参数a
Lua.lua_pushnumber(L,101);
//压入参数b
Lua.lua_pushnumber(L,202);
//2个参数,2个返回值
Lua.lua_pcall(L,2,2,0);
//pcall会让参数和函数指针都出栈
//pcall执行完毕后,会将结果压入栈
Debug.Log(Lua.lua_tonumber(L,-2));
Debug.Log(Lua.lua_tonumber(L,-1));
Lua.lua_pop(L,2);
5. lua注册并调用c#静态函数
首先,想要被Lua调用的c#函数,都必须满足以下的格式:
public delegate int LuaCSFunction(System.IntPtr luaState);
同时需要加上特性:
MonoPInvokeCallback(typeof(LuaCSFunction))
我们可以通过以下方式,将一个LuaCSFunction注册到lua中:
static void RegisterCSFunctionGlobal(System.IntPtr L,string funcName,LuaCSFunction func){
//将LuaCSFunction压入栈中
Lua.lua_pushcfunction(L,func);
//lua_setglobal会弹出栈顶元素,并按给定的名字作为key将其加入到全局表
Lua.lua_setglobal(L,funcName);
}
那么,当我们在lua中执行c#注册的函数时,其交互过程如下:
LuaVM会临时分配一个局部栈结构(这里要区分开始通过luaL_newstate创建的全局栈,两者是独立的)LuaVM会将lua侧的函数参数压入这个临时栈,然后将栈指针传给LuaCSFunctionLuaCSFunction在实现上需要从这个栈中读取lua侧压入的参数,然后执行真正的相关逻辑,并将最终结果压入栈中LuaCSFunction需要返回一个int值,表示往栈中压入了多少个返回值Lua从栈中获取C#侧压入的0/1/多个返回值
官方说明文档可以参考 - Calling C from Lua
接下来要将演示如何将一个c#静态函数Print注入到lua中,实现lua中调用c#端的日志输出功能。
我们定义一个c#静态函数如下:
[MonoPInvokeCallback(typeof(LuaCSFunction))]
private static int Print(System.IntPtr localL){
//获取栈中元素个数
var count = Lua.lua_gettop(localL);
System.Text.StringBuilder s = new System.Text.StringBuilder();
for(var i = 1; i <= count; i ++){
//依次读取print的每个参数,合并成一个string
s.Append(Lua.lua_tostring(localL,i));
s.Append(&#39; &#39;);
}
Debug.Log(s);
//print函数没有返回值
return 0;
}
lua_gettop 可以获取栈中的元素个数,此处代表了lua端压入栈中的函数参数个数
然后我们通过以下方式将这个c#侧的Print注册到lua中,命名为print。
//将LuaCSFunction压入栈中
Lua.lua_pushcfunction(L,Print);
//lua_setglobal会弹出栈顶元素,并按给定的名字作为key将其加入到全局表
Lua.lua_setglobal(L,&#34;print&#34;);
接下来我们执行以下的lua代码:
print(&#39;hello&#39;,&#39;csharp&#39;)就能看到编辑器中输出
hello csharp6. lua注册c#类型
通常我们使用lua中的table来模拟c#中的类。一般类的注册思路如下:
在lua中创建一个与c#类同名的表将c#类的静态函数都注册到lua的这个同名表里
下面演示一下如何将Unity中的Debug类注册到lua中:
Lua.lua_createtable(L,0,1);
Lua.lua_setglobal(L,&#34;Debug&#34;);
其实很简单:
lua_createtable会创建一个table,压入栈顶lua_setglobal会弹出栈顶元素,并将其加到全局表里
这样我们在lua里就有了一个名为Debug的表可供全局访问。但目前这个表是空空如也的,我们还需要为其添加静态函数。(tips:实际上完整的设计中,还需要为class table设置metatable,增加一些限制性,但这里先不表)
6.1 注入类的静态函数
首先我们定义一个符合LuaCSFunction形式的c#函数如下:
[MonoPInvokeCallback(typeof(LuaCSFunction))]
private static int Debug_Log(System.IntPtr L){
string msg = Lua.lua_tostring(L,1);
Debug.Log(msg);
return 0;
}
这个c#函数是对Debug.Log的一个封装。
然后可以通过以下方式将这个c#函数注册到lua中的Debug表中:
Lua.lua_createtable(L,0,1);
//往栈中压入字符串&#39;Log&#39;
Lua.lua_pushstring(L,&#34;Log&#34;);
//往栈中压入函数Debug_Log
Lua.lua_pushcfunction(L,Debug_Log);
//从栈中弹出一个元素作为key,再弹出一个元素作为value,作为pair赋值到index指定的table
Lua.lua_settable(L,1);
Lua.lua_setglobal(L,&#34;Debug&#34;);
这里的关键是lua_settable这个函数,它等于执行了一个table[key]=value的操作。
以上就完成了Debug.Log这个函数在Lua中的注册.
我们运行以下的lua代码能在编辑器中看到正确输出:
Debug.Log(&#39;call debug.log from lua&#39;)tips: 在实际的解决方案中,人们一般通过反射技术遍历一个c#类的所有静态函数,自动生成以上形式的模板代码完成注册,就不用手写了。
6.2 注入类的构造函数
考虑我们有一个c#的类GameObject,我们希望将这个类注册到lua中,并在lua中执行以下代码:
local go = GameObject(&#39;LuaGO&#39;)
go:SetActive(false)按照前面的方式,我们已经可以将GameObject作为一个table注册到lua中,并注册其所有静态函数。但为了实现以上的代码调用,还需要注册构造函数到lua。
在lua中,要让一个table可以像函数一样被调用,需要为其设置metatable,并在其中增加一个__call函数.
这样当我们在lua中执行GameObject()时,就会触发其metatable中的__call函数.
完整的代码如下:
//local GameObject = {}
//L.push(GameObject)
Lua.lua_createtable(L,0,1);
//local classMeta = {}
//L.push(classMeta)
Lua.lua_createtable(L,0,1);
//classMeta.__call = GameObject_Constructor
Lua.lua_pushstring(L,&#34;__call&#34;);
Lua.lua_pushcfunction(L,GameObject_Constructor);
Lua.lua_settable(L,-3);
//会将栈顶元素弹出,作为metatable赋给指定的索引位置的元素
Lua.lua_setmetatable(L,-2);
//将栈顶元素弹出,设为全局变量
Lua.lua_setglobal(L,&#34;GameObject&#34;);
在以上代码中,我们依次往栈中压入两个表,一个作为GameObject Class对象,一个作为其metatable。
接下来通过lua_pushXXX和lua_settable的方式为metatable设置了__call函数。
然后通过lua_setmetatable为GameObject class设置好metatable,最后导出到lua全局表。
接下来看一下__call函数在c#端的实现:
[MonoPInvokeCallback(typeof(LuaCSFunction))]
private static int GameObject_Constructor(System.IntPtr L){
string name = Lua.lua_tostring(L,1);
var go = new GameObject(name);
//创建一个userdata,代表gameObject实例
var udptr = Lua.lua_newuserdata(L,(uint)4);
return 1;
}
注意到我们使用了一个新的api - lua_newuserdata.
构造函数需要返回一个c#对象到lua中,实际上我们并不能真正将c#对象返回到lua,因此这里使用了userdata类型的lua对象作为c#对象在lua中的替身.
userdata是lua中的一种类型,其代表了在宿主语言中分配出来的一块内存区域,但生命周期却是交给lua的gc来管理的。我们同样可以为userdata变量设置metatable,以此为其增加各种方法、属性.
6.3 注入c#类成员函数
在6.2中,虽然通过以下的代码可以完成GameObject构造函数的调用:
local go = GameObject(&#39;GO&#39;)
print(type(go)) --输出 userdata但go还并不具备任何成员函数。我们将要为go设置metatable,以赋予其相关的成员函数。
//创建一个metatable并放到lua注册表中,同时压入栈顶
//local metatable = {}
//register[&#34;GameObject&#34;] = metatable
Lua.luaL_newmetatable(L,&#34;GameObject&#34;);
//local __index = {}
Lua.lua_pushstring(L,&#34;__index&#34;);
Lua.lua_createtable(L,0,1);
//__index.SetActive = GameObject_SetActive
Lua.lua_pushstring(L,&#34;SetActive&#34;);
Lua.lua_pushcfunction(L,GameObject_SetActive);
Lua.lua_settable(L,-3);
//metatable.__index = __index
Lua.lua_settable(L,-3);
//弹出metatable
Lua.lua_pop(L,1);
以上代码等效于创建了以下一个metatable表:
{
__index = {
SetActive = GameObject_SetActive
}
}并将这个表放到lua的注册表中,key为&#39;GameObject&#39;。我们将为所有的GameObject实例替身使用这个metatable。
将GameObject_Constructor修改如下:
[MonoPInvokeCallback(typeof(LuaCSFunction))]
private static int GameObject_Constructor(System.IntPtr L){
string name = Lua.lua_tostring(L,1);
var go = new GameObject(name);
var udptr = Lua.lua_newuserdata(L,(uint)4);
//为userdata设置metatable
Lua.luaL_setmetatable(L,&#34;GameObject&#34;);
_objectCache.Add(udptr,go);
return 1;
}
这里通过luaL_setmetatable这个api,为新创建出来的userdata设置了&#39;GameObject&#39;这个metatable。这样我们就为这个替身赋予了SetActive这个成员函数。
local go = GameObject(&#39;GO&#39;)
print(type(go)) -- userdata类型
go:SetActive(false); -- 会从metatable的__index这个表中,找到SetActive这个方法进行调用注意到前面我们使用了_objectCache将userdata和go的映射缓存起来,这是因为后续lua中执行userdata上的成员函数时,我们需要通过这个cache找到userdata在c#中对应的实例。
例如c#端的SetActive封装函数如下:
[MonoPInvokeCallback(typeof(LuaCSFunction))]
private static int GameObject_SetActive(System.IntPtr L){
//第一个参数是userdata
var udptr = Lua.lua_touserdata(L,1);
//第二个参数材质active
var active = Lua.lua_toboolean(L,2) != 0;
var go = _objectCache[udptr] as GameObject;
go.SetActive(active);
return 0;
}
7. GC管理
在part 6中我们在c#端通过objectCache缓存了userdata和go。 同时在lua端通过GameObject()返回了一个userdata对象代表GameObject实例。
userdata的生命周期是交给lua vm来管理的,因此假如我们在lua中没有引用住这个go对象,那么很快就会被gc回收掉。 这样我们在c#端objectCache中缓存的userdata就会成为传说中的野指针,同时造成内存泄露。
为了解决这个问题,需要在c#端监听lua中对象的gc情况,当userdata被lua vm gc回收时,我们同步将其从objectCache中移除.
好在lua的metatable中提供了gc这个函数,当对象被gc回收时会触发。 因此我们只要在对象的metatable上额外注册gc函数就可以了:
//metatable.__gc = gc -- 设置gc函数
Lua.lua_pushstring(L,&#34;__gc&#34;);
Lua.lua_pushcfunction(L,gc);
Lua.lua_settable(L,-3);
c#端的gc函数实现如下:
[MonoPInvokeCallback(typeof(LuaCSFunction))]
private static int gc(System.IntPtr L){
var udptr = Lua.lua_touserdata(L,1);
if(_objectCache.Remove(udptr)){
// Debug.Log(&#34;gc called for userdata : &#34; + udptr);
}else{
Debug.LogError(&#34;cache missing:&#34; + udptr);
}
return 0;
}
8. c#引用lua中的临时函数
某些情况下,我们需要在c#中引用住lua中传递的临时函数。例如实现一些回调函数接口时。考虑以下用例:
local callback = function()
end
EventManager.Register(callback)这里我们往c#端的EventManager中注册了一个lua函数作为callback。在c#端需要对其进行引用,并在合适的时机执行这个callback。(否则callback在luavm中因为不存在引用,会被gc回收调)
c#端,EventManager.Register实现如下:
[MonoPInvokeCallback(typeof(LuaCSFunction))]
private static int EventManager_Register(System.IntPtr L){
//即LuaRegistery[reference] = luaCallback
var reference = Lua.luaL_ref(L,(int)LUA_REGISTRY.Index);
var luaFunc = new LuaFunction(_globalL,reference);
EventManager.Register((value)=>{
luaFunc.PCall(value);
});
return 0;
}
这里使用了luaL_ref这个api,它会将栈顶元素添加到lua的注册表中(这样就不会被luavm gc回收)。luaL_ref会返回一个int类型的reference,用于后续去注册表中重新获取该元素.
既然我们使用luaL_ref引用住了lua中的一个临时变量,那么就需要在恰当的时机释放这个临时变量,否则lua端会造成内存泄露。
在本用例里,这个lua function的生命周期应当跟c#注册到EventManager中的Delegate对象保持一致。
因此我们新建了LuaFunction这个类,来维护lua中这个reference的生命周期:
public class LuaFunction{
private int _reference;
private System.IntPtr _L;
public LuaFunction(System.IntPtr L, int reference){
_reference = reference;
_L = L;
}
public void PCall(int value){
//根据reference从registery中取到lua callback,放到栈顶
Lua.lua_rawgeti(_L,(int)LUA_REGISTRY.Index,_reference);
//压入参数
Lua.lua_pushinteger(_L,value);
//执行lua callback
Lua.lua_pcall(_L,1,0,0);
}
~LuaFunction(){
Lua.luaL_unref(_L,(int)LUA_REGISTRY.Index,_reference);
Debug.Log(&#34;LuaFunction gc in c#:&#34; + _reference);
}
}
可以看到,LuaFunction实现了析构函数。即当LuaFunction这个对象在c#端被GC回收时,我们同步释放其所维护的lua reference.
所以这里c#端的引用关系是:
EventManager->Delegate->LuaFunction
这样就成功在c#端完成了对lua端对象的引用和生命周期维护。
9. 无法解决的循环引用
在part7中,c#将自己的一个临时对象生命周期委托给lua的gc管理.
在part8中,lua将自己的一个临时对象生命周期委托给c#的gc管理.
但这种设计并不是完美无缺的,以下这种情形将会导致循环引用,并使得两边的gc都无法释放对象:
local csObj = CSObject()
csObj:AddCallback(function()
csObj:DoSomething()
end)以上这个用例的引用链如下:
lua端: LuaRegistery->luaCallback->csObj
c#端: objectCache->csObj->Delegate->LuaFunction->luaCallback
lua端依赖c#这边的gc释放LuaFunction,从而对luaCallback进行解引用,才能触发csObj(userData)的gc
但c#端又依赖lua这边对csObj(userData)进行gc回收,才能从objectCache中移除csObj.
这就造成了死锁,两边都无法进行回收,并且两边都已完全失去了对象的访问能力(因为lua代码中无法访问LuaRegistery,同样c#端通常不会将objectCache暴露给上层使用者)。
目前似乎没有确切的,自动化的解决方案(ps:我只用过slua,里面是存在这个问题的)
10. 结束
到这里为止,c#与lua的几种交互情形基本上已经罗列清楚了。Unity中的各种lua解决方案,基本上是针对以上的交互情形,提供了更高性能的、更少GC的高级封装,并且通过自动化工具生成模板代码,将c#中的类、函数注入到lua中。
用例项目地址: |
本帖子中包含更多资源
您需要 登录 才可以下载或查看,没有账号?立即注册
×
|