tolua 是比较普遍的一个 Unity + Lua 开发的解决方案,本文记录使用 tolua 过程中的一些技术细节
1. C# 方法和变量如何导出供 lua 调用
在 tolua 框架下,如果你需要把你的 C# 类导出到 Lua ,你需要在 CustomSettings.cs
中用方法 _GT
把类名列添加到静态变量 customDelegateList
中,例如导出 UnityEngine.GameObject
_GT(typeof(GameObject));
导出时,ToLuaExport
会处理这个列表,自动生成对应的包装类 Wrap
文件UnityEngine_GameObjectWrap.cs,针对 GameObject
类中所有的方法、变量和属性,UnityEngine_GameObjectWrap.cs 文件中会自动生成对应的方法或 getter 和 setter 方法,另外额外生成一个 Register
方法。
所有 Wrap 类中, Register
方法的组成都相对固定,比如
public static void Register(LuaState L)
{
L.BeginClass(typeof(StoredBuddy), typeof(System.Object));
L.RegFunction("New", _CreateStoredBuddy);
L.RegFunction("GetBuddyLevel", GetBuddyLevel);
L.RegFunction("__tostring", ToLua.op_ToString);
L.RegVar("bid", get_bid, set_bid);
L.RegVar("exp", get_exp, set_exp);
L.RegVar("level", get_level, set_level);
L.EndClass();
}
-
BeginClass
在 Lua 中创建类对应的 table 和元表,并将对应的 table 加入到 loaded 中,并设置类的通用方法__gc
,__index
,__call
等 -
RegFunction
将成员函数转换为函数指针,添加到类的元表中 -
RegVar
为成员变量添加 getter 和 setter 方法,并转换为函数指针,添加到类元表中 -
EndClass
为 table 设置包含上述方法的元表
那么,Wrap 类的Register
方法在什么时机被调用呢?当你启动 Lua 虚拟机时,使用 LuaBinder
来绑定虚拟机,LuaBinder
的Bind
方法将执行虚拟机和 Wrap 类的绑定逻辑
2. lua 调用 C# 方法的全过程
2.1 在 lua 中实例化 C# 对象
在 lua 中的代码
local go = UnityEngine.GameObject("temp");
执行的流程大概是这样
- Lua侧查找新建方法的函数指针
在 lua 中的 GameObject 表中查找New
方法(通过 Wrap 的Register
方法导出到 Lua的,看下面的代码),找不到于是在它的元表的__index
中查找,找到了之前导出的函数指针
L.RegFunction("New", _CreateUnityEngine_GameObject);
- Lua侧调用参数压栈
将 lua 字符串 "temp" 压栈,同时将参数个数1压栈 - C#侧取出参数并实例化
根据函数指针调用到了UnityEngine_GameObjectWrap
类的CreateUnityEngine_GameObject
方法,该方法中核心的代码如下,逻辑是从 Lua 栈中Pop出参数个数,然后从栈中Pop出字符串 "temp",然后调用 C# 的相关方法创建实例
int count = LuaDLL.lua_gettop(L);
if (count == 1 && TypeChecker.CheckTypes<string>(L, 1))
{
string arg0 = ToLua.ToString(L, 1);
UnityEngine.GameObject obj = new UnityEngine.GameObject(arg0);
ToLua.PushSealed(L, obj);
return 1;
}
注意,这里的
ToLua.ToString
有可能会申请内存空间,存在 GCAlloc,尽量少在 Lua 和 C# 之间传递字符串。
- C# 侧包装实例对象并压栈
查看上面的代码ToLua.PushSealed(L, obj)
实现可以知道,实例实际上是被存在了ObjectTranslator
中维护的一个对象池objects
中, 然后新建一个userdata
类型的数据进行压栈
public static void PushUserData(IntPtr L, object o, int reference)
{
int index;
ObjectTranslator translator = ObjectTranslator.Get(L);
if (translator.Getudata(o, out index))
{
if (LuaDLL.tolua_pushudata(L, index))
{
return;
}
translator.Destroyudata(index);
}
index = translator.AddObject(o);
LuaDLL.tolua_pushnewudata(L, reference, index);
}
- lua 侧从栈中获得对象引用
lua 这边的变量 go 是一个userdata
类型的变量,是对 C# 实例的引用
2.2 调用方法
接上面,lua 中的代码
go.transform.name = "abc";
执行的流程
获取
get_transform
函数指针并将参数入栈
从GameObject
的元表中查找get_transform
函数的指针,并将引用go
入栈C# 侧取出引用并调用对应的方法
C# 这边执行get_transform
方法,从栈中取出userdata
类型的引用数据,然后从ObjectTranslator
的对象池列表中取出C#对象
public static object ToObject(IntPtr L, int stackPos)
{
int udata = LuaDLL.tolua_rawnetobj(L, stackPos);
if (udata != -1)
{
ObjectTranslator translator = ObjectTranslator.Get(L);
return translator.GetObject(udata);
}
return null;
}
- C# 侧调用实例方法,将返回值压栈
C# 拿到实例后,通过transform
属性得到返回值,同样缓存再ObjectTranslator
对象列表中,同时生成一个userdata
引用,压栈 - Lua 侧从栈中取出引用
Lua 侧从栈中取出实例的引用 - 后续使用这个引用再调用 .name = "abc" 的方法如出一辙
这里可以看出来,Lua 调用 C# 方法的过程中,多次入栈出栈的操作和大量的类型转换,并伴随有引用数据的生成,甚至可能有临时对象的分配
3. C# 调用 lua 方法全过程
首先明确一点,C# 调用 Lua 方法,与 Wrap 类无关,
下面是一段 C# 调用 lua 方法的代码,可以看出大概的流程
LuaManager.Instance.OpenState();
LuaTable luaTable = LuaManager.Instance.lua.DoFile<LuaTable>("SceneManager/login_scene_manager");
LuaFunction func = luaTable.GetLuaFunction("Awake");
func.Call();
func.Dispose();
- 调用时通过
DoString
或DoFile
方法加载 lua 代码 - 上述两个方法通过
laodBuffer
加载代码到 lua 虚拟机,得到LuaTable
对象 - 通过
GetFunction
获得对应的函数指针LuaFunction
对象 - 执行调用,调用的过程也同样涉及参数的压栈操作
- 调用完成后将
LuaFunction
对象析构掉
如果需要获取返回值的情况,可以看看如下代码:
LuaState luaMgr = LuaManager.Instance.lua;
luaMgr.DoFile("Config/surveySetting.lua");
LuaTable table = luaMgr.GetTable("SurveySettingConfig");
LuaDictTable dict = table.ToDictTable();
table.Dispose();
注意,C# 侧持有的 LuaTable 本质上也是一个 lua 对象的引用,需要调用
table.Dispose()
来解引用
4. lua 和 C# 互相持有引用情况分析
lua 通常是用来做UI界面开发的,我们在开发的过程中进行界面管理,往往会存在如下这种情况
- 在 C# 这边持有 lua 的 UI 界面对应的 table 引用
- Lua 侧 UI 界面 table 中有各种 UI 控件成员,实际上是
userdata
的引用,这些控件的实例存放在 C# 测的对象池中,甚至有可能有 lua 方法被注册到 C# 这边的按钮实例中
存在问题:
当关闭 UI 界面时,C# 未将持有的 panel 引用析构,未将注册的回调方法注销,则会导致双方互相持有引用,GC 时对象无法回收
避免出现这种问题,需要确保 - 界面关闭时,将 Lua 侧的 ui table 对象要被置为 nil 且不要被引用,(button\image\label 等成员可以不用置为 nil,因为GC可达性检查时唯一能到根对象的 ui table 无人引用)
- C# 侧析构对界面的引用 panel,调用
panel.Dispose();
- Button/Image/Label 这些C#测的对象,在 C# GC 时会被回收掉