Unity手游开发札记——我们是如何使用Lua来开发大型游戏的?(上)

粘贴过来的原因,代码比较乱,知乎原文传送门:https://zhuanlan.zhihu.com/p/34660501

0. 照旧的碎碎念

转眼间已经三月了,2月份的博客因为过年的懒惰和开年之后的忙碌而没有写……第二个月就打破了去年总结时对于2018年的愿望,真是羞耻呢……

年后在准备新的测试版本,断断续续做了一些优化,更多的精力放在团队的绩效评估、沟通这样偏管理的事物上,说实话技术上可以聊的东西不多。近期看到UWA群里和问答上聊Lua的使用之类的话题比较多,也在看ET这套完全基于C#进行游戏开发的框架中提到——

“在发布的时候,定义预编译指令ILRuntime就可以无缝切换成使用ILRuntime加载热更新动态库。这样开发起来及其方便,再也不用使用狗屎lua了。”

Lua是门小而精的语言,它的确很多地方像狗屎一样……比如只提供table这样一种数据结构,而且基于数组域和哈希域的封装让#这样的操作符号可以坑死不少新手甚至老司机,一个哈希表要取长度还要自己封装一个遍历函数等等诸多不便的地方。

我们项目深度使用了Lua,原因其实在1年多前的一篇文章里已经有聊过——《Unity手游开发札记——Lua语言集成》,有兴趣的朋友可以再去看看。那篇文章也聊了最初对于一些框架上的改造,而今天这篇文章我想聊聊我们团队是如何使用Lua来开发大型游戏的。一方面让大家看看我们是如何把Lua这个“狗屎”,捏成巧克力的形状甚至做出一点点巧克力的味道;另外一方面,也想为纠结是否使用Lua来做Unity的代码更新方案的朋友提供一些做决策的参考。

1. 我的观点

在聊一些更加具体的经验之前,我想先把我自己的观点抛出来,这也是我花时间写这篇文章最想表达的两点内容:

使用Lua这样的脚本语言,目的不仅仅在于让代码可以被Patch更新,而且让游戏逻辑可以被Hotfix更新

使用Lua这样的脚本语言,调试bug的效率并不低,甚至可能比C#这样的静态语言还要高

先聊下第一点,我看很多朋友在聊的时候不断提到客户端的热更新,可能每个人或者公司有自己不同的叫法,在我的观点里,通过在游戏启动的时候下载新的资源文件替换之前的文件,让游戏不需要重新安装就可以更新内容的方式叫做“Patch更新”,而不是热更新(Hotfix)

在我的理解中,热更新(Hotfix)的概念从服务端来讲,是指不停止服务的情况下进行的更新,此时如果玩家正在进行游戏,玩家是无感知的,最多感觉到一点顿卡之类的。而对于客户端来说,玩家正在进行游戏,这时候如果需要玩家退出到登陆界面重新下载Patch内容再进入游戏,打断了玩家的游戏体验,根本就不能称之为“热”更新,虽不至于是冷更,最多是“温”更新……

脚本语言让游戏逻辑和数据可以做到玩家无感知的情况下进行错误的修复,比如有一个trace导致了玩家某个系统的界面打开后内容显示错误,Hotfix应用之后,玩家下次打开这个界面的时候,trace就已经被修复了,内容显示正确,而玩家完全没有任何更新的感知,这种才能叫做真正的客户端热更新

第二点,有些朋友认为脚本语言只能通过打log进行调试,是一件非常痛苦的事情。首先,Python和Lua这样的脚本语言都有各自的调试工具,可能没有那么便利,但基本功能是够用的;其次,在移动网络游戏的开发中,有网络因素、异步逻辑、设备上运行等存在的情况下,有些bug是很难单步调试来进行重现和分析的,这种情况下log调试必不可少,而且我认为通过分析代码逻辑精准地添加log快速定位问题并修复问题的能力,是每一个程序员应该掌握的基本技巧;最后,结合动态语言的reload功能,即使是使用log调试,也有很高效的方法,在加上内存查看工具,可以做到很高效的bug定位和修复。

这里只是先阐述一下我个人的观点,下面我将根据实际的项目经验来聊聊我们使用Lua的一些方面。

2. 让Lua代码更好写

Lua自身提供的功能很精简,精简也意味着它在很多方面会有些“残疾”……这会导致团队的开发效率比较低,因此必须通过一些基础内容的构建来让团队更好地使用Lua语言。需要注意的是,天下没有免费的午餐,更快的开发效率有很多时候意味着更慢的运行效率

2.1 全局变量访问控制

