Ylisar 发表于 2023-2-18 10:22

三、Lua相关知识

一、lua语言基础
1、metatable
2、pairs、ipairs、table.sort
3、table的内存(数组结构和哈希结构)
4、字符串缓存(字符串常量是共享的。这个5.3版本有调整,40字节一下的短字符串才是共享的,长字符串还是保持独立内存)。所以配置文件中存在大量重复的字符串并不是很耗内存。
  反而是配置中存在大量的数字或者是嵌套的table的时候,非常耗内存。
5、lua本身的协程不支持常用的 WaitForSeconds 的功能。xlua通过Coroutine_Runner.cs 这个文件实现了这个功能。

二、lua和C#如何进行交互
1、通过lua state堆栈进行交互。
2、C#通过 lua_pushnumber 、lua_pushboolean、lua_pushstring、lua_pushlstring等接口传递参数。然后通过lua_pcall 调用函数。
3、lua调用C#,在C#的wrap函数中,通过lua_tonumber、lua_tostring等接口获取参数。执行后的结果可以通过lua_pushXXX 返回给lua。
4、lua_pushstring 传递一个字符串给lua。内部会使用strlen计算字符串长度。\0结尾。
  lua_pushlstring 传递一个buffer给lua。指定长度。
5、userdata。C#或者C++的类对象传递给lua,使用的是userdata。在XLua中ObjectTranslator就负责维护这些userdata。
6、C#怎么导出类型给lua的。
  通过Registry (注册表,LUA_REGISTRYINDEX)。这是一个全局table。只能被C代码访问。xlua把CS这个对象放在LUA_REGISTRYINDEX上,并通过生成的wrap文件,把所有C#类型都放在CS对象上。于是lua中就可以通过 http://CS.XXX访问C#的对象了。比如CS.UnityEngine.GameObject或者CS.Actor。
7、C#怎么调用lua的函数。
  函数应该通过字符串名字获取到,或者通过lua使用参数传递过来。这个LuaFunction对象可以保存起来。
  通过lua_getref把函数放到栈顶,通过lua_pushXXX把参数压栈,然后通过lua_pcall执行函数。如果有返回值则通过lua_toXXX在-1(栈顶位置)获取到lua的返回值,在xlua中返回的一般都是TResult对象。最后通过lua_settop恢复栈顶。
8、通过luaState.AddBuildin接口,可以添加C模块或者自定义模块。

三、lua面向对象
1、通过metatable实现class
2、lua访问一个table会先看有没有对应的字段(可以是变量或者函数),如果没有的话,会查找metable的__index。这个索引可以是一个function,也可以是一个table。如果__index也无法取到对应的变量则返回nil。
  另外一个是__newindex。它会在给table中不存在的字段赋值的时候调用。
3、class会返回一个table。它的metatable会指定为父类类型。这个返回的table就是一个lua中定义的类型了,我们会给它添加各种函数定义。
  当new的时候,会返回一个新的table,它的metatable会设置为class返回的这个类型table。这样当访问一个函数的时候,就会从当前对象的table--》类型table--》父类的table,这样的顺序去查找函数。从而实现面向对象中常用的多态(override)。
4、另外需要注意,我们只有函数是放在类型table上的,这个是所有对象共用的。而变量字段是通过ctor的递归调用直接赋值到对象table上的。也就是说变量是归对象的,函数是归类型的。
  这么设计符合直觉,有利于减少内存,也不用担心父子类之间变量冲突。
5、调用父类的正确写法。比如 Hero 继承自 Player 继承自 Animal
  self.super.Move(self, x, y, z)。而不能直接 self.super:Move(x, y, z)。因为后者由于lua的语法糖,传递给Move的参数是super,而实际上这个时候我们期望传递的是self。
  当有三层继承的调用的时候,上述写法也是有问题的。因为第二层调用依然是self.super.Move,会形成死循环。这个时候正确的调用方式是使用类型名调用。比如Player.Move(self, x, y, z)。

