SLua入门学习(一)——热更新原理及SLua的Demo剖析

发表于2018-04-23
评论0 7.3k浏览
很多地方都需要使用Lua来进行热更新,这个系列主要是给大家介绍SLua入门学习,对于Lua语法这里就不多说了,先从热更新的原理机制开始给大家进行介绍。

什么是热更新?
为什么要使用Lua进行热更新?
Unity是如何使用Lua进行热更新的?

至此,前两个问题的理解如下:
1、作为 Unity 游戏来说,热更新即在游戏运行的过程中,编译并运行修改后的新代码
2、因为Ios 通过设置内存 No eXecute 限制了 JIT 的使用,而 Lua 可以通过 AoT 等方式编译运行

第三个问题,目前的方案是SLua, SLua使用起来非常简单方便,但不搞清楚其实现机制,用起来总是会心里没底,而SLua没有完全开源,其源码也少有注释,硬啃源码还是比较吃力

所以,在细究SLua的原理之前,得知道 Lua 和 C/C++ 通常是如何交互的,这篇博客给出了不错的解释,Lua 和 C 交互的核心就是栈,Lua 库也提供了大量 API 用来在 C 中对栈进行操作从而实现 Lua 和 C 的数据交换和函数调用。

有了这些前置知识,我们开始看 Slua Unity 的第一个Demo
public class Circle : MonoBehaviour {
    LuaSvr svr;
    LuaTable self;
    LuaFunction update;
    [CustomLuaClass]
    public delegate void UpdateDelegate(object self);
    UpdateDelegate ud;
    void Start () {
        svr = new LuaSvr();
        svr.init(null, () =>
        {
            self = (LuaTable)svr.start("circle/circle");
            update = (LuaFunction)self["update"] ;
            ud = update.cast<UpdateDelegate>();
        });
    }
    void Update () {
        if (ud != null) ud(self);
    }
}

首先是 LuaSvr, LuaSvr 其实是对 Lua_State 的一个封装, 而 Lua_State 在这篇博客有详细的解释,主要是管理一个lua虚拟机的执行环境, 通过名为 L 的 int 指针作为 ref

接下来是 svr.init 其实是将 UnityEngine 的一些常用函数压栈以便接下来在 Lua 中调用,在 Editor 中具体调用如下:
IntPtr L = mainState.L;
LuaObject.init(L);
if (!UnityEditor.EditorApplication.isPlaying)
{
    doBind(L);
    doinit(mainState, flag);
    complete();
    mainState.checkTop();
}

LuaObject.init(L)我们先跳过,其中 dobind
static internal void doBind(IntPtr L)
{
    var list = collectBindInfo ();
    int count = list.Count;
    for (int n = 0; n < count; n++)
    {
        Action<IntPtr> action = list[n];
        action(L);
    }
}

调用了 list 中所有的委托,现在看看 list 里面存储了什么
        static List<Action<IntPtr>> collectBindInfo() {
            List<Action<IntPtr>> list = new List<Action<IntPtr>>();
            #if !SLUA_STANDALONE
            #if !USE_STATIC_BINDER
            Assembly[] ams = AppDomain.CurrentDomain.GetAssemblies();
            List<Type> bindlist = new List<Type>();
            for (int n = 0; n < ams.Length;n++ )
            {
                Assembly a = ams[n];
                Type[] ts = null;
                try
                {
                    ts = a.GetExportedTypes();
                }
                catch
                {
                    continue;
                }
                for (int k = 0; k < ts.Length; k++)
                {
                    Type t = ts[k];
                    if (t.IsDefined(typeof(LuaBinderAttribute), false))
                    {
                        bindlist.Add(t);
                    }
                }
            }
            bindlist.Sort(new System.Comparison<Type>((Type a, Type b) => {
                LuaBinderAttribute la = System.Attribute.GetCustomAttribute( a, typeof(LuaBinderAttribute) ) as LuaBinderAttribute;
                LuaBinderAttribute lb = System.Attribute.GetCustomAttribute( b, typeof(LuaBinderAttribute) ) as LuaBinderAttribute;
                return la.order.CompareTo(lb.order);
            }));
            for (int n = 0; n < bindlist.Count; n++)
            {
                Type t = bindlist[n];
                var sublist = (Action<IntPtr>[])t.GetMethod("GetBindList").Invoke(null, null);
                list.AddRange(sublist);
            }
            #else
            var assemblyName = "Assembly-CSharp";
            Assembly assembly = Assembly.Load(assemblyName);
            list.AddRange(getBindList(assembly,"SLua.BindUnity"));
            list.AddRange(getBindList(assembly,"SLua.BindUnityUI"));
            list.AddRange(getBindList(assembly,"SLua.BindDll"));
            list.AddRange(getBindList(assembly,"SLua.BindCustom"));
            #endif
            #endif
            return list;
        }