Lua的设计中有一个特点就是:

当你不在变量前使用local关键字的时候,这个变量会被放在_G这个全局表中。

我在最初学习Lua的时候也很难理解这个设计,这和之前我使用的编程语言中作用域的概念是相违背的,但是当你理解函数的env概念之后,就很容易理解为什么在Lua语言中,这样的设计反而是最为合理和自洽的。

对于Lua语言自身来说,这种合理和自洽是美的,但是它会给使用的人带来困惑和难以排查的bug,因为你非常可能因为遗漏的local声明,导致污染了_G,甚至修改到了了你不想修改的变量,或者你的某个变量被别处的代码不小心修改了。因此在我们的工程中,去掉了Lua的这一特性,当期望使用一个局部变量但是没有写local变量的时候,使用error报出错误,所有的全局变量必须显示地进行声明

实现方法很简单,重写_G的__index方法和__newindex方法:

-- Global.lua-- 辅助记录全局变量的名称是否被使用过local_GlobalNames={}localfunction__innerDeclare(name,defaultValue)ifnotrawget(_G,name)thenrawset(_G,name,defaultValueorfalse)elseprint("[Warning] The global variable "..name.." is already declared!")end_GlobalNames[name]=truereturn_G[name]endlocalfunction__innerDeclareIndex(tbl,key)ifnot_GlobalNames[key]thenerror("Attempt to access an undeclared global variable : "..key,2)endreturnnilendlocalfunction__innerDeclareNewindex(tbl,key,value)ifnot_GlobalNames[key]thenerror("Attempt to write an undeclared global variable : "..key,2)elserawset(tbl,key,value)endendlocalfunction__GLDeclare(name,defaultValue)localok,ret=pcall(__innerDeclare,name,defaultValue)ifnotokthen--        LogError(debug.traceback(res, 2))returnnilelsereturnretendendlocalfunction__isGLDeclared(name)if_GlobalNames[name]orrawget(_G,name)thenreturntrueelsereturnfalseendend-- Set "GLDeclare" into global.if(not__isGLDeclared("GLDeclare"))or(notGLDeclare)then__GLDeclare("GLDeclare",__GLDeclare)end-- Set "IsGLDeclared" into global.if(not__isGLDeclared("IsGLDeclared"))or(notIsGLDeclared)then__GLDeclare("IsGLDeclared",__isGLDeclared)endsetmetatable(_G,{__index=function(tbl,key)localok,res=pcall(__innerDeclareIndex,tbl,key)ifnotokthenlogerror(debug.traceback(res,2))endreturnnilend,__newindex=function(tbl,key,value)localok,res=pcall(__innerDeclareNewindex,tbl,key,value)ifnotokthenlogerror(debug.traceback(res,2))endend})return__GLDeclare

我相信这种强制报错的设定可以帮助很多刚刚上手Lua的朋友避免一些错误。上述的代码也是参考网上的开源工程,需要用的朋友可以直接拿去。

2.2 Class的设计

虽然面向对象的设计在很多帖子的讨论中已经过时的,面向切面编程等等新概念不断被提出,但是对于一个需要团队协作的游戏项目来说,面向对象的设计依然是目前最为常用的逻辑实现方式。Lua自身没有Class的概念,提供了metatable来做继承,但很弱。我们在项目最初的时候就构建了Class的机制,来方便代码的编写。虽然和原生支持Class的Python和C#这样的语言相比易用性和功能上还都有差距,但是基本够用了。

直接提供核心代码如下:

-- Class.lua-- 类定义,不支持多重继承localGLDeclare=require"Framework/Global"-- 所有定义过的类列表,key为类的类型名称,value为对应的虚表local__ClassTypeList={}-- 类的继承关系数据,用于处理Hotfix等逻辑。-- 数据形式:key为ClassType,value为继承自它的子类列表。local__InheritRelationship={}localfunction__createSingletonClass(cls,...)ifcls._instance==nilthencls._instance=cls.new(...)endreturncls._instanceendlocalTypeNames={}-- 参数含义为:-- typeName: 字符串形式的类型名称-- superType: 父类的类型,可以为nil-- isSingleton: 是否是单例模式的类localfunction__Class(typeName,superType,isSingleton)-- 该table为类定义对应的表localclassType={__IsClass=true}-- 类型名称classType.typeName=typeNameifTypeNames[typeName]~=nilthenlogerror("The class name is used already!!!"..typeName)elseTypeNames[typeName]=classTypeend-- 父类类型classType.superType=superType-- 在Class身上记录继承关系-- Todo:在修改了继承关系的情况下,Reload和Hotfix可能会存在问题classType._inheritsCount=0ifsuperType~=nilthenlocalcache={}localcounter=1localcurClass=superTypewhilecurClassdocache[counter]=curClasscounter=counter+1curClass=curClass.superTypeendclassType._classInherits=cacheclassType._inheritsCount=counterendclassType._IsSingleton=isSingletonorfalse-- 记录类的继承关系ifsuperTypethenif__InheritRelationship[superType]==nilthen__InheritRelationship[superType]={}endtable.insert(__InheritRelationship[superType],classType)else__InheritRelationship[classType]={}endclassType.ctor=falseclassType.dtor=falselocalfunctionobjToString(self)ifnotself.__instanceNamethenlocalstr=tostring(self)local_,_,addr=string.find(str,"table%s*:%s*(0?[xX]?%x+)")self.__instanceName=string.format("Class %s : %s",classType.typeName,addr)endreturnself.__instanceNameendlocalfunctionobjGetClass(self)returnclassTypeendlocalfunctionobjGetType(self)returnclassType.typeNameend-- 创建对象的方法。classType.new=function(...)-- 该table为对象对应的表localobj={}-- 对象的toString方法,输出结果为类型名称 内存地址。obj.toString=objToString-- 获取类obj.getClass=objGetClass-- 获取类型名称的方法。obj.getType=objGetType-- 递归的构造过程localcreateObj=function(class,object,...)-- 优化递归过程中的函数调用ifclass.superType~=nilthenfori=class._inheritsCount-1,1,-1dolocalcurClass=class._classInherits[i]ifcurClass.ctorthencurClass.ctor(object,...)endendendifclass.ctorthenclass.ctor(object,...)endend-- 设置对象表的metatable为虚表的索引内容setmetatable(obj,{__index=__ClassTypeList[classType]})-- 构造对象createObj(classType,obj,...)returnobjend-- 类的toString方法。classType.toString=function(self)returnself.typeNameendifclassType._IsSingletonthenclassType.GetInstance=function(...)return__createSingletonClass(classType,...)endendifsuperTypethen-- 有父类存在时,设置类身上的super属性classType.super=setmetatable({},{__index=function(tbl,key)localfunc=__ClassTypeList[superType][key]if"function"==type(func)then-- 缓存查找结果-- Todo,要考虑reload的影响tbl[key]=funcreturnfuncelseerror("Accessing super class field are not allowed!")endend})end-- 虚表对象。localvtbl={}__ClassTypeList[classType]=vtbl-- 类的metatable设置,属性写入虚表,setmetatable(classType,{__index=function(tbl,key)returnvtbl[key]end,__newindex=function(tbl,key,value)vtbl[key]=valueend,-- 让类可以通过调用的方式构造。__call=function(self,...)-- 处理单例的模式ifclassType._IsSingleton==truethenreturn__createSingletonClass(classType,...)elsereturnclassType.new(...)endend})-- 如果有父类存在,则设置虚表的metatable,属性从父类身上取-- 注意,此处实现了多层父类递归调用检索的功能,因为取到的父类也是一个修改过metatable的对象。ifsuperTypethensetmetatable(vtbl,{__index=function(tbl,key)localret=__ClassTypeList[superType][key]-- Todo 缓存提高了效率,但是要考虑reload时的处理。vtbl[key]=retreturnretend})endreturnclassTypeend-- 判断一个类是否是另外一个类的子类localfunction__isSubClassOf(cls,otherCls)returntype(otherCls)=="table"andtype(cls.superType)=="table"and(cls.superType==otherClsor__isSubClassOf(cls.superType,otherCls))endif(notIsGLDeclared("isSubClassOf"))or(notisSubClassOf)thenGLDeclare("isSubClassOf",__isSubClassOf)end-- 判断一个对象是否是一个类的实例(包含子类)localfunction__isInstanceOf(obj,cls)localobjClass=obj:getClass()returnobjClass~=nilandtype(cls)=='table'and(cls==objClassor__isSubClassOf(objClass,cls))endif(notIsGLDeclared("isInstanceOf"))or(notisInstanceOf)thenGLDeclare("isInstanceOf",__isInstanceOf)endif(notIsGLDeclared("Class"))or(notClass)thenGLDeclare("Class",__Class)endreturn__Class

这个Lua的Class实现也有参考网上的开源代码,做了一些自己的改进,主要功能有:

只支持单继承;

原生支持单例,但注意,对于不需要继承的单例,比如一些常用的Manager,其实不推荐使用Class的方式,而是直接使用Lua的Table的形式来做效率更高;

