【基于tolua】C# 和 Lua 方法互调细节和互相持有引用问题

椎名林檎

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来绑定虚拟机,LuaBinderBind方法将执行虚拟机和 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(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("SceneManager/login_scene_manager");
        LuaFunction func = luaTable.GetLuaFunction("Awake");
        func.Call();
        func.Dispose();
  • 调用时通过 DoStringDoFile 方法加载 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 时会被回收掉

你可能感兴趣的:(【基于tolua】C# 和 Lua 方法互调细节和互相持有引用问题)