可以看到,收集了所有带 LuaBinderAttribute 修饰的 object 并排序然后通过反射获取其 GetBindList 方法里存储的委托,然后插入 list 返回, 查引用则可以看到:

以 BindUnity 为例:

可以看到都是一些 Unity 的常用接口的注册函数,以 Lua_UnityEngine_Camera_StereoscopicEye.reg 为例:
[UnityEngine.Scripting.Preserve]
public class Lua_UnityEngine_Camera_StereoscopicEye : LuaObject {
    static public void reg(IntPtr l) {
        getEnumTable(l,"UnityEngine.Camera.StereoscopicEye");
        addMember(l,0,"Left");
        addMember(l,1,"Right");
        LuaDLL.lua_pop(l, 1);
    }
}

到这里,已经很接近 lua 的原始接口了, getEnumTable 和 addMember 的封装如下:
public static void getEnumTable(IntPtr l, string t)
{
    newTypeTable(l, t);
}
public static void newTypeTable(IntPtr l, string name)
{
    string[] subt = name.Split('.');
    LuaDLL.lua_pushglobaltable(l);
    foreach(string t in subt)
    {
        LuaDLL.lua_pushstring(l, t);
        LuaDLL.lua_rawget(l, -2);
        if (LuaDLL.lua_isnil(l, -1))
        {
            LuaDLL.lua_pop(l, 1);
            LuaDLL.lua_createtable(l, 0, 0);
            LuaDLL.lua_pushstring(l, t);
            LuaDLL.lua_pushvalue(l, -2);
            LuaDLL.lua_rawset(l, -4);
        }
        LuaDLL.lua_remove(l, -2);
    }
}
protected static void addMember(IntPtr l, int v, string name)
{
    LuaDLL.lua_pushinteger(l, v);
    LuaDLL.lua_setfield(l, -2, name);
}

到这里,就都是 lua 库提供的对栈进行操作的 API 了, 其具体作用在前面的链接或者其他 lua 文档都可以查到了,LuaObject.init(L) 的作用也很清楚了——为 lua 注入一些通用的方法, dobind 则是注入 UnityEngine 常用的一些方法

doinit(mainState, flag) 则是打开了一些 c# 与 lua 交互所需要的库,具体的看不了源码就深究了,但其中的一个函数
LuaValueType.reg(L.L);
public static void reg(IntPtr l)
        {
#if !LUA_5_3 && !SLUA_STANDALONE
            // lua implemented valuetype isn't faster than raw under non-jit.
            LuaState ls = LuaState.get(l);
            ls.doString(script, "ValueTypeScript");
#endif
        }

一路调用到了 dobuffer 函数,而我们常用的 dofile 也调用到了 dobuffer, dobuffer 到底是做什么的?其源码如下:
        public bool doBuffer(byte[] bytes, string fn, out object ret)
        {        
            // ensure no utf-8 bom, LuaJIT can read BOM, but Lua cannot!
            bytes = CleanUTF8Bom(bytes);
            ret = null;
            int errfunc = LuaObject.pushTry(L);
            if (LuaDLL.luaL_loadbuffer(L, bytes, bytes.Length, fn) == 0)
            {
                if (LuaDLL.lua_pcall(L, 0, LuaDLL.LUA_MULTRET, errfunc) != 0)
                {
                    LuaDLL.lua_pop(L, 2);
                    return false;
                }
                LuaDLL.lua_remove(L, errfunc); // pop error function
                ret = topObjects(errfunc - 1);
                return true;
            }
            string err = LuaDLL.lua_tostring(L, -1);
            LuaDLL.lua_pop(L, 2);
            throw new Exception(err);
        }

