整个思路的核心就是:
1、通过Lua_topointer,直接获取Lua table的内存指针。 2、由于Lua/LuaJIT的table内存结构是可以确认的,我们可以对照其C代码在C#中声明结构体,这样就可以通过table指针拿到array的指针以及array的长度。 3、但是,这里有一个难点,就是要处理Lua/LuaJIT的差异,以及在不同编译选项下产生出来的32位、64位的差异。所以可以看到我们是分LuaAdapter.cs和LuaJitAdapter.cs两套实现,并且各自提供了32/64位的结构体声明。 4、不管是Lua还是LuaJIT,array数组存储的不是int或者double,而是一个叫TValue的联合体,TValue除了存储数值本身,还存储了类型信息。我们在读写的时候,需要先判断类型信息,不然就会无法获得正确的结果。 5、在了解这些信息之后,整个过程就是:拿到table指针,用对应平台的结构体指针获得array指针,再通过数组index拿到array中正确位置的TValue,最后根据TValue的类型信息获得/写入int或者double。
文章最后提供了实现的下载链接,此处演示如何使用这个库。
Lua端:
C#端:
具体使用流程:
- 参考附录中的方法将代码整合到游戏中。
- local luaTable = LuaCSharpArr.New(123)会产生一个长123的Lua数组,长度仅仅是预分配,可以正常扩充,这个数组跟正常的数组是基本一样的,仅仅在里头内嵌了一个供C#使用的访问器LuaArrAccess类。
- 在Lua端可以直接像普通数组一样访问,例如LuaTable[12] = 34.56。
- local arrAccess = luaTable:GetCSharpAccess(),通过这个方式获得C#访问器
- 自己实现一个C#函数并导出到Lua,用这个函数将C#访问器从Lua传递到C#。
- 在C#中拿到这个访问器,然后可以使用arrAccess.SetInt(12, 34)、arrAccess.GetDouble(12)这样的方式去读写数据。
如何应用
有了这个跨语言共享数据的方案,我们就可以大胆将主要的数据放在Lua层,而不用再为跨语言性能而过度牺牲热更代码的覆盖率。具体怎么设计代码的架构来利用这个数据共享方式呢?
以《赤潮》这样的产品为例:
- 同屏角色200+,必须高度考虑性能。
- 我们使用了帧同步,一旦线上发现不同步的bug是致命的,必须支持热更新修复。
- 每月我们会推出新兵种,每隔几个月会推出新玩法地图和节日地图,无论兵种逻辑还是地图规则,都需要一定的热更新能力,减少发布完整包更新的必要性。
在以往的方案里,由于性能和热更新两者高度矛盾,要同时做到以上的点,十分困难。 而现在,借助跨语言数据共享,我们可以这样实现:
- 常用的逻辑数据(比如角色属性/角色状态/buff信息等等),用数组存储放在Lua,并通过本文源码中的LuaArrAccess共享到C#内。
- 主体战斗逻辑使用C#实现,保证基本性能。
- C#逻辑直接通过LuaArrAccess读写Lua中的数据,而不是自己复制一份。这样,无论Lua还是C#,都可以自由访问这些共享的信息。
- Lua申请一个巨大的数组,共享到C#中,C#可以通过这个数组回传事件消息,Lua可以响应来自C#的事件,性能比使用C#导出delegate更优。
- Lua实现网络同步逻辑,以便支持随时的热更同步逻辑,以及热更网络协议。
- Lua处理策划数据表,以便支持随时热更策划数据结构变更。
- Lua处理整个玩法大规则,以便我们在推出新玩法的时候,可以做到热更新。一般而言,玩法大规则的运算量比较小,并非性能瓶颈,但需要更战斗逻辑有大量数据交互。
- Lua作为战斗逻辑的大入口,有权限管理所有角色/地图/组件的开关以及生命周期,为hotfix提供最大的便利。
- 对一些可以模板化的代码,比如技能逻辑/AI节点等,支持Lua/C#两种实现方式,以便做到新特性可以直接热更。
这一切之所以可行,得益于我们可以低消耗跨语言传递数据,否则,我们放在Lua的功能(网络同步/数据表/玩法规则/声明周期管理/技能AI模板节点),会因为需要频繁跟C#交互,产生巨大的性能瓶颈。 可见,高效地在两个语言之间共享数据,是非常重要的。
此外,如果你的项目本身是用Lua做的,要迁移到C#,你也可以利用这个共享机制很轻松地将局部代码转移过去。
性能对比
由于在Lua和C#间传递数据的方法很多,我们对比一下通过传统C#版Lua导出传参,以及在C导出API给Lua直接读写C内存的性能,进行对比。
环境:Windows + i7 + Unity 2018.3.11f1,Lua使用5.3.5,LuaJIT使用2.1.0beta3(启用interpreter模式)
1、可以看到,新方案(操作1/2和操作3/4)比起传统直接Lua调用C#传递数据(操作6),性能优化非常大。新方案在两个语言都做到近乎原生读写的性能,读写成本基本不再是瓶颈。因此,将代码按需求分布在Lua和C#之间,将成为可能。
2、其中,C#端的操作3/4性能会较慢,主要原因在于两方面:
- Lua和LuaJIT都区分int和double存储,C#操作需要进行判断。其中LuaJIT的判断较为复杂,耗时也会更多。
- 为了提高易用性和安全性,本文的源码增加了一些保护,这些保护并非必须。如果希望追求更高的效率,可以参考附录自行调整。
3、而Lua原生读写数组(操作1)的效率也比通过C API访问共享内存(操作5)要高一些,提升效率大概是2~3倍的水平。而C#端我们没有对比,因为我们的实现本质就是访问共享内存的方式,所以性能本质上是一样的,完全看共享内存存储方式的具体实现(比如是否像Lua一样需要判断数据类型)。另外考虑到我们访问不需要编译C代码,所以这个方案也是非常有优势的。
4、另外,由于数据是共享的,就没有必要在Lua和C#之间分配两套内存空间存数据,也就提供了节省内存的可能性。
5、事实上,了解Lua底层的朋友会知道,Lua数组(采用1~n连续整数键值的表)比Lua hashtable(即有带命名字段的表)访问起来要快,且节省内存(TValue的内存占用大概是Node+Key/Value的四分之一)。所以即使使用面向对象的方式开发,从性能最优的角度,我们也鼓励用Lua数组的方式来替代Lua hashtable的方式,如下代码所示:
附录:使用细节说明及源码下载
1. 如何整合插件
a) 由于代码使用了unsafe code,所以需要在Unity的player settings勾选Allow unsafe code。 b) 下载本文附件中的代码,将LuaAdapter.cs和LuaJitAdapter.cs拷贝到工程的任意目录。 c) 代码默认按照xLua的标准开发,但如果你使用的不是xLua也很容易集成。
- 将xLua相关的API替换为对应的API;
- 向Lua导出LuaArrAccessAPI/LuaArrAccess/LuaTablePin64/LuaJitTablePin共4个类;
- 由于xLua导出的C#类在Lua中都是按CS.XXX的命名调用,所以需要将LuaCSharpArr.lua.txt内的CS.LuaTableCSharpAccess替换为正确的命名,比如uLua为LuaTableCSharpAccess。
d) 在Lua初始化后,调用LuaTableCSharpAccess.RegisterPinFunc(L);注册函数,其中参数L是lua state的IntPtr。 e) LuaCSharpArr.lua.txt中包含了LuaCSharpArr的整个定义声明,可以直接require使用,如果你的Lua代码require机制不同,将代码复制到你的Lua可以访问到的地方即可。 f) 参考示例代码LuaTestScript.lua.txt以及LuaTestBehaviour.cs使用Lua端的LuaCSharpArr和C#端的LuaArrAccess。 g) 如果想运行测试用例,在空场景中建立一个GameObject,将LuaTestBehaviour拖进去,并将里头的LuaScript字段附上LuaTestScript.lua.txt即可。
2. 理解Lua array与hashtable
a) 你需要知道,Lua table实际内部是包含一个数组(array)和hashtable来共同存储所有数据的。其中array用于存储键值为1~n的数据,且1~n必须连续。而其他的数据,会被存放在hashtable内。 b) 必须要注意,Lua数组要求key必须是连续的整数(1~n),如果中间有空洞,那么可能出现的情况是后面的数据会被放到hashtable存储,也就无法在LuaArrAccess读取,所以我们提供了预分配机制,防止自己插入的时候出现失误。
3. 安全读写与访问
a) 要大规模在工程中使用,那么代码的安全性就很重要,将lua底层数据结构暴露这个事情,本身会破坏Lua的安全性,错误的操作可能会导致严重的内存错误。因此我们提供的实现做了一些机制避免出现问题。
- LuaArrAccess(C#访问器)会正确地响应LuaCSharpArr(Lua端数组)被GC的情况。当LuaCSharpArr被GC时会触发LuaArrAccess.OnGC(),将C#对LuaCSharpArr的引用指针置空。此时LuaArrAccess处于InValid状态,读取数值会返回0,写入数值会被忽略。
- Lua分配过的内存是保证地址的可持续性的,也就是你用指针引用的数据不会突然间被转移到其他的内存位置。
- 前文提到LuaCSharpArr.New会进行预分配,防止数组空洞。
- LuaArrAccess会检查数组越界。
b) 使用注意
- 你可以在Lua中引用LuaCSharpArr,但是不要在Lua直接强引用LuaCSharpArr:GetAccess返回的LuaArrAccess。强引用LuaArrAccess会导致C#端不能正确响应Lua array被GC的情况。
- Lua array的扩展只能发生在Lua端。也就是如果Lua array长128,你不能在C#端通过LuaArrAccess设置第129项来扩展数组长度。
- LuaArrAccess:GetArrayCapacity()返回的长度是Lua底层预分配的长度,并不是你在Lua中用#运算符获取的数组长度。一般GetArrayCapacity返回的值是2的n次方,比#返回的值更大。在这个范围内读写是内存安全的。
- 你可以通过Lua的#运算符获取数组长度,确认数组是否有空洞。如果有空洞,则#返回的长度只会等于空洞前面的长度。
- LuaArrAccess使用与Lua一致的index,也就是从1开始,而不是从0开始。但注意,LuaJIT允许从0开始,这个也是LuaArrAccess支持的。
4. 进一步的性能提升
a) LuaArrAccess的代码为了易用性和安全性,牺牲了相当程度的性能,读者如果对性能有更高要求并且有意愿修改源码的话,可以尝试以下方法提高性能。
- 跳过Index检查以及null指针检查:如果代码能够保证数组非固定长度;
- 使用GetIntFast和GetDoubleFast函数:该系列函数不检查index范围,不检查double和int类型,性能极致高效,但是读者使用需要相当注意,尤其是int和double的处理要十分小心;
- 使用C语言重新实现LuaArrAccess:使用C可以获得比C#更好的性能,但是需要读者去修改和编译Lua/LuaJIT的C代码,限于篇幅关系,这里不提供详细说明,读者可以参考LuaArrAccess的代码直接翻译到C代码。
5. 其他问题
a) 目前提供的实现,只支持数组存储int/double,不支持其他类型(bool/string等)。由于Lua/LuaJIT在默认编译方式下使用double存储浮点数,所以不提供float相关的接口。 b) 代码只支持读写数组,无法读写hashtable。这里也额外提一点,Lua hashtable虽然使用便利,但是在读写效率以及内存占用上,都比lua array要差不少。所以在我们的项目实践中,会有大量的Lua class使用array来存储字段。 c) 事实上这个方法可以举一反三,推广到用于直接在C#绑定访问lua中的某个表的一个字段,或者访问完整的key-value table,不过由于table访问的复杂性,实现起来会相对复杂一些,不在本文讨论的范围内,读者可以进一步探索。 d) 本文提供的代码并非文中提到的《赤潮》所使用的版本,因为《赤潮》使用的代码基于ulua+luajit2.1.0beta2,也未实现文中所提到的安全访问特性,且需要修改LuaJIT源码,所以代码会有大量不同。 e) 本文的源码已通过以下平台测试(xLua 2.1.14 + unity2018.3.5)。
- Windows x86/x64,Lua+LuaJIT
- Android il2cpp armv7/arm64,LuaJIT
- Ios il2cpp arm64,LuaJIT
f) 另由于Lua/LuaJIT、xLua/sLua/uLua多版本差异以及Mono/il2cpp/跨平台带来的复杂性,本文代码无法确保完全覆盖所有版本和所有平台的组合情况,如果使用中遇到问题,欢迎在下方留言反馈。
|
请发表评论