四、实际项目经验
1、使用luajit2.1的版本。luajit性能很高,比lua可能高5~10倍。但是也要小心极端情况下可能会有jit失效的问题。这个时候代码无法jit,而又重复尝试jit,反而会导致性能降低。
2、luajit对应的lua版本是5.1。支持少量5.2的功能。不支持5.3的int。不支持_Env。
3、luajit在iOS下没有开启jit功能。因为苹果政策问题,关键api没有权限调用。不过多数情况luajit依然要比lua快,所以在iOS下我们也是使用luajit。
4、ios下编译的是64位bytecode。不兼容32位的cpu。所以iPad1,iPhone5及以下的机型要么舍弃掉。要么同时再编译一份32位的bytecode,运行时根据机器cpu决定读取哪个脚本文件。我们的选择是放弃iPad1这样的老旧设备。
5、编辑器下使用luajit,总是会导致编辑器崩溃。原因未知。换成lua5.1的版本就好了。
  也就是说我们对外发布使用的是luajit,编辑器下使用的是lua5.1。
6、我们lua代码做了几层加密。luajit编译为bytecode是一层,aes加密是一层,lz4压缩是一层。
  因为aes和lz4都是非常快的算法,尤其是在读取的时候,所以性能上问题不大。
7、我们之前的lua代码倾向是打包在固定的几个文件的ab包内。不过后来随着代码量的逐渐增大,gui.ab、config.ab和logic.ab越来越大。更新一个版本补丁最小也要八九兆。后面这三个目录还是按照子目录打包。目的是减少更新补丁的大小。
8、随着代码量增大,我们打包的时候编译lua可能要占用三分钟时间。所以打包的时候维护了一个当前lua.bytes的缓存,记录了每个lua文件的md5。如果md5一致的话,就不重新编译。这样节约了打包的时间。

五、Lua代码热加载(Hot Reload)
1、热加载不同于热更新,服务器可以用来不关服修正线上问题(动态更新),客户端主要方便开发时不关闭游戏就重载更新后的代码,提高开发效率。
2、检测哪些文件发生改变(被修改了),这个原本使用的是C#的FileSystemWatcher,不过貌似很不稳定。经常导致Unity卡死,原因未知。
  也考虑过做一个VSCode的插件,然后使用socket通知Unity,不过感觉方案比较复杂,别人使用起来也比较复杂。
  最后的解决方案是使用Go(或其他语言)实现一个独立的监控文件改变的进程。Unity开启新进程监控文件变化。进程之间通过stdout的返回值进行通信。最终可以准确识别的文件的改变,也不会导致Unity卡死。
3、获取到的变化文件列表,不要在子线程处理。统一丢到主线程通知lua处理。
4、package.loaded = nil。先把已加载文件的缓存清空。
5、重新require文件,替换保存的upvalue。
6、最终达成的效果是,某个函数添加了日志或者修改了逻辑,会自动修改生效,而不用重启游戏。
7、补充说明的是,只有全局变量有这么处理的必要。像我们的界面是local变量,那么只要关掉界面的时候把 package.loaded 置空,再次打开界面就可以重新加载修改后的文件。

六、Lua的调试
1、不同的IDE插件(VSCode+luaidelite,或者IDEA+EmmyLua),有不同的LuaDebug.lua的代码。
  但是本质上都是游戏运行时开启一个socket,设置断点的时候就直接sleep掉主线程。等插件继续运行游戏的时候,就是socket通知游戏,取消sleep。
2、各种变量信息可以通过 debug.getinfo 获取。

七、Lua的性能分析
1、推荐 Miku-LuaProfiler。提供了可视化的Unity窗口界面,看着非常直观。
2、本质上是使用 debug.sethook 监控函数的执行,在开始和结束的位置打点,最后统计分析哪些是耗时函数。
3、内存分析
3.1、善用 collectgarbage("count"),获取当前的lua内存。
3.2、做内存分析之前,先执行 collectgarbage("stop"),停止GC,否则运行过程中可能触发gc导致数据不准确。
3.3、在切换场景或者其他必要情景,执行 collectgarbage("collect"),进行gc。

IT圈老男孩1 发表于 2023-2-18 10:26

好文章啊,总结非常全面了

TheLudGamer 发表于 2023-2-18 10:29

感谢, 好文

Mecanim 发表于 2023-2-18 10:33

FileSystemWatcher导致崩溃应该是多线程问题,应该用它记录哪些lua变化了,然后在主线程去重新加载。
页: [1]
查看完整版本: 三、Lua相关知识