没错,我们看到了 JIT,这里就是编译并运行 lua 代码的,首先是处理一些编码的问题先不管,然后将错误处理函数入栈并返回 index, 然后通过 luaL_loadbuffer 载入并解析这段代码,如果解析成功,会把结果压到栈 L 中

接下来就是运行了,正如前面链接的博客解释的差不多:
函数调用流程是先将函数入栈,参数入栈,然后用lua_pcall调用函数,此时栈顶为参数,栈底为函数,所以栈过程大致会是:参数出栈->保存参数->参数出栈->保存参数->函数出栈->调用函数->返回结果入栈
而这里稍有区别,是从头运行整个 bytes 中包含的所有 lua 代码,将整块 lua 代码看作一个无参函数, 参照 lua_pcall 的定义,其中参数 LuaDLL.LUA_MULTRET 表示所有的返回值都会入栈, 如果运行出错则运行错误处理函数并将 error message 入栈, 运行成功则移除掉错误处理函数并取出返回值,lua_pcall 的具体信息和使用可以参考这篇博客

而说了这么多,这里的 doInit 函数到底是干嘛的呢?

我们可以看到 doString 的 script 参数其实是一个好长字符串,里面存储的都是常用的数学库的 lua 实现, doInit 做的实际上就是运行了这一大托 lua 代码,声明了一大堆数学函数,方便之后在 lua 中使用,原因应该是为了提高效果,具体可以看 UWA 的这篇博客

回到 doInit, 接下来就是 complete 回调了没什么好说的了,其余的代码有了上面的基础也都很好理解了,总结下,其实 LuaSvr 就是一个封装了 Unity 常用接口、数学库和一些通用方法的 Lua_State

接下来我们看这个 example 的 lua 代码,已经有博客写了源码分析,但只说了结论,那我们就来根据结论和源码来倒推其背后的机制:
-- Circle.cs里先dofile把文件加载到global,然后通过luaState[key]把global的值压栈给c#使用
-- c#把入栈的luaState["main"]转换为LuaFunction,然后根据ref压栈并pcall之,得到classtable入栈,然后c#把此table转为LuaTable,
-- 就可以通过LuaTable里的ref访问register中的class这个实例数据了。
function main()

在 complete 回调中有这样一句 self = (LuaTable)svr.start("circle/circle"); ,其定义为:
public object start(string main)
{
    if (main != null)
    {
        mainState.doFile(main);
        return mainState.run("main");
    }
    return null;
}

对,出现了 doFile, 再往下就是前面的 dobuffer, 通过 lua_getglobal 将文件解析并存入了栈中,至于这里为什么说是 global 我不太清楚,再往下就是DLL了看不了源码,存在多个 Lua_State 的时候到底怎么处理还没搞清楚,这个以后再说…
接下来mainState.run("main") 则是运行 lua 中的 main 函数,其源码如下:
public object run(string entry) {
            using (LuaFunction func = getFunction(entry))
            {
                if (func != null)
                    return func.call();
            }
            return null;
        }
public LuaFunction getFunction(string key)
        {
            //把入栈的luaState["main"]转换为LuaFunction
            return (LuaFunction)this[key];
        }
public LuaFunction(LuaState l, int r)
            : base(l, r)
        {
        }
// base constructor
public LuaVar(LuaState l, int r)
        {
            state = l;
            valueref = r;
        }

首先是把入栈的luaState[“main”]转换为LuaFunction,然后我们看func.call() 的源码:
public object call()
        {
            int error = LuaObject.pushTry(state.L);
            if (innerCall(0, error))
            {
                return state.topObjects(error - 1);
            }
            return null;
        }
bool innerCall(int nArgs, int errfunc)
        {
            bool ret = pcall(nArgs, errfunc);
            LuaDLL.lua_remove(L, errfunc);
            return ret;
        }
public bool pcall(int nArgs, int errfunc)
        {
            if (!state.isMainThread())
            {
                Logger.LogError("Can't call lua function in bg thread");
                return false;
            }
            //转换为 LuaFunction 时构造函数存储的 valueref
            LuaDLL.lua_getref(L, valueref);
            if (!LuaDLL.lua_isfunction(L, -1))
            {
                LuaDLL.lua_pop(L, 1);
                throw new Exception("Call invalid function.");
            }
            LuaDLL.lua_insert(L, -nArgs - 1);
            if (LuaDLL.lua_pcall(L, nArgs, -1, errfunc) != 0)
            {
                LuaDLL.lua_pop(L, 1);
                return false;
            }
            return true;
        }

