开源手游热更新方案 Unity3D下的Lua编程
xLua 是 Unity3D 下的 Lua 编程解决方案。自 2016 年初推广以来,已应用于十多款腾讯自研游戏,凭借良好的性能、易用性和扩展性广受好评。目前,腾讯已将 xLua 开源到 GitHub。
2016 年 12 月末,xLua 实现新突破:全平台支持用 Lua 修复 C# 代码 bug。
现有 Unity 下 Lua 热更新方案的不足
目前 Unity 下的 Lua 热更新方案大多要求需热更新的部分从一开始就用 Lua 语言实现,存在以下不足:
- 接入成本高:部分项目已用 C# 开发完成,若要接入热更新,需将需要热更的部分用 Lua 重新实现。
- 开发难度大:即便项目一开始就接入 Lua 热更新,同时使用两种语言开发的难度较大。
- 性能欠佳:Lua 的性能不如 C#。
xLua 热补丁技术优势
xLua 热补丁技术支持在运行时将一个 C# 实现(函数、操作符、属性、事件或整个类)替换为 Lua 实现,具有以下优势:
- 开发便捷:平时使用 C# 进行开发。
- 性能卓越:运行时采用 C#,性能远超 Lua。
- 修复灵活:若发现 bug,可下发 Lua 脚本进行修复;下次整体更新时,可将 Lua 实现换回正确的 C# 实现,甚至能在不重启游戏的情况下完成更新。
该新特性已在 iOS、Android、Windows 和 Mac 平台测试通过,目前正在进行一些易用性优化。
带着“腾讯开源的 xLua 究竟是怎样的技术?它为何如此设计?xLua 的性能如何?”等问题,InfoQ 对其作者进行了采访并整理成文。
嘉宾简介
车雄生,2005 年毕业,曾在华为工作 6 年,随后先后在两家游戏创业公司任职数年,2015 年进入腾讯互娱公共组件中心,目前专注于一些游戏公共组件的开发。
技术背景
腾讯自研手游中,多数使用 Unity 3D 游戏引擎,少数使用 Cocos2d。
xLua 插件于 2015 年 3 月完成第一个版本,但当时项目组对热更新的意识尚不普遍,需求不强烈,开发资源被调至更紧急的项目。直到 2015 年底,xLua 正式集成到腾讯的 Apollo 手游开发框架,迎来第一个应用项目。截至目前,已知应用 xLua 的项目有十多个,其中不乏重量级 IP 或按星级标准打造的产品。
在 xLua 之前,针对 iOS 无法热更新的问题,部分项目使用 uLua、sLua,也有项目使用自研的脚本语言,但当时进行热更新的项目较少。
热更新流程
手游的热更新流程较为简单,启动时检测是否有新版本文件,若有则下载并覆盖老文件,然后启动游戏。
然而,若下载的文件为 Unity 原生的代码逻辑,无论是以前的 Mono AOT 还是后来的 il2cpp,都会被编译成 native code,在 iOS 下无法运行。解决办法是不使用 native code 和 JIT,采用解析执行的方式。包括 xLua 在内的所有热更新支持方案均通过“解析执行”实现代码逻辑热更新。
xLua 入门示例
三行代码运行 Lua 脚本
一个完整的示例仅需 3 行代码:下载 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 分配,既优雅又高效。更详细的内容可查看 XLua\Doc 下的文档。
xLua 全局特性
易用性:编辑器下无需生成代码支持所有特性
xLua 的易用性不仅体现在编程方面,还体现在各个细节以及团队协作工作流中。xLua 仅有两个菜单选项,分别是生成代码和清除生成代码。在菜单之外,仅需在构建手机版本前执行一次“Generate Code”即可(也可通过 API 将其集成到项目的自动化打包流程中)。
这一特色功能的实现是因为部分项目反馈,“生成代码”对于策划和美术人员来说较为复杂,即便多次教导仍容易遗忘;还有大型项目表示,由于代码量较大,每次生成代码后,Unity 3D 需要较长时间进行转换。
扩展性:授之以鱼,不如授之以渔
在开发过程中,常需使用多种工具,如使用 PB 与后台交互、解析 JSON 格式的配置文件等。虽然可在 C# 中找到相应的库,并通过 xLua 使用这些库,但效率不高,最好能有相应的 Lua 库。
部分方案直接集成常用的 Lua 库,但存在一些问题:这些库不一定会被使用,却会增大安装包体积;集成的库可能不符合项目习惯,例如 JSON 解析,不同开发者偏好不同的库;对于某些项目,集成的库可能不够,仍需自行添加。
腾讯团队的设计原则是“授之以鱼,不如授之以渔”,因此 xLua 具有以下特点:
- 自定义库添加:提供接口和教程,开发者可在不修改 xLua 代码的情况下,根据个人喜好添加库。
- 跨平台编译:通过 cmake 实现跨平台编译,可选择与 xLua 一起编译,修改一个 makefile 文件即可完成各平台的编译。
- 二次开发支持:xLua 的生成引擎支持二次开发,可编写生成插件,生成所需的代码和配置。
性能保证
游戏性能至关重要,任何模块的变化都应尽量不降低甚至优化游戏整体性能。xLua 的设计原则是在保证运行效率的前提下,尽可能提高开发效率。
在性能优化方面,有几个关键版本:
- 1.0.0 版本(2015 年 3 月发布):该版本将 delegate 和 interface 作为 C# 访问 Lua 的主要方式,从接口层面避免了 boxing、unboxing 和 GC 分配,为后续发展奠定了良好基础。部分从其他 Lua 插件切换到 xLua 的开发者,一开始习惯使用 LuaFunction.Call 调用 Lua 代码(xLua 保留了该接口,适用于对性能要求不高的场景),后期需要逐个修改。
- 2.0.0 版本(2016 年 3 月发布):主要目标是性能优化。当时有一个对性能要求极高的项目,甚至认为 C# 性能都无法满足需求,打算使用 C++ 编写战斗系统。该版本将虚拟机切换到 LuaJIT,加入了 lazyload 技术,对逐行语句进行优化,甚至在关键地方使用自定义的容器替代 C# 提供的容器(实测比 Dictionary 性能高 4 倍)。最终,该项目在选型测试中选择了 xLua。
- 2.1.0 版本(2016 年 7 月发布):主要目标是进行 GC 优化。重写了反射机制,将反射调用的 GC 分配减少到原来的几分之一,性能提高了约 3 倍。设计了全新的复杂值类型支持方案,支持更多类型(只要 struct 的字段都是值类型即可),包括用户自定义的 struct(其他方案不支持),且更节省内存(以 Vector3 为例,内存占用仅为其他方案的 30%)。不过,在调用 Vector3 上的一些方法时,性能可能不如 uLua 和 sLua,因为后两者将 Vector3 用 Lua 重新实现,对于这类耗时不大的运算,在 Lua 中直接进行比处理 Lua 和 C# 的适配成本更低,但这种差距仅存在于 uLua 和 sLua 完全重新实现的类中。
性能优化是一个持续的过程,开发团队会不断尝试新的优化思路,进行测试,若有性能提升则将其纳入版本;同时建立性能基线,防止新功能的加入或 bug 的修复影响性能。
xLua 内置 Lua 代码 profiler,支持真机调试。目前,lua profiler 是一个小工具,未提供图形化界面,典型的报告如下:
网上也有类似的工具,xLua 的优势在于对 C# 函数的支持以及在 LuaJIT 下更为准确。真机调试支持与其他 Lua 插件类似,即预先编译 ZeroBraneStudio 调试所需的 luasocket 库。
技术实现细节
泛型支持
泛型类型除运行时动态实例化外均支持,而运行时动态实例化需要 JIT 支持,在 iOS 平台不可行。例如,若已为 Dictionary<int, string> 配置生成代码,则该类型可正常使用;但如果新更新的 Lua 代码需要使用 Dictionary<int, double>,且该类型之前未生成代码,同时在 C# 中也未使用过,则不支持。静态实例化的泛型在处理上与非泛型类型相同。
委托事件封装
委托封装是根据委托的接口生成一段操作 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# 主动使用 delegate 引用 Lua 函数,使用 delegate 代替类似
object[] Call(params object[] args)的接口调用 Lua,可避免值类型传递时的 boxing/unboxing 以及参数数组和返回值数组的 GC 分配。 - 高阶函数支持:支持返回 delegate 的 delegate,可对应 Lua 的高阶函数。
作为该技术的延伸,xLua 支持使用 C# interface 引用 Lua table,此特性与一些 IOC 框架配合,可实现 C# 和 Lua 之间的无感知交互(模块间通过 interface 耦合,由框架进行组装)。
无缝支持生成代码及反射
生成代码是各大主流方案的标配,反射在部分方案中明确不支持,但从项目反馈来看,反射也至关重要。部分项目代码量接近苹果 80M Text 段的限制,代码量大小关乎项目能否发布。反射方式的性能虽不如生成代码,但对安装包的影响较小。
xLua 中“无缝”支持生成代码及反射有两层含义:
- 使用一致性:两者在支持的特性和特性的使用方式上一致,在两种方式之间切换时,业务逻辑代码无需修改,仅需修改配置。
- 配合无缝性:两者可无缝配合,例如在一个继承链上,任意一个类可选择使用生成代码或反射。若子类选择生成代码,父类因不常用选择反射,仍可在子类对象上调用父类的方法。
对于 il2cpp 的 stripping,xLua 也进行了考虑。若为一个类配置了 ReflectionUse,会自动生成 Unity 的 link.xml 配置文件,将该类型列为不剪裁。
其他 Lua 插件对比
uLua
uLua 应用项目最多,由于开源较早,名气较大。但存在版本前后兼容问题:
- 版本更迭混乱:uLua 最早是 LuaInterface 开源库的 Unity 移植版本,2015 年初更换为 cs2lua,2016 年初又更换为 tolua c#。从 API 角度看,可认为是三个不同的产品,升级困难,且每次更换后,之前的版本不再维护,给项目带来很大困扰。
- 接口问题:uLua 的第一个版本纯反射,实用性差,已逐渐退出市场。现存应用多使用后两个版本。cstolua 版本接口混乱,保留了第一版 uLua 接口的同时,又新增了一套接口,两套接口之间不正交,也非后者完全替代前者,让人无所适从。tolua c# 版本解决了接口问题,但废除了反射特性(老接口)。总体而言,uLua 正朝着好的方向发展。
sLua
sLua 的代码质量比 cstolua 好很多,部分支持反射。根据测试用例,其整体性能略低于 tolua c#,且代码质量的优势已不明显。
C#light
C#light 存在两个主要不足:
- 性能不佳:按其实现原理,性能难以满足手机应用的需求。
- 代码配合复杂:由于不完全支持 C#,本质上是另一种名为 C#light 的语言,与 C# 代码配合的复杂度甚至高于 C# 和 Lua 的配合。
事实证明,C#light 已基本退出市场,可忽略不计。
LSharp
LSharp 是 C#light 作者的后续作品,从 il 层面执行,有望改善 C#light 的问题,但后续停止维护,无进一步发展。
相比之下,腾讯在设计 xLua 时,实现的功能更全面,体现在对 C# 特性的支持更全,对 Lua 虚拟机版本的支持更广泛;更易用,例如在编辑器下无需生成代码;性能也不逊色于其他插件。
有人可能认为 xLua 未集成 PB、JSON、SQLite 等功能,但熟悉 Lua 的开发者知道,这些功能只是将现成的 Lua 扩展编译进去,并非真正意义上的功能实现。预集成的好处是方便,但缺乏选择,未使用的库会占用空间,使用的库也可能不符合开发者的喜好。
xLua 的 Lua 库基于 cmake 编译,添加这些库的门槛较低,有教程指导,修改一个 Makefile 即可完成各平台的编译。在 C# 端也提供了 API 初始化这些库。总之,xLua 的原则是“授之以渔”。
xLua 的灵感来源
xLua 立项之初,考察了当时所有可找到的方案,分析各方案的优劣,确定了第一个版本的特性,大体是在 NLua 的基础上增加代码生成功能。NLua 的作者也是 LuaInterface 的作者,可认为 NLua 是 LuaInterface 的升级版,而第一版 uLua 是 LuaInterface 的 Unity 移植版本,并非原创。
在生成代码方面,xLua 参考了 cstolua 的实现,但认为其通过硬编码字符串拼接的方式维护性不佳,因此采用模版实现。实践证明,这一决策是正确的,后续生成代码的调整较为简单,对性能调优有很大帮助。
经过十多个版本的迭代和优化,NLua 的影子逐渐淡化(NLua 仅支持反射,而 xLua 的反射在 2.1.0 版本已完全重写),仅保留了 C# 引用类型对象在 Lua 中的表达思路。
此外,在遇到需要较大调整的 bug 时,xLua 团队会参考同类插件的解决方案,选择更适合的修改方案。
xLua 背后的研发与团队
xLua 目前已迭代十多个版本,从第一个项目开始,平均每月发布一个版本。
研发团队有一名全职开发人员。测试使用腾讯互娱的公有资源,流程规范:拥有一套不断补充的功能自动化用例,建立了性能测试基线,确保功能迭代不会影响性能。腾讯互娱有专门的客户端兼容性测试实验室,对于中版本号以上的变动,会提交给实验室针对 top 100 的机型进行兼容性测试。
关于 Lua 和 LuaJIT 的更新跟进,LuaJIT 的变动较小,作者第一次使用 LuaJIT 是在 2011 年,当时支持到 Lua 5.1,目前仍为 Lua 5.1,期间主要进行 bug 修复、性能优化和新平台支持等工作,所需工作量不大。Lua 中版本之间的差别较大,但中版本变动并不频繁,从 5.1 到 5.2 用了 6 年,从 5.2 到 5.3 用了 3 年,5.3 于 2015 年初发布,预计下一次中版本变动的时间间隔较长。小版本主要进行 bug 修复,稳定后可直接升级。目前 xLua 使用的是 Lua 的最新版本 5.3.3。
C# 与 Lua 的对比及配合
C# 特点
C# 在开发效率和运行效率之间取得了较好的平衡,语言特性丰富,是一门优秀的编程语言。但在 Unity 3D 中,C# 的 Mono 版本较低,存在一些古老的 bug,如著名的 foreach 性能问题,多个版本都未解决,且不支持新特性,如 await。此外,在手机平台上,iOS 不允许应用下载 native code 运行和 JIT,这限制了 Mono 应用的热更新。若 Mono 虚拟机能够像 LuaJIT 一样,在 JIT 不可用时采用 interpret 模式,可能就无需 Lua 或其他热更新方案。
Lua 特点
Lua 被誉为“游戏脚本之王”,在游戏领域应用广泛。其设计之初就考虑到嵌入式领域,具有体积小、启动 vm 占用资源少、性能在脚本语言中表现出色等特点。
与 C# 相比,Lua 支持解析执行,从而支持热更新,免编译对开发效率的提升较大,尤其适用于大型项目。Lua 的动态类型具有优势和劣势:优势在于无需编译期的类型检查,在需求频繁变化的游戏领域,快速开发具有优势;劣势在于需要大量测试保证软件的健壮性,且由于运行期检查,性能低于静态类型语言。
Lua 的一大特色是支持语言级的协程(coroutine),比 Unity3D 基于 generator 模拟的协程更优秀,对复杂异步业务逻辑的编写有很大帮助,xLua 的配套例子中有相关范例(若 Unity3D 的 Mono 版本升级到支持 await,将是更理想的异步方案)。
C# 与 Lua 的配合
对于 C# 和 Lua 的配合,不同开发者有不同的看法,但至少可以确定的是:对于需求变化大、预计可能需要热更新的部分,可使用 Lua 开发。当然,也可尝试最新的开发模式,即全 C# 开发,使用 Lua 修复 bug。