最新文章
泰课在线 | 微信拼团成功后如何获取课程?
08-09 17:57
Unity教程 | 使用ARKit为iOS开发AR应用
07-31 17:23
Unity Pro专业版7折订阅四选一工具包之VR开发与艺术设计
07-28 11:47
网友使用虚幻UE4实现CAVE 多通道立体渲染的沉浸式环境
07-27 11:57
VR晕动症调查:未来5年内大部分VR晕动症将得到解决
07-27 11:26
AMD CEO:未来3-5年最重要 希望5年达1亿VR用户
07-27 10:44
开源手游热更新方案 Unity3D下的Lua编程
2016 年 12 月末,xLua 实现了新的突破:全平台支持用 Lua 修复 C# 代码 bug。目前,Unity 下的 Lua 热更新方案大多要求需要热更新的部分从一开始就使用 Lua 语言实现,这种方式存在以下不足之处:
- 接入成本高:对于一些已经使用 C# 完成开发的项目,若要接入热更新,需要将需要热更的部分用 Lua 重新实现。
- 开发难度大:即便项目从一开始就接入了 Lua,同时使用 C# 和 Lua 两种语言进行开发的难度较大。
- 性能问题:Lua 的性能不如 C#。
xLua 热补丁技术支持在运行时将一个 C# 实现(函数、操作符、属性、事件或整个类)替换为 Lua 实现,这意味着开发者可以:
- 日常开发:平时使用 C# 进行开发。
- 运行性能:运行时使用 C#,性能远超 Lua。
- 热更新修复:当发现有 bug 时,下发一个 Lua 脚本进行修复。在下次整体更新时,可以将 Lua 的实现换回正确的 C# 实现,甚至可以在更新时不重启游戏。
该新特性已在 iOS、Android、Windows 和 Mac 平台测试通过,目前正在进行一些易用性方面的优化。那么,开源的 xLua 究竟是怎样的技术?它为何如此设计?其性能又如何?带着这些问题,InfoQ 对其作者进行了采访,并将内容整理成文。
技术背景
在已知的项目中,大多数游戏引擎采用 Unity3D,少数使用 Cocos2d。
xLua 的应用情况
xLua 插件于 2015 年 3 月完成第一个版本,但由于当时项目组对热更新的意识并不普遍,需求也不强烈,xLua 的开发资源被调配到了更紧急的项目中。直到 2015 年底,xLua 正式集成到 Apollo 手游开发框架,迎来了第一个应用项目。截至目前,已知应用了 xLua 的项目有十多个,其中不乏一些重量级 IP 或按星级标准打造的产品。
在 xLua 出现之前,针对 iOS 无法热更新的问题,部分项目使用 uLua、sLua,也有项目使用自研的脚本语言,但当时进行热更新的项目数量较少。
热更新流程
手游的热更新流程相对简单,在游戏启动时检测是否有新版本文件,若有则下载并覆盖老文件,然后启动游戏。
下载的文件如果是图片、模型等资源,不会存在问题。但如果是 Unity 原生的代码逻辑,无论是以前的 Mono AOT 还是后来的 il2cpp,都会被编译成 Native Code,在 iOS 系统下无法运行。解决该问题的方法是不使用 Native Code 和 JIT,采用解析执行的方式。包括 xLua 在内的所有热更新支持方案,都是通过“解析执行”来实现代码逻辑的热更新。
来自 xLua 的 Hello world
三行代码运行 Lua 脚本
一个完整的示例仅需三行代码。下载 xLua 后,将其解压到 Unity 工程的 Assets 目录下,创建一个 MonoBehaviour 并拖到场景中,在 Start 方法里添加以下代码:
XLua.LuaEnv luaenv = new XLua.LuaEnv();
luaenv.DoString("CS.UnityEngine.Debug.Log('hello world')");
luaenv.Dispose();
运行游戏后,在 Console 中即可看到打印的“hello world”。第一行和第三行代码分别用于创建和销毁 LuaEnv,LuaEnv 可以理解为 Lua 虚拟机,通常整个工程使用一个虚拟机即可。DoString 方法中可以包含任意合法的 Lua 代码,在示例中,调用了 UnityEngine.Debug.Log 接口打印了一条日志(C# 的静态函数在 CS 命名空间下可直接使用)。
C# 调用 Lua 系统函数 math.max
xLua 支持将一个 Lua 函数绑定到 C# 的 delegate。首先声明一个 delegate,并为其添加 CSharpCallLua 标签:
[XLua.CSharpCallLua]
public delegate double LuaMax(double a, double b);
然后在上述示例代码中(luaenv 销毁前)添加以下两行代码:
var max = luaenv.Global.GetInPath<LuaMax>("math.max");
Debug.Log("max:" + max(32, 12));
通过这种方式,将 Lua 的 math.max 函数绑定到 C# 的 max 变量后,调用该变量就如同调用一个 C# 函数。并且,在执行“XLua/Generate Code”后,max(32, 12) 调用不会产生(C#)GC Alloc,既优雅又高效。更详细的内容可查看 XLua/Doc 下的文档。
xLua 全局观
易用性:编辑器下无需生成代码支持所有特性
xLua 的易用性不仅体现在编程方面,还体现在各个细节之处,甚至考虑到了团队协作的工作流程。xLua 仅有两个菜单选项,分别是生成代码和清除生成代码。在菜单之外,只需在构建手机版本前执行一次“Generate Code”即可(该操作也有 API 可集成到项目的自动化打包流程中)。
这一特色功能的设计是因为部分项目反馈,“生成代码”对于策划和美术人员来说较为复杂,即使经过长时间的教导,他们仍然容易遗忘。此外,一些大型项目由于代码量庞大,每次生成代码后,Unity3D 需要花费很长时间进行转换。
扩展性:授之以鱼,不如授之以渔
在开发过程中,常常需要使用多种功能,例如使用 PB 与后台交互、解析 JSON 格式的配置文件等。虽然可以在 C# 中找到相应的库,并通过 xLua 来使用这些库,但这种方式效率较低,最好能有相应的 Lua 库。
许多方案直接集成一些常用的 Lua 库,但这会带来一些新问题:这些库不一定会被使用,但会增大安装包的体积;集成的库可能不符合项目的使用习惯,例如 JSON 解析,不同的人可能喜欢使用不同的库,如 rapidjson 或 cjson;对于某些项目来说,这些集成的库可能还不够,仍需要自行添加。
xLua 的设计原则是“授之以渔”,具体体现在以下方面:
- 添加库的便利性:提供了接口和教程,开发者可以在不修改 xLua 代码的情况下,根据个人喜好添加库。
- 跨平台编译:通过 CMake 实现跨平台编译,只需修改一个 Makefile 文件,即可完成各平台的编译。
除了方便添加第三方 Lua 插件外,xLua 的生成引擎支持二次开发,开发者可以编写生成插件,生成自己所需的代码和配置。
性能的保证
游戏的性能是备受关注的重点,因此任何模块的变化都应尽可能不降低甚至优化游戏的整体性能。xLua 的设计原则是在保证运行效率的前提下,尽量提高开发效率。
在性能方面,有几个关键的版本:
- 1.0.0 版本(2005 年 3 月发布):该版本将 delegate 和 interface 作为主要的 C# 访问 Lua 的方式,从接口层面避免了 boxing、unboxing 和 GC Alloc,为后续的开发奠定了良好的基础。在开发通用组件时,接口设计的合理性至关重要,一旦设计不合理,后续很难进行纠正。例如,一些从其他 Lua 插件迁移到 xLua 的开发者,一开始习惯使用 LuaFunction.Call 来调用 Lua 函数(xLua 也保留了该接口,可用于对性能要求不高的场景),后期需要逐个修改代码。
- 2.0.0 版本(2006 年 3 月发布):该版本的主要目标是进行性能优化。当时有一个对性能要求极其严苛的项目,甚至认为 C# 的性能都无法满足需求,打算使用 C++ 编写战斗系统。在这个版本中,xLua 将虚拟机切换到 LuaJIT,加入了 lazyload 技术,对逐行语句进行优化,甚至在关键地方不使用 C# 提供的容器,而是自行编写专用的容器(实测性能比 Dictionary 高 4 倍)。可以说,该版本几乎重新开发了 xLua。最终,该项目的选型测试结论是选择 xLua。
- 2.1.0 版本(2006 年 7 月发布):该版本的主要目标是进行 GC 优化。通过重写反射,将反射调用的 GC 减少到原来的几分之一,性能提高了约 3 倍。同时,设计了一个全新的复杂值类型支持方案,该方案支持更多类型(只要 struct 的字段都是值类型即可),包括用户自定义的 struct(其他方案通常不支持),并且更节省内存(以 Vector3 为例,内存占用只有其他方案的 30%)。
不过,xLua 也存在一些劣势。例如,在调用 Vector3 上的某些方法时,性能会比 uLua 和 sLua 差。这是因为 uLua 和 sLua 将 Vector3 用 Lua 重新实现,对于这类耗时不大的运算,直接在 Lua 中进行比在 C# 和 Lua 之间进行适配的成本要小得多。但这种差距仅限于 uLua 和 sLua 完全重新实现的类。
性能是一个需要持续关注的点,xLua 的开发团队会不断进行优化:平时想到好的优化思路,会进行测试,若性能有所提升则加入到代码中;建立性能基线,防止因新功能的加入或 bug 的修复而导致性能下降。
xLua 内置了 Lua 代码分析器(Profiler),并支持真机调试。目前,Lua Profiler 只是一个小工具,尚未提供图形化界面,典型的报告示例如下。与网上类似的工具相比,xLua 的 Profiler 对 C# 函数的支持更好,在 LuaJIT 下的分析结果也更为准确。真机调试方面,各 Lua 插件的实现方式类似,都是将 ZeroBraneStudio 调试所需的 LuaSocket 库预先编译进去,没有太多特别之处。
技术实现的细节
泛型
泛型类型除了运行时动态实例化之外都支持,而运行时动态实例化需要 JIT 的支持,在 iOS 系统下无法实现。例如,如果为 Dictionary 配置了生成代码,那么该类型可以正常使用;但如果新更新的 Lua 代码中需要使用一个之前未生成代码且在 C# 中未使用过的 Dictionary 类型,则不支持。静态实例化的泛型在处理上与非泛型类型没有区别。
委托事件的封装
委托封装是根据委托的接口生成一段操作 Lua 栈的代码,作为委托的实现。以委托 delegate double Add(double a, double b) 为例,生成的代码如下:
public double SystemDouble(double a, double b)
{
RealStatePtr L = luaEnv.L;
int err_func = LuaAPI.load_error_func(L, errorFuncRef);
LuaAPI.lua_getref(L, luaReference);
LuaAPI.lua_pushnumber(L, a);
LuaAPI.lua_pushnumber(L, b);
int __gen_error = LuaAPI.lua_pcall(L, 2, 1, err_func);
if (__gen_error != 0)
luaEnv.ThrowExceptionFromError(err_func - 1);
double __gen_ret = LuaAPI.lua_tonumber(L, err_func + 1);
LuaAPI.lua_settop(L, err_func - 1);
return __gen_ret;
}
这段代码将调用委托的操作转换为调用 Lua 函数。与其他方案相比,xLua 对委托的支持更为完整,具体体现在以下方面:
- C# 主动引用 Lua 函数:支持 C# 主动使用 delegate 来引用一个 Lua 函数。使用 delegate 代替类似
object[] Call(params object[] args)的接口调用 Lua 函数,最大的好处是可以避免值类型传递时的 boxing/unboxing 以及参数数组和返回值数组的 GC Alloc。 - 支持高阶函数:支持返回 delegate 的 delegate,可对应到 Lua 的高阶函数。
- C# 接口引用 Lua 表:作为该技术的延伸,xLua 支持使用一个 C# 接口引用一个 Lua 表,该特性与一些 IOC 框架配合使用,可以实现 C# 和 Lua 之间的无感知交互(模块间通过接口进行耦合,由框架进行组装)。
无缝支持生成代码及反射
生成代码是各大主流方案的标配,而反射在一些方案中明确不支持,但从项目反馈来看,反射同样至关重要。对于一些代码量较大、接近苹果 80M Text 段限制的项目,代码量的大小直接关系到能否发布。虽然反射方式的性能不如生成代码,但对安装包的影响较小。
xLua 实现的“无缝”支持具有以下两个含义:
- 特性和使用方式一致:生成代码和反射在支持的特性以及特性的使用方式上保持一致,在两种方式之间进行切换时,业务逻辑代码无需修改,只需修改配置即可。
- 无缝配合:两者可以无缝配合,例如在一个继承链上,任意一个类都可以选择使用生成代码或反射的方式。即使子类选择生成代码,父类由于不常用而选择反射,仍然可以在子类对象上调用父类的方法。
对于 il2cpp 的 stripping,xLua 也进行了考虑。只要为一个类配置了 ReflectionUse,就会自动生成 Unity 的 link.xml 配置文件,将该类型列为不剪裁。
其他 Lua 插件一览
在 xLua 之外,还有其他的 Lua 插件,如 uLua、sLua、C#light 等。
uLua
uLua 是应用项目最多的插件,由于开源时间较早,名气较大,这是其显著优势。但也存在一些问题,主要是版本的前后兼容问题:
- 版本更迭:uLua 最早是 LuaInterface 开源库的 Unity 移植版本,2015 年初更换为 cs2lua,2016 年初又更换为 tolua c#。从 API 角度来看,这三个版本可视为不同的产品,它们之间很难进行升级,且每次更换版本后,之前的版本就不再维护,给项目带来了很大的困扰。
- 接口问题:uLua 的第一个版本采用纯反射方式,实用性较差,已逐渐退出市场,现存的应用大多使用后两个版本。cstolua 版本的接口比较混乱,它在保留第一版 uLua 接口的同时,又引入了一套新接口,这两套接口之间并非正交关系,也不是后者完全替代前者,让人使用起来感到困惑。到了 tolua c# 版本,接口问题得到了解决,但同时也取消了反射特性(老接口)。不过总体而言,uLua 正在朝着更好的方向发展。
sLua
sLua 的代码质量比 cstolua 好很多,这也是很多人选择 sLua 的原因之一。它部分支持反射,根据测试用例,其整体性能比 tolua c# 略低。随着时间的推移,sLua 在代码质量方面与 tolua c# 相比,已不再具有明显优势。
C#light
C#light 主要存在以下两个不足:
- 性能问题:从其实现原理来看,性能不太可靠,无法满足手机端的实际应用需求。
- 代码配合复杂:由于不完全支持 C#,本质上只是一种类似 C# 的语言(C# like),与 C# 代码配合使用时较为复杂,甚至比 C# 和 Lua 配合使用还要复杂。
事实上,C#light 已基本退出市场,可以忽略不计。
LSharp
LSharp 是 C#light 作者的后续作品,有望改善 C#light 的问题,它从 IL 层面执行。但可惜的是,该项目后续停止了维护,没有了进一步的发展。
相比之下,xLua 在设计上具有更全面的功能,体现在对 C# 特性的支持更全面、对 Lua 虚拟机版本的支持更广泛;使用起来更加方便,例如在编辑器下无需生成代码;性能也不逊色于其他插件。有人可能会抱怨 xLua 没有预集成 PB、JSON、SQLite 等功能,但对于熟悉 Lua 的人来说,这些只是将一些现成的 Lua 扩展编译进去而已,算不上是 xLua 自身的功能。预集成的好处是方便,但缺点是缺乏选择余地,未使用的功能会占用空间,而使用的功能也不一定是用户喜欢的库。xLua 的 Lua 库基于 CMake 编译,添加这些库的门槛很低,有相应的教程,修改一个 Makefile 即可完成各平台的编译,在 C# 侧也提供了 API 来初始化这些库。总之,xLua 的原则是“授之以渔”。
xLua 的灵感来源
xLua 立项之初,对当时能找到的所有方案进行了考察,并分析了各方案的优劣,确定了第一个版本的特性,大体是在 NLua 的基础上加上代码生成。NLua 的作者也是 LuaInterface 的作者,NLua 可以看作是 LuaInterface 的升级版,而第一版 uLua 是 LuaInterface 的 Unity 移植版本,并非原创。
在开发过程中,xLua 参考了 cstolua 的实现(当时还未使用 uLua 的名称),但认为其通过硬编码字符串拼接的方式维护性较差,因此采用了模板的方式。事实证明,这一决策是正确的,后续生成代码的调整较为简单,对性能调优有很大帮助。
经过十多个版本的迭代和优化,NLua 的影子在 xLua 中已经比较淡了(NLua 仅支持反射,而 xLua 的反射在 2.1.0 版本已经完全重写),仅保留了 C# 引用类型对象在 Lua 中的表达思路。此外,当遇到需要进行较大调整的 bug 时,开发团队会参考同类插件的解决方案,选择更适合的修改方式。
xLua 背后的研发与团队
xLua 目前已经迭代了十多个版本,从第一个项目开始,平均每月发布一个版本。研发团队目前有一名全职开发人员。测试工作使用公有资源,非常规范:拥有一套不断补充的功能自动化用例,建立了性能测试基线,确保不会因功能迭代而影响性能。此外,还有专门的客户端兼容性测试实验室,对于中版本号以上的变动,会提交给实验室针对 top 100 的机型进行兼容性测试。
在 Lua 和 LuaJIT 的更新跟进方面,LuaJIT 的变动较小。自 2011 年首次使用 LuaJIT 以来,它一直支持 Lua 5.1 版本,期间主要进行了一些 bug 修复、性能优化和新平台支持等工作,开发团队需要做的事情相对较少。而 Lua 中版本之间的差别较大,但中版本的变动并不频繁,从 Lua 5.1 到 5.2 用了 6 年,从 5.2 到 5.3 用了 3 年,Lua 5.3 于 2015 年初发布。开发团队认为,下一次中版本的变动可能需要较长时间,甚至可能超过从 5.1 到 5.2 的时间跨度(有人认为 Lua 5.2 只是一个过渡版本)。小版本通常只是修复一些 bug,待稳定后直接升级即可,无需进行大量工作。目前,xLua 使用的是 Lua 的最新版本 5.3.3。
聊聊 C#,谈谈 Lua
C# 的特点
C# 在开发效率和运行效率之间取得了较好的平衡,语言特性也比较全面,是一门非常优秀的编程语言。但在 Unity3D 中使用时,存在一些缺憾:
- Mono 版本问题:Unity3D 中使用的 Mono 版本较低,存在一些古老的 bug,例如著名的 foreach 性能问题在多个版本中都未得到解决,同时也不支持一些新的特性,如 await。
- 热更新限制:在手机平台的 iOS 系统中,不允许应用下载 Native Code 运行和使用 JIT,这直接限制了 Mono 应用的热更新功能。如果 Mono 虚拟机能够像 LuaJIT 那样,在 JIT 不可用时采用 interpret 模式,那么可能就不需要 Lua 或其他热更新方案了。
Lua 的特点
Lua 被誉为“游戏脚本之王”,在游戏领域应用广泛。它在设计之初就考虑到了嵌入式领域的需求,具有以下特点:
- 体积小、资源占用少:相对于其提供的特性,Lua 的体积非常小,启动一个 VM 占用的资源也不多,性能在脚本语言中表现出色。
- 支持热更新:Lua 支持解析执行,从而支持热更新。免编译的特性对开发效率的提升有很大帮助,特别是对于大型项目。
- 动态类型:Lua 的动态类型具有两面性。优点是没有编译期的类型检查,在快速开发方面具有优势,尤其适用于需求变化频繁的游戏领域;缺点是要开发出健壮的软件需要进行大量的测试,并且由于需要在运行期进行类型检查,性能会比静态类型语言低。
- 协程支持:Lua 具有语言级的协程(Coroutine)支持,比 Unity3D 基于 Generator 模拟的协程要好很多,对于复杂异步业务逻辑的编写非常有帮助。xLua 的配套例子中有相关的范例(如果 Unity3D 的 Mono 版本升级到支持 await,将是更理想的异步方案)。
C# 和 Lua 的配合
关于 C# 和 Lua 之间的配合方式,不同的人可能有不同的看法,但至少有一点是明确的:对于需求变化较大、预计可能需要热更新的部分,建议使用 Lua 进行开发。当然,也可以尝试最新的开发模式,即全 C# 开发,使用 Lua 修复 bug。