main 函数的运行初始化了一个名为 class 的 Table 并且返回,c# 从栈顶取得该 table 并转换,这里可以看到 LuaTable 的构造函数:
public LuaTable(LuaState state)
            : base(state, 0)
{
    LuaDLL.lua_newtable(L);
    valueref = LuaDLL.luaL_ref(L, LuaIndexes.LUA_REGISTRYINDEX);
}

通过 luaL_ref 将 table 存入注册表并返回引用,注册表用来在 c 中存放 lua 的全局信息, 同环境变量、UpValue 一起用来在 c 中保存状态,具体解析可以看这篇博客,luaL_ref 是实例分析则可以看这篇博客

OK,我们开始下一行:
-- 先取得GameObject类型表,然后使用里面注册的Find函数(其实是lclosure),然后调用此lclosure, c#层对应的Find函数被调用,把找到的GameObject实例对象存入ObjectCach中
  -- 同时作为ud压入栈,并设置GameObject实例表为该ud的元表,这样ud就可以使用GameObject实例表的Getcomponent函数(lclosure),
  -- 同理的得到一个元表为UI.Slider实例表的ud,赋值给slider,counttxt类似
    local slider = GameObject.Find("Canvas/Slider"):GetComponent(UI.Slider)
    local counttxt = GameObject.Find("Canvas/Count"):GetComponent(UI.Text)

在 Lua_UnityEngine_GameObject.cs 脚本中我们可以找到如下函数;
[SLua.MonoPInvokeCallbackAttribute(typeof(LuaCSFunction))]
    [UnityEngine.Scripting.Preserve]
    static public int Find_s(IntPtr l) {
        try {
            System.String a1;
            checkType(l,1,out a1);
            var ret=UnityEngine.GameObject.Find(a1);
            pushValue(l,true);
            pushValue(l,ret);
            return 2;
        }
        catch(Exception e) {
            return error(l,e);
        }
    }
...
addMember(l,SLua.MyGameObject.Find_s);
...

在前面提过 reg 注册函数中通过 AddMenber 的形式注入 Lua, 而在这里, Find_s 使用的是重载后的,官方 wiki 解释如下:
有时我们需要在默认动态生成的导出函数中增加一些自己的代码,之前你需要在生成的wrapper文件里手动添加对应的代码,但这样每次重新make之后,添加的代码会丢失,需要重新添加,这时你可以考虑重载默认的导出方法,例如:
namespace SLua {    
    [OverloadLuaClass(typeof(GameObject))]
    public class MyGameObject : LuaObject {
        [MonoPInvokeCallbackAttribute(typeof(LuaCSFunction))]
        public static int Find_s(IntPtr l) {
            UnityEngine.Debug.Log ("GameObject.Find overloaded my MyGameObject.Find");
            try {
                System.String a1;
                checkType(l,1,out a1);
                var ret=UnityEngine.GameObject.Find(a1);
                pushValue(l,true);
                pushValue(l,ret);
                return 2;
            }
            catch(Exception e) {
                return error(l,e);
            }
        }
    }
}
这样GameObject.Find方法的导出方法会调用到上面Find_s函数中,你可以任意添加自己的代码,在最终的wrapper文件中,也会使用上述方法作为Find方法的导出实现。

现在我们看 AddMember 函数
protected static void addMember(IntPtr l, LuaCSFunction func)
        {
            checkMethodValid(func);
            pushValue(l, func);
            string name = func.Method.Name;
            if (name.EndsWith("_s"))
            {
                name = name.Substring(0, name.Length - 2);
                LuaDLL.lua_setfield(l, -3, name);
            }
            else
                LuaDLL.lua_setfield(l, -2, name);
        }

这里 _s 的区分处理,在另外一篇博客找到的答案如下:
  • 只有函数指针位置的部分,在Lua中定义成了Table变量内的函数,例如:cube:AddCommponent
  • 在函数指针名的末尾部分以_s结尾的,在Lua中定义成了元表变量内的函数,例如:GameObject.CreatePrimitive
  • 在添加成员时,包含了类似于"transform"字符串的,在Lua中定义成了Table变量内的键值对属性,例如:cube.transform

