从零开始的SLua(一)热更新原理及SLua的第一个Demo剖析

什么是热更新?

为什么要使用Lua进行热更新?

Unity是如何使用Lua进行热更新的?

知乎这个问题的觉得下面已经给出了不少解释,其中一个回答又很好的解释了为什么Unity没有原生的热更新方案,看完这个我又开始疑问编译器和解释器的区别是什么?静态语言和脚本语言的本质区别又是什么?这篇博客给出了非常生形象的解释,然后,我们就可以深入到问题的核心:CIL,大佬也在博客里进行了详细的解释

至此,前两个问题我的理解如下:

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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 中具体调用如下:

1
2
3
4
5
6
7
8
9
IntPtr L = mainState.L;
LuaObject.init(L);
if (!UnityEditor.EditorApplication.isPlaying)
{
doBind(L);
doinit(mainState, flag);
complete();
mainState.checkTop();
}

LuaObject.init(L)我们先跳过,其中 dobind

1
2
3
4
5
6
7
8
9
10
11
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 里面存储了什么

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
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 为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
using System;
using System.Collections.Generic;
namespace SLua {
[LuaBinder(0)]
public class BindUnity {
public static Action<IntPtr>[] GetBindList() {
Action<IntPtr>[] list= {
Lua_UnityEngine_Rendering_ComputeQueueType.reg,
Lua_UnityEngine_Rendering_CommandBuffer.reg,
Lua_UnityEngine_Rendering_SphericalHarmonicsL2.reg,
Lua_UnityEngine_RectOffset.reg,
Lua_UnityEngine_Object.reg,
Lua_UnityEngine_Component.reg,
Lua_UnityEngine_SortingLayer.reg,
Lua_UnityEngine_GradientColorKey.reg,
Lua_UnityEngine_GradientAlphaKey.reg,
Lua_UnityEngine_GradientMode.reg,
Lua_UnityEngine_Gradient.reg,
...

可以看到都是一些 Unity 的常用接口的注册函数,以 Lua_UnityEngine_Camera_StereoscopicEye.reg 为例:

1
2
3
4
5
6
7
8
9
[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 的封装如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
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 交互所需要的库,具体的看不了源码就深究了,但其中的一个函数

1
2
3
4
5
6
7
8
9
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 到底是做什么的?其源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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 代码,已经有博客写了源码分析,但只说了结论,那我们就来根据结论和源码来倒推其背后的机制:

1
2
3
4
5
6
-- 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”); ,其定义为:

1
2
3
4
5
6
7
8
9
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 函数,其源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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() 的源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
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 的构造函数:

1
2
3
4
5
6
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,我们开始下一行:

1
2
3
4
5
6
7
-- 先取得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 脚本中我们可以找到如下函数;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
[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之后,添加的代码会丢失,需要重新添加,这时你可以考虑重载默认的导出方法,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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 函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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 接口,而是经过封装的,定义如下:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    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;
    }

然后我们继续下一句:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
-- 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 中,可以看到如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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