支持super来调用父类的方法,但是调用的时候必须使用ClassName.super(self, ...)这样的方式来显示地把self传递给父类,否则父类拿到的self会是错误的对象;

支持构造函数ctor,但是这在某些想自动控制构造的情况下也是一把双刃剑……

对于多重集成没有提供原生支持,本来是可以的,但是多重集成有自身的问题,我们提供了一种基于Mixin 的思路来处理,类似于Interface,核心目标功能是合并一些函数到一个Class中,提供一些大类的模块拆分,避免出现一个几千甚至上万行代码的类文件。(之前端游项目中,几万行的py文件都有遇到……当时eclipse这样的IDE打开这样的py文件都要好久……)

-- 将一个table中所有的属性和方法合并到一个class中,用于处理一个类比较大的设计-- 注意,合并的方法的reload需要单独处理localfunction__MixinClass(cls,mixin)assert(type(mixin)=='table',"mixin must be a table")forname,attrinpairs(mixin)doifcls[name]==nilthencls[name]=attrelse-- 属性名称相同不覆盖而是给出警告。print(string.format("[WARNING] The attribute name %s is already in the Class %s!",name,cls.toString()))endendendif(notIsGLDeclared("MixinClass"))or(notMixinClass)thenGLDeclare("MixinClass",__MixinClass)end

2.3 常用函数库的补充

这一部分是自己来弥补Lua语言函数库不丰富的问题,当然也要看项目需求,我们引入的主要有:

table相关的一些操作函数,包括长度获取、dump为字符串、深浅拷贝、深度对比、根据值获得索引等等;

json库;

int64库(用的是Lua 5.1);

bit操作库;

Lua socket库;

……

这部分跟项目具体需求相关,就不一一列举和给出代码了。

2.4 IDE

IDE的部分也只说几句,我们团队目前用的比较多的是Sublime Text 3和VS Code,最初我个人还在使用VS+插件的形式,后来也转向了VS Code阵营。

个人体验VS Code还是比较不错的,加上一些自动补全和基于LuaChecker的语法检查插件,基本能够保证避免开发中一些很蠢的bug。

如果需要,可以自己导出一下Unity的接口为一个Lua的文件,提升自动补全的体验,比如我们最初导出的一份U3DAPI.lua的部分内容截取示例如下:

--- --- 全名:UnityEngine.Camera.depthTextureMode [读写] --- 返回值 : DepthTextureMode--- --- Camera.depthTextureMode=function()end--- --- 全名:UnityEngine.Camera.clearStencilAfterLightingPass [读写] --- 返回值 : Boolean--- --- Camera.clearStencilAfterLightingPass=function()end--- --- 全名:UnityEngine.Camera.commandBufferCount [读写] --- 返回值 : Int32--- --- Camera.commandBufferCount=function()end

2.5 培训和分享

我们团队的同学大都有多年使用Python的经验,但是对于Lua还是需要上手时间,所以在最初的时候就组织了程序内部的Lua培训和分享,把比如对于table和string使用的坑、元表、Lua的GC基本原理、错误处理等等方面在团队内部进行了统一的学习和讨论,整体的收获还是比较大的。在开发过程中发现的代码上的问题,也及时在群内进行讨论,这些都逐步提高了整个团队使用Lua进行游戏开发的能力和效率。

2.6 小结

Lua语言自身的确是有很多易用性上的问题,前文提到的库不够丰富之类的,通过在项目初期添加一些基础的结构和库,再加上一些提前规避错误的强制手段,可以一定程度上改善易用性的问题。然而,即使到现在,使用Lua有一年多的时候,我们团队中还是偶尔有同学出现.和:用错导致bug的现象。用好一门语言总是需要一个不断踩坑不断成长的过程,C#也好,Python也好,Lua也好,都需要不断地学习和改进,希望我们的一些经验和教训可以帮助刚刚上手Lua的团队提前规避一些坑,也期望更多已经熟练使用Lua的团队可以分享你们经验和方法~

总是,Lua这门小而精的语言,在提供了脚本语言中几乎最快的运行效率的同时,也有着开发效率方面的各种问题,这些问题需要整个团队的力量去弥补和改进。 我相信,经过积淀的团队,在使用Lua进行大型游戏的开发时,可以达到不差于任何其他语言的开发速度。

[未完待续]

2018年3月18日凌晨于杭州家中

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,126评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,254评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,445评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,185评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,178评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,970评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,276评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,927评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,400评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,883评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,997评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,646评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,213评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,204评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,423评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,423评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,722评论 2 345

推荐阅读更多精彩内容