然后我们回到 Find_s 函数,注意这里的 pushValue 并非原生的 lua 接口,而是经过封装的,定义如下:
public static void pushValue(IntPtr l, UnityEngine.Object o)
        {
            if (o == null)
                LuaDLL.lua_pushnil(l);
            else
                pushObject(l, o);
        }
public static void pushObject(IntPtr l, object o)
        {
            ObjectCache oc = ObjectCache.get(l);
            oc.push(l, o);
        }
internal void push(IntPtr l, object o)
        {
            push(l, o, true);
        }
internal void push(IntPtr l, object o, bool checkReflect)
        {
            int index = allocID (l, o);
            if (index < 0)
                return;
            bool gco = isGcObject(o);
#if SLUA_CHECK_REFLECTION
            int isReflect = LuaDLL.luaS_pushobject(l, index, getAQName(o), gco, udCacheRef);
            if (isReflect != 0 && checkReflect && !(o is LuaClassObject))
            {
                Logger.LogWarning(string.Format("{0} not exported, using reflection instead", o.ToString()));
            }
#else
            //同时作为ud压入栈,并设置GameObject实例表为该ud的元表,看不了源码,但只能是这句了
            LuaDLL.luaS_pushobject(l, index, getAQName(o), gco, udCacheRef);
#endif
        }
internal int allocID(IntPtr l,object o) {
            int index = -1;
            if (o == null)
            {
                LuaDLL.lua_pushnil(l);
                return index;
            }
            bool gco = isGcObject(o);
            bool found = gco && objMap.TryGetValue(o, out index);
            if (found)
            {
                if (LuaDLL.luaS_getcacheud(l, index, udCacheRef) == 1)
                    return -1;
            }
            index = add(o);
            return index;
        }
internal int add(object o)
        {
        //把找到的GameObject实例对象存入ObjectCach中
            int objIndex = cache.add(o);
            if (isGcObject(o))
            {
                objMap[o] = objIndex;
                #if SLUA_DEBUG || UNITY_EDITOR
                objNameDebugs[o] = getDebugName(o);
                #endif
            }
            return objIndex;
        }

然后我们继续下一句:
  -- onValueChaned是UI.Slider实例表中注入的一个属性表,取属性会调用属性表里的第一个函数(lclosure),c#对应的get函数被调用,一个元表为SliderEvent实例表的ud入栈
  -- 而SliderEvent实例表.__parent = UnityEvent_float实例表,后者注入了AddListenner函数(lclosure),调用它会调用c#对应函数,c#中把lua传过来的lfunction转为
  -- LuaDelegate ld,接着实例化一个UnityAction<float>的委托,委托里会调用ld,并且委托会被Add到前面的ud在c#中的UnityEvent<float>实例中,
  -- 这样,当c#的onValueChanged时就会调用该委托,进而调用ld,进而通过ld里的ref调用register里的lfunction,即下面那个函数
    slider.onValueChanged:AddListener(
        function(v)
            class:init(v)
            counttxt.text=string.format("cube:%d",v)
        end
    )

有了前面的分析,这里就可以顺藤摸瓜了,在Lua_UnityEngine_UI_Slider.cs 中,可以看到如下代码:
addMember(l,"onValueChanged",get_onValueChanged,set_onValueChanged,true);
protected static void addMember(IntPtr l, string name, LuaCSFunction get, LuaCSFunction set, bool instance)
        {
            checkMethodValid(get);
            checkMethodValid(set);
            int t = instance ? -2 : -3;
            LuaDLL.lua_createtable(l, 2, 0);
            if (get == null)
                LuaDLL.lua_pushnil(l);
            else
                pushValue(l, get);
            LuaDLL.lua_rawseti(l, -2, 1);
            if (set == null)
                LuaDLL.lua_pushnil(l);
            else
                pushValue(l, set);
            LuaDLL.lua_rawseti(l, -2, 2);
            LuaDLL.lua_setfield(l, t, name);
        }

这里大佬的分析已经很清楚了,我一小白就不再做解释了,因为还没有看过 lua 源码,因此也没法再深入去分析,下一篇再来分析具体的 lua 源码实现和过程中虚拟栈的内存结构
来自:https://blog.csdn.net/notmz/article/details/79645949

如社区发表内容存在侵权行为,您可以点击这里查看侵权投诉指引