Lua是目前国内使用最多的热更语言,基于Lua的热更框架也非常多,最近学习了一下ToLua的热更框架,主要使用的问题在于C#和Lua之间的互调,因此做一下学习记录以备后查。
所谓“互调”,当然要包括两个方面,一是通过C#调用Lua代码,二是通过Lua代码调用C#脚本,第二点还包括注册在C#脚本里的Unity物体。
1. ToLua的简单实现原理
ToLua框架主要是通过静态绑定来实现C#与Lua之间的交互的,基本原理是通过建立一个Lua虚拟机来映射C#脚本,然后再通过这个虚拟机来运行Lua脚本,Lua脚本在运行时可以通过虚拟机反过来调用C#脚本里注册过的物体,这种方式的优势在于比起使用反射的uLua来说效率更高。
ToLua框架下可以将实现分成三大部分:普通的Unity+C#部分、ToLua虚拟机部分和Lua脚本部分,结构见下图:
ToLua结构
目前国内需要热更的手游一般都将主要的逻辑框架和组件功能用C#实现,而具体功能和调用放在Lua中,因为C#是不能被打包进AssetBundle中的,所以无法通过AssetBundle对代码进行改动,但是Lua是即时编译型语言,并且可以被打包进入AssetBundle中,在需要修改简单功能时,将Lua代码通过AssetBundle进行更新即可。
2. ToLua的下载的安装
首先是下载地址:
ToLua
这是作者的github地址,进入以后点击下载Zip,完成后解压到自己需要的目录,再用Unity打开即可。
点击下载zip即可
第一次打开工程时会提示是否需要自动生成注册文件,新手可以选择直接生成,若选择了取消,也可以在编辑器菜单中手动注册。——这是一个非常重要的操作,后文也会提到。
下面开始关于使用的正文。
3. ToLua的基本使用
前面有提到过ToLua的基本实现方式,这里可以再细化一点:创建虚拟机——绑定数据——调用Lua代码,这套步骤在框架自带的Example里也非常清晰。
首先脱离Example实现一下这三个步骤。
- ToLua虚拟机的创建非常简单,只需要new一个LuaState即可,我们建立一个C#脚本作为入口,引用
LuaInterface命名空间
,输入以下代码,将文件挂载到场景中的一个空物体上即可。
using LuaInterface;
using UnityEngine;
public class LuaScene : MonoBehaviour
{
string luaString = @"
print('这是一个使用DoString的Lua程序')
";
string luaFile = "LuaStudy";
LuaState state;
void Start()
{
state = new LuaState();//建立Lua虚拟机
state.Start();//启动虚拟机
//使用string调用Lua
state.DoString(luaString);
//使用文件调用Lua
//手动添加一个lua文件搜索地址
string sceneFile = Application.dataPath + "/LuaStudy";
state.AddSearchPath(sceneFile);
state.DoFile(luaFile);
state.Require(luaFile);
state.Dispose();//使用完毕回收虚拟机
Debug.LogFormat("当前虚拟机状态:{0}", null == state);//验证虚拟机状态
}
}
- 这里使用的Lua脚本也非常简单
print('这是一个使用DoFile的Lua程序')
Lua挂载
-
ToLua直接调用Lua代码的方式有两种,一种是
DoString
,另一种是DoFile
;此外还有一个Require
方法,这个方法和前两个方法不同的是,ToLua会将调用的Lua文件载入Lua栈中,而前两者只是运行一次,运行之后保存在缓存中,虽然也可以后续调用,但是会。
在上述代码中要注意,使用DoFile
和Require
方法时,要手动给目标文件添加一个文件搜索位置。
运行结果如下:Lua运行结果
-
最后,使用完毕记得清理虚拟机,我使用
null==state
来进行判断,最后输出“true”,说明调用LuaState.Dispose()
后,虚拟机已经被清理。
4. C#中调用Lua变量/函数
我们上面实现了C#调用Lua文件和string,其实对于ToLua而且,直接调用string和文件并没有本质区别,最后都会转换成byte[]
进行载入。
接下来实现一下ToLua调用指定Lua变量和函数,这里通过文件导入Lua代码。
- 首先是我们的Lua代码,这一段Lua代码一共有一个普通变量、一个带有函数的表,一个无参函数,一个有参函数,功能非常简单,并且在这一段代码中没有调用。
num = 0
mytable={1,2,3,4}
mytable.tableFunc=function()
print('调用TableFunc');
end
function Count()
num=num+1
print('计数器+1,当前计数器为'..num)
return num;
end
function InputValue( param)
print('[lua中调用:]InputValue方法传入参数:'..tostring( param))
end
- 然后是C#代码,还是一样的套路,先创建虚拟机,读入Lua文件。下面依次说明普通变量、无参函数、有参函数和
table
的调用。
注意:如果带有local
标识,那么C#中无法直接获取
- 普通变量
普通变量的调用非常简单,在载入文件后,通过LuaState[string]
的形式就可以直接获取到,也可以通过这个表达式来直接赋值。 - 无参函数
函数的调用有两种方式,一是先缓存为LuaFunction
类型后调用,二是直接能过Call
方法调用。 - 有参函数
有参函数和无参函数调用的区别在于参数的传入,在ToLua中重载了非常多的传参函数,与无参函数的调用方法相同,有参函数也有两种调用方式,这里具体说明一下传入参数的不同方式。
-
- 传入参和调用分离。
这种方式一般需要先将函数缓存为LuaFunction
,然后使用BeginPcall
方法标记函数,再使用Push
或者PushArgs
方法将参数传入函数,最后调用PCall
,还可以调用EndPcall
标记结束。
- 传入参和调用分离。
//对方法传入参数
LuaFunction valueFunc = state.GetFunction("InputValue");
valueFunc.BeginPCall();
valueFunc.Push("--push方法从C#中传入参数--");
valueFunc.PCall();
-
- 调用时直接传入参数。
这是最符合一般操作逻辑的方式,但是查看实现代码会发现,事实上只是LuaFunction中封装的一套实现,其本质和上一种是一样的。
- 调用时直接传入参数。
4.table
table是lua中的一个百宝箱,一切东西都可以往里装,table里可以有普通的变量,还可以有table,也可以有方法。
在ToLua里对table的数据结构进行了解析,实现了非常多的方法,这里完全可以将table看一个LuaState
来进行操作,两者没有什么区别。
以下是完整的C#代码,运行结果后附。
using LuaInterface;
using UnityEngine;
public class LuaAccess : MonoBehaviour
{
string luaFile = "LuaAccess";
LuaState state;
void Start()
{
state = new LuaState();
state.Start();
//使用文件调用Lua
//手动添加一个lua文件搜索地址
string sceneFile = Application.dataPath + "/LuaStudy";
state.AddSearchPath(sceneFile);
state.Require(luaFile);//载入文件
//获取Lua变量
Debug.Log("获取文件中变量:" + state["num"]);
state["num"] = 10;
Debug.Log("设置文件中变量为:" + state["num"]);
//调用Lua方法
LuaFunction luaFunc = state.GetFunction("Count");
luaFunc.Call();
Debug.Log("C#调用LuaFunc,函数返回值:" + state["num"]);
Debug.Log("C#直接调用Count方法。");
state.Call("Count", false);
//对方法传入参数
LuaFunction valueFunc = state.GetFunction("InputValue");
valueFunc.BeginPCall();
valueFunc.Push("--push方法从C#中传入参数--");
valueFunc.PCall();
valueFunc.EndPCall();
valueFunc.Call("--直接Call方法从C#传入参数--");
//获取LuaTable
LuaTable table = state.GetTable("mytable");
table.Call("tableFunc");
LuaFunction tableFunc = table.GetLuaFunction("tableFunc");
Debug.Log("C#调用table中的func");
tableFunc.Call();
Debug.Log("获取table中的num值:"+table["num"]);
//通过下标直接获取
for (int i = 0; i < table.Length; i++)
{
Debug.Log("获取table的值:" + table[i]);
}
//转换成LuaDictTable
LuaDictTable dicTable = table.ToDictTable();
foreach (var item in dicTable)
{
Debug.LogFormat("遍历table:{0}--{1}", item.Key, item.Value);
}
state.Dispose();
}
}
Lua访问变量
5. Lua中调用C#方法/变量
之前在 @罗夏L的文章里看过一篇他关于lua调用C#的笔记,但总觉得少了点什么,所以在我自己记笔记的时候特别注意了一下具体的实现。
在@罗夏L的文章中,将一个C#对象作为参数传入列表中,然后直接在Lua代码里运行对应的方法名,其中少了几个关键的步骤,如果只是进行了这几步,是实现不了在Lua里引用的。
- 首先还是从实现原理说起,在文章开始的第一节我提过ToLua的基本实现思路,并且将这套系统分成了三部分,在这三部分之中,ToLua作为一个桥梁实现了沟通Lua脚本和C#的功能,我们知道Lua的实质是通过字节码对C进行了一套封装,具有即时编译的特点,从C#或者其他语言中来调用Lua都不算太困难,只需要提前约定特定方法名然后载入脚本即可,但C#是需要提前编译的,怎么通过一段解释器来调用C#中的对象就是主要的难点了,ToLua实现的就是这两方面的功能。
从这方面来分析,我觉得大多数人会想到的最直接的实现思路大概都是通过反射来实现,uLua也是通过反射来实现的,但是反射的效率非常低,虽然确实可以实现,但问题还是非常明显。
ToLua是通过方法名绑定的方式来实现这个映射的,首先构造一个Lua虚拟机,在虚拟机启动后对所需的方法进行绑定,在虚拟机运行时可以在Lua中调用特定方法,虚拟机变相地实现了一个解释器的功能,在Lua调用特定方法和对象时,虚拟机会在已绑定的方法中找到对应的C#方法和对象进行操作,并且ToLua已经自动实现了一些绑定的方法 。
-
基本原理大概了解以后,我们就可以来看看它的具体实现了。
-
-
- 第一步还是建立虚拟机并且启动,为了实现Lua对C#的调用,首先我们要调用一下绑定方法,于是我们的代码变成了下面这样。可以看到,这里和之前的唯一区别是增加了
LuaBinder.Bind(state)
方法,这一个方法内部其实是对许多定义好的方法的绑定,也就是上面说的绑定方法。
- 第一步还是建立虚拟机并且启动,为了实现Lua对C#的调用,首先我们要调用一下绑定方法,于是我们的代码变成了下面这样。可以看到,这里和之前的唯一区别是增加了
-
using LuaInterface;
using UnityEngine;
public class CSharpAccess : MonoBehaviour
{
private string luaFile = "LuaCall";
LuaState state;
void Start()
{
state = new LuaState();
state.Start();
string sceneFile = Application.dataPath + "/LuaStudy";
state.AddSearchPath(sceneFile);
// 注册方法调用
LuaBinder.Bind(state);
state.Require(luaFile);//载入文件
}
}
-
-
- 然后我们加入一个变量和一个方法,我们要实现的是完成在Lua中对这个方法和变量的调用。
-
public string AccessVar = "++这是初始值++";
public void PrintArg(string arg)
{
Debug.Log("C#输出变量值:" + arg);
}
-
-
- 在有了目标方法之后,我们要将这个变量和方法绑定进入虚拟机中。
查看LuaState的实现代码,可以发现绑定主要有RegFunction
、RegVar
和RegConstant
三个方法,分别用于绑定函数/委托、变量和常量。在这里ToLua是通过一个委托来实现方法的映射,这个委托需要传入一个luaState变量,类型是IntPtr
,这个变量的实质是一个句柄,在实际操作中,会将虚拟机作为变量传入。
- 在有了目标方法之后,我们要将这个变量和方法绑定进入虚拟机中。
-
public delegate int LuaCSFunction(IntPtr luaState);
public void RegFunction(string name, LuaCSFunction func);
public void RegVar(string name, LuaCSFunction get, LuaCSFunction set);
public void RegConstant(string name, double d);
public void RegConstant(string name, bool flag);
-
-
- 总结一下几个方法的特点:
-
- 这几个方法都需要传入一个
string name
,这个name
就是之后在Lua中调用的变量或方法名。RegConstant
方法比较简单,传入一个name
再传入一个常量即可;RegFunction
和RegVar
都是通过LuaCSFunction
类型的委托实现;RegFunction
需要一个LuaCSFunction
委托,这个委托需要对原方法重新进行一次实现;RegVar
除了name
之外,还需要两个LuaCSFunction
委托,可以理解为一个变量的get/set方法,如果只有get或set,另一个留null即可。
-
-
- 接下来我们对
AccessVar
和PrintArg
方法进行一下LuaCSFunction
形式的实现。
- 接下来我们对
-
private int PrintCall(System.IntPtr L)
{
try
{
ToLua.CheckArgsCount(L, 2); //对参数进行校验
CSharpAccess obj = (CSharpAccess)ToLua.CheckObject(L, 1, typeof(CSharpAccess));//获取目标对象并转换格式
string arg0 = ToLua.CheckString(L, 2);//获取特定值
obj.PrintArg(arg0);//调用对象方法
return 1;
}
catch (System.Exception e)
{
return LuaDLL.toluaL_exception(L, e);
}
}
private int GetAccesVar(System.IntPtr L)
{
object o = null;
try
{
o = ToLua.ToObject(L, 1); //获得变量实例
CSharpAccess obj = (CSharpAccess)o; //转换目标格式
string ret = obj.AccessVar; //获取目标值
ToLua.Push(L, ret);//将目标对象传入虚拟机
return 1;
}
catch (System.Exception e)
{
return LuaDLL.toluaL_exception(L, e, o, "attempt to index AccessVar on a nil value");
}
}
private int SetAccesVar(System.IntPtr L)
{
object o = null;
try
{
o = ToLua.ToObject(L, 1);//获得变量实例
CSharpAccess obj = (CSharpAccess)o;//转换目标格式
obj.AccessVar = ToLua.ToString(L, 2);//将要修改的值进行设定,注意这里如果是值类型可能会出现拆装箱
return 1;
}
catch (System.Exception e)
{
return LuaDLL.toluaL_exception(L, e, o, "attempt to index AccessVar on a nil value");
}
}
可以看到这三个方法的格式都是一致的,通用的步骤如下:
- 使用
ToLua
中的方法对L句柄进行校验,出现异常则抛出,本例中使用ToLua.CheckArgsCount
方法;- 获得目标类的实例,并转换格式,具体转换方法较多,可以根据需要在ToLua类中选择,本例中使用了
ToLua.CheckObject
和ToLua.ToObject
等方法;- 调用对应方法,不同的方法调用略有区别。
值得注意的是,在ToLua的ToObjectQuat、ToObjectVec2等获取值类型的方法中,会出现拆装箱的情况。
-
-
- 下一步将几个方法注册进lua虚拟机。
注意这里有两对方法,分别是BeginModule\EndModule
和BeginClass\EndClass
,BeginModule\EndModule
用于绑定命名空间,可以逐层嵌套;而BeginClass\EndClass
用于开启具体的类型空间,具体的方法和变量绑定必须在这成对的方法之中,否则会导致ToLua崩溃(百试百灵,别问我怎么知道的)。
- 下一步将几个方法注册进lua虚拟机。
-
private void Bind(LuaState L)
{
L.BeginModule(null);
L.BeginClass(typeof(CSharpAccess), typeof(UnityEngine.MonoBehaviour));
state.RegFunction("Debug", PrintCall);
state.RegVar("AccessVar", GetAccesVar, SetAccesVar);
L.EndClass();
L.EndModule();
}
-
-
- 最后是我们的Lua代码,非常简单,注意
Debug
和AccessVar
调用的区别。
- 最后是我们的Lua代码,非常简单,注意
-
print('--进入Lua调用--')
local go = UnityEngine.GameObject.Find("LuaScene")
local access=go:GetComponent("CSharpAccess")
access:Debug("Lua调用C#方法")
access.AccessVar="--这是修改值--"
print('--Lua调用结束--')
-
-
- 完整C#代码
-
using LuaInterface;
using UnityEngine;
public class CSharpAccess : MonoBehaviour
{
private string luaFile = "LuaCall";
LuaState state;
void Start()
{
state = new LuaState();
state.Start();
string sceneFile = Application.dataPath + "/LuaStudy";
state.AddSearchPath(sceneFile);
// 注册方法调用
LuaBinder.Bind(state);
Bind(state);
Debug.Log("AccessVar初始值:" + AccessVar);
state.Require(luaFile);//载入文件
Debug.Log("C#查看:" + AccessVar);
state.Dispose();
}
private void Bind(LuaState L)
{
L.BeginModule(null);
L.BeginClass(typeof(CSharpAccess), typeof(UnityEngine.MonoBehaviour));
state.RegFunction("Debug", PrintCall);
state.RegVar("AccessVar", GetAccesVar, SetAccesVar);
L.EndClass();
L.EndModule();
}
private int PrintCall(System.IntPtr L)
{
try
{
ToLua.CheckArgsCount(L, 2); //对参数进行校验
CSharpAccess obj = (CSharpAccess)ToLua.CheckObject(L, 1, typeof(CSharpAccess));//获取目标对象并转换格式
string arg0 = ToLua.CheckString(L, 2);//获取特定值
obj.PrintArg(arg0);//调用对象方法
return 1;
}
catch (System.Exception e)
{
return LuaDLL.toluaL_exception(L, e);
}
}
public void PrintArg(string arg)
{
Debug.Log("C#输出变量值:" + arg);
}
[System.NonSerialized]
public string AccessVar = "++这是初始值++";
private int GetAccesVar(System.IntPtr L)
{
object o = null;
try
{
o = ToLua.ToObject(L, 1); //获得变量实例
CSharpAccess obj = (CSharpAccess)o; //转换目标格式
string ret = obj.AccessVar; //获取目标值
ToLua.Push(L, ret);//将目标对象传入虚拟机
return 1;
}
catch (System.Exception e)
{
return LuaDLL.toluaL_exception(L, e, o, "attempt to index AccessVar on a nil value");
}
}
private int SetAccesVar(System.IntPtr L)
{
object o = null;
try
{
o = ToLua.ToObject(L, 1);//获得变量实例
CSharpAccess obj = (CSharpAccess)o;//转换目标格式
obj.AccessVar = ToLua.ToString(L, 2);//将要修改的值进行设定
return 1;
}
catch (System.Exception e)
{
return LuaDLL.toluaL_exception(L, e, o, "attempt to index AccessVar on a nil value");
}
}
}
-
-
-
运行结果
Lua调用C#
-
-
-
那么最后,我们回到本节开始, @罗夏L的文章里是哪里出现了问题?
我在lua中加入了一行access:PrintArg("PrintArg")
调用方法,发现Unity报了这样的错误:直接调用方法名报错.png
说明单纯这样是做不到直接调用方法的,仔细看文章,我发现他有提到这样的内容:
首先将自己写的类 放到 CustomSettings 里 就是CallLuafunction
BindType[] customTypeList
放到这个数组里 注册进去供lua使用
这里是不是他说得不够详细?我找到这个类,发现这个类里记录了非常多的Unity自带类,这让我想起了第一次启动Lua时的提示,心里生出了一个疑问:这些数据是不是用于自动注册生成类的呢?
//在这里添加你要导出注册到lua的类型列表
public static BindType[] customTypeList =
{
_GT(typeof(LuaInjectionStation)),
_GT(typeof(InjectType)),
_GT(typeof(Debugger)).SetNameSpace(null),
...以下部分省略
沿着调用链,我找到了这个变量的引用,果然,最这个数据是用于类型注册的。
我将这个类放到了数组的最后,点击Clear wrap files
,完成后立即弹出了数据自动生成的对话框,点击确认,
重新生成注册
自动生成
接下来我重新运行了lua脚本:
print('--进入Lua调用--')
local go = UnityEngine.GameObject.Find("LuaScene")
local access=go:GetComponent("CSharpAccess")
access:Debug("Lua调用C#方法")
access.AccessVar="--这是修改值--"
print('--Lua调用结束--')
access:PrintArg("PrintArg")
成功运行
成功运行,说明ToLua实现了一整套绑定方案,只需要将所需要的内容配置完成即可。
6.总结
原本只是想简单写一写调用方式,最后又写成了一篇长文,但是从有计划开始到一整篇结束却花掉了近一整天的时间。
虽然如此,收获还是非常大的,对这套工具的使用熟练度又上了一个层次,以后也要加强总结。