粘貼過來的原因,代碼比較亂,知乎原文傳送門:https://zhuanlan.zhihu.com/p/34660501
0. 照舊的碎碎念
轉(zhuǎn)眼間已經(jīng)三月了,2月份的博客因?yàn)檫^年的懶惰和開年之后的忙碌而沒有寫……第二個(gè)月就打破了去年總結(jié)時(shí)對(duì)于2018年的愿望,真是羞恥呢……
年后在準(zhǔn)備新的測(cè)試版本,斷斷續(xù)續(xù)做了一些優(yōu)化,更多的精力放在團(tuán)隊(duì)的績(jī)效評(píng)估、溝通這樣偏管理的事物上,說實(shí)話技術(shù)上可以聊的東西不多。近期看到UWA群里和問答上聊Lua的使用之類的話題比較多,也在看ET這套完全基于C#進(jìn)行游戲開發(fā)的框架中提到——
“在發(fā)布的時(shí)候,定義預(yù)編譯指令I(lǐng)LRuntime就可以無縫切換成使用ILRuntime加載熱更新動(dòng)態(tài)庫。這樣開發(fā)起來及其方便,再也不用使用狗屎lua了?!?/p>
Lua是門小而精的語言,它的確很多地方像狗屎一樣……比如只提供table這樣一種數(shù)據(jù)結(jié)構(gòu),而且基于數(shù)組域和哈希域的封裝讓#這樣的操作符號(hào)可以坑死不少新手甚至老司機(jī),一個(gè)哈希表要取長度還要自己封裝一個(gè)遍歷函數(shù)等等諸多不便的地方。
我們項(xiàng)目深度使用了Lua,原因其實(shí)在1年多前的一篇文章里已經(jīng)有聊過——《Unity手游開發(fā)札記——Lua語言集成》,有興趣的朋友可以再去看看。那篇文章也聊了最初對(duì)于一些框架上的改造,而今天這篇文章我想聊聊我們團(tuán)隊(duì)是如何使用Lua來開發(fā)大型游戲的。一方面讓大家看看我們是如何把Lua這個(gè)“狗屎”,捏成巧克力的形狀甚至做出一點(diǎn)點(diǎn)巧克力的味道;另外一方面,也想為糾結(jié)是否使用Lua來做Unity的代碼更新方案的朋友提供一些做決策的參考。
1. 我的觀點(diǎn)
在聊一些更加具體的經(jīng)驗(yàn)之前,我想先把我自己的觀點(diǎn)拋出來,這也是我花時(shí)間寫這篇文章最想表達(dá)的兩點(diǎn)內(nèi)容:
使用Lua這樣的腳本語言,目的不僅僅在于讓代碼可以被Patch更新,而且讓游戲邏輯可以被Hotfix更新。
使用Lua這樣的腳本語言,調(diào)試bug的效率并不低,甚至可能比C#這樣的靜態(tài)語言還要高。
先聊下第一點(diǎn),我看很多朋友在聊的時(shí)候不斷提到客戶端的熱更新,可能每個(gè)人或者公司有自己不同的叫法,在我的觀點(diǎn)里,通過在游戲啟動(dòng)的時(shí)候下載新的資源文件替換之前的文件,讓游戲不需要重新安裝就可以更新內(nèi)容的方式叫做“Patch更新”,而不是熱更新(Hotfix)。
在我的理解中,熱更新(Hotfix)的概念從服務(wù)端來講,是指不停止服務(wù)的情況下進(jìn)行的更新,此時(shí)如果玩家正在進(jìn)行游戲,玩家是無感知的,最多感覺到一點(diǎn)頓卡之類的。而對(duì)于客戶端來說,玩家正在進(jìn)行游戲,這時(shí)候如果需要玩家退出到登陸界面重新下載Patch內(nèi)容再進(jìn)入游戲,打斷了玩家的游戲體驗(yàn),根本就不能稱之為“熱”更新,雖不至于是冷更,最多是“溫”更新……
腳本語言讓游戲邏輯和數(shù)據(jù)可以做到玩家無感知的情況下進(jìn)行錯(cuò)誤的修復(fù),比如有一個(gè)trace導(dǎo)致了玩家某個(gè)系統(tǒng)的界面打開后內(nèi)容顯示錯(cuò)誤,Hotfix應(yīng)用之后,玩家下次打開這個(gè)界面的時(shí)候,trace就已經(jīng)被修復(fù)了,內(nèi)容顯示正確,而玩家完全沒有任何更新的感知,這種才能叫做真正的客戶端熱更新。
第二點(diǎn),有些朋友認(rèn)為腳本語言只能通過打log進(jìn)行調(diào)試,是一件非常痛苦的事情。首先,Python和Lua這樣的腳本語言都有各自的調(diào)試工具,可能沒有那么便利,但基本功能是夠用的;其次,在移動(dòng)網(wǎng)絡(luò)游戲的開發(fā)中,有網(wǎng)絡(luò)因素、異步邏輯、設(shè)備上運(yùn)行等存在的情況下,有些bug是很難單步調(diào)試來進(jìn)行重現(xiàn)和分析的,這種情況下log調(diào)試必不可少,而且我認(rèn)為通過分析代碼邏輯精準(zhǔn)地添加log快速定位問題并修復(fù)問題的能力,是每一個(gè)程序員應(yīng)該掌握的基本技巧;最后,結(jié)合動(dòng)態(tài)語言的reload功能,即使是使用log調(diào)試,也有很高效的方法,在加上內(nèi)存查看工具,可以做到很高效的bug定位和修復(fù)。
這里只是先闡述一下我個(gè)人的觀點(diǎn),下面我將根據(jù)實(shí)際的項(xiàng)目經(jīng)驗(yàn)來聊聊我們使用Lua的一些方面。
2. 讓Lua代碼更好寫
Lua自身提供的功能很精簡(jiǎn),精簡(jiǎn)也意味著它在很多方面會(huì)有些“殘疾”……這會(huì)導(dǎo)致團(tuán)隊(duì)的開發(fā)效率比較低,因此必須通過一些基礎(chǔ)內(nèi)容的構(gòu)建來讓團(tuán)隊(duì)更好地使用Lua語言。需要注意的是,天下沒有免費(fèi)的午餐,更快的開發(fā)效率有很多時(shí)候意味著更慢的運(yùn)行效率。
2.1 全局變量訪問控制
Lua的設(shè)計(jì)中有一個(gè)特點(diǎn)就是:
當(dāng)你不在變量前使用local關(guān)鍵字的時(shí)候,這個(gè)變量會(huì)被放在_G這個(gè)全局表中。
我在最初學(xué)習(xí)Lua的時(shí)候也很難理解這個(gè)設(shè)計(jì),這和之前我使用的編程語言中作用域的概念是相違背的,但是當(dāng)你理解函數(shù)的env概念之后,就很容易理解為什么在Lua語言中,這樣的設(shè)計(jì)反而是最為合理和自洽的。
對(duì)于Lua語言自身來說,這種合理和自洽是美的,但是它會(huì)給使用的人帶來困惑和難以排查的bug,因?yàn)槟惴浅?赡芤驗(yàn)檫z漏的local聲明,導(dǎo)致污染了_G,甚至修改到了了你不想修改的變量,或者你的某個(gè)變量被別處的代碼不小心修改了。因此在我們的工程中,去掉了Lua的這一特性,當(dāng)期望使用一個(gè)局部變量但是沒有寫local變量的時(shí)候,使用error報(bào)出錯(cuò)誤,所有的全局變量必須顯示地進(jìn)行聲明。
實(shí)現(xiàn)方法很簡(jiǎn)單,重寫_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
我相信這種強(qiáng)制報(bào)錯(cuò)的設(shè)定可以幫助很多剛剛上手Lua的朋友避免一些錯(cuò)誤。上述的代碼也是參考網(wǎng)上的開源工程,需要用的朋友可以直接拿去。
2.2 Class的設(shè)計(jì)
雖然面向?qū)ο蟮脑O(shè)計(jì)在很多帖子的討論中已經(jīng)過時(shí)的,面向切面編程等等新概念不斷被提出,但是對(duì)于一個(gè)需要團(tuán)隊(duì)協(xié)作的游戲項(xiàng)目來說,面向?qū)ο蟮脑O(shè)計(jì)依然是目前最為常用的邏輯實(shí)現(xiàn)方式。Lua自身沒有Class的概念,提供了metatable來做繼承,但很弱。我們?cè)陧?xiàng)目最初的時(shí)候就構(gòu)建了Class的機(jī)制,來方便代碼的編寫。雖然和原生支持Class的Python和C#這樣的語言相比易用性和功能上還都有差距,但是基本夠用了。
直接提供核心代碼如下:
-- Class.lua-- 類定義,不支持多重繼承l(wèi)ocalGLDeclare=require"Framework/Global"-- 所有定義過的類列表,key為類的類型名稱,value為對(duì)應(yīng)的虛表local__ClassTypeList={}-- 類的繼承關(guān)系數(shù)據(jù),用于處理Hotfix等邏輯。-- 數(shù)據(jù)形式:key為ClassType,value為繼承自它的子類列表。local__InheritRelationship={}localfunction__createSingletonClass(cls,...)ifcls._instance==nilthencls._instance=cls.new(...)endreturncls._instanceendlocalTypeNames={}-- 參數(shù)含義為:-- typeName: 字符串形式的類型名稱-- superType: 父類的類型,可以為nil-- isSingleton: 是否是單例模式的類localfunction__Class(typeName,superType,isSingleton)-- 該table為類定義對(duì)應(yīng)的表localclassType={__IsClass=true}-- 類型名稱classType.typeName=typeNameifTypeNames[typeName]~=nilthenlogerror("The class name is used already!!!"..typeName)elseTypeNames[typeName]=classTypeend-- 父類類型classType.superType=superType-- 在Class身上記錄繼承關(guān)系-- Todo:在修改了繼承關(guān)系的情況下,Reload和Hotfix可能會(huì)存在問題classType._inheritsCount=0ifsuperType~=nilthenlocalcache={}localcounter=1localcurClass=superTypewhilecurClassdocache[counter]=curClasscounter=counter+1curClass=curClass.superTypeendclassType._classInherits=cacheclassType._inheritsCount=counterendclassType._IsSingleton=isSingletonorfalse-- 記錄類的繼承關(guān)系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-- 創(chuàng)建對(duì)象的方法。classType.new=function(...)-- 該table為對(duì)象對(duì)應(yīng)的表localobj={}-- 對(duì)象的toString方法,輸出結(jié)果為類型名稱 內(nèi)存地址。obj.toString=objToString-- 獲取類obj.getClass=objGetClass-- 獲取類型名稱的方法。obj.getType=objGetType-- 遞歸的構(gòu)造過程localcreateObj=function(class,object,...)-- 優(yōu)化遞歸過程中的函數(shù)調(diào)用ifclass.superType~=nilthenfori=class._inheritsCount-1,1,-1dolocalcurClass=class._classInherits[i]ifcurClass.ctorthencurClass.ctor(object,...)endendendifclass.ctorthenclass.ctor(object,...)endend-- 設(shè)置對(duì)象表的metatable為虛表的索引內(nèi)容setmetatable(obj,{__index=__ClassTypeList[classType]})-- 構(gòu)造對(duì)象createObj(classType,obj,...)returnobjend-- 類的toString方法。classType.toString=function(self)returnself.typeNameendifclassType._IsSingletonthenclassType.GetInstance=function(...)return__createSingletonClass(classType,...)endendifsuperTypethen-- 有父類存在時(shí),設(shè)置類身上的super屬性classType.super=setmetatable({},{__index=function(tbl,key)localfunc=__ClassTypeList[superType][key]if"function"==type(func)then-- 緩存查找結(jié)果-- Todo,要考慮reload的影響tbl[key]=funcreturnfuncelseerror("Accessing super class field are not allowed!")endend})end-- 虛表對(duì)象。localvtbl={}__ClassTypeList[classType]=vtbl-- 類的metatable設(shè)置,屬性寫入虛表,setmetatable(classType,{__index=function(tbl,key)returnvtbl[key]end,__newindex=function(tbl,key,value)vtbl[key]=valueend,-- 讓類可以通過調(diào)用的方式構(gòu)造。__call=function(self,...)-- 處理單例的模式ifclassType._IsSingleton==truethenreturn__createSingletonClass(classType,...)elsereturnclassType.new(...)endend})-- 如果有父類存在,則設(shè)置虛表的metatable,屬性從父類身上取-- 注意,此處實(shí)現(xiàn)了多層父類遞歸調(diào)用檢索的功能,因?yàn)槿〉降母割愐彩且粋€(gè)修改過metatable的對(duì)象。ifsuperTypethensetmetatable(vtbl,{__index=function(tbl,key)localret=__ClassTypeList[superType][key]-- Todo 緩存提高了效率,但是要考慮reload時(shí)的處理。vtbl[key]=retreturnretend})endreturnclassTypeend-- 判斷一個(gè)類是否是另外一個(gè)類的子類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-- 判斷一個(gè)對(duì)象是否是一個(gè)類的實(shí)例(包含子類)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
這個(gè)Lua的Class實(shí)現(xiàn)也有參考網(wǎng)上的開源代碼,做了一些自己的改進(jìn),主要功能有:
只支持單繼承;
原生支持單例,但注意,對(duì)于不需要繼承的單例,比如一些常用的Manager,其實(shí)不推薦使用Class的方式,而是直接使用Lua的Table的形式來做效率更高;
支持super來調(diào)用父類的方法,但是調(diào)用的時(shí)候必須使用ClassName.super(self, ...)這樣的方式來顯示地把self傳遞給父類,否則父類拿到的self會(huì)是錯(cuò)誤的對(duì)象;
支持構(gòu)造函數(shù)ctor,但是這在某些想自動(dòng)控制構(gòu)造的情況下也是一把雙刃劍……
對(duì)于多重集成沒有提供原生支持,本來是可以的,但是多重集成有自身的問題,我們提供了一種基于Mixin 的思路來處理,類似于Interface,核心目標(biāo)功能是合并一些函數(shù)到一個(gè)Class中,提供一些大類的模塊拆分,避免出現(xiàn)一個(gè)幾千甚至上萬行代碼的類文件。(之前端游項(xiàng)目中,幾萬行的py文件都有遇到……當(dāng)時(shí)eclipse這樣的IDE打開這樣的py文件都要好久……)
-- 將一個(gè)table中所有的屬性和方法合并到一個(gè)class中,用于處理一個(gè)類比較大的設(shè)計(jì)-- 注意,合并的方法的reload需要單獨(dú)處理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 常用函數(shù)庫的補(bǔ)充
這一部分是自己來彌補(bǔ)Lua語言函數(shù)庫不豐富的問題,當(dāng)然也要看項(xiàng)目需求,我們引入的主要有:
table相關(guān)的一些操作函數(shù),包括長度獲取、dump為字符串、深淺拷貝、深度對(duì)比、根據(jù)值獲得索引等等;
json庫;
int64庫(用的是Lua 5.1);
bit操作庫;
Lua socket庫;
……
這部分跟項(xiàng)目具體需求相關(guān),就不一一列舉和給出代碼了。
2.4 IDE
IDE的部分也只說幾句,我們團(tuán)隊(duì)目前用的比較多的是Sublime Text 3和VS Code,最初我個(gè)人還在使用VS+插件的形式,后來也轉(zhuǎn)向了VS Code陣營。
個(gè)人體驗(yàn)VS Code還是比較不錯(cuò)的,加上一些自動(dòng)補(bǔ)全和基于LuaChecker的語法檢查插件,基本能夠保證避免開發(fā)中一些很蠢的bug。
如果需要,可以自己導(dǎo)出一下Unity的接口為一個(gè)Lua的文件,提升自動(dòng)補(bǔ)全的體驗(yàn),比如我們最初導(dǎo)出的一份U3DAPI.lua的部分內(nèi)容截取示例如下:
--- --- 全名: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 培訓(xùn)和分享
我們團(tuán)隊(duì)的同學(xué)大都有多年使用Python的經(jīng)驗(yàn),但是對(duì)于Lua還是需要上手時(shí)間,所以在最初的時(shí)候就組織了程序內(nèi)部的Lua培訓(xùn)和分享,把比如對(duì)于table和string使用的坑、元表、Lua的GC基本原理、錯(cuò)誤處理等等方面在團(tuán)隊(duì)內(nèi)部進(jìn)行了統(tǒng)一的學(xué)習(xí)和討論,整體的收獲還是比較大的。在開發(fā)過程中發(fā)現(xiàn)的代碼上的問題,也及時(shí)在群內(nèi)進(jìn)行討論,這些都逐步提高了整個(gè)團(tuán)隊(duì)使用Lua進(jìn)行游戲開發(fā)的能力和效率。
2.6 小結(jié)
Lua語言自身的確是有很多易用性上的問題,前文提到的庫不夠豐富之類的,通過在項(xiàng)目初期添加一些基礎(chǔ)的結(jié)構(gòu)和庫,再加上一些提前規(guī)避錯(cuò)誤的強(qiáng)制手段,可以一定程度上改善易用性的問題。然而,即使到現(xiàn)在,使用Lua有一年多的時(shí)候,我們團(tuán)隊(duì)中還是偶爾有同學(xué)出現(xiàn).和:用錯(cuò)導(dǎo)致bug的現(xiàn)象。用好一門語言總是需要一個(gè)不斷踩坑不斷成長的過程,C#也好,Python也好,Lua也好,都需要不斷地學(xué)習(xí)和改進(jìn),希望我們的一些經(jīng)驗(yàn)和教訓(xùn)可以幫助剛剛上手Lua的團(tuán)隊(duì)提前規(guī)避一些坑,也期望更多已經(jīng)熟練使用Lua的團(tuán)隊(duì)可以分享你們經(jīng)驗(yàn)和方法~
總是,Lua這門小而精的語言,在提供了腳本語言中幾乎最快的運(yùn)行效率的同時(shí),也有著開發(fā)效率方面的各種問題,這些問題需要整個(gè)團(tuán)隊(duì)的力量去彌補(bǔ)和改進(jìn)。 我相信,經(jīng)過積淀的團(tuán)隊(duì),在使用Lua進(jìn)行大型游戲的開發(fā)時(shí),可以達(dá)到不差于任何其他語言的開發(fā)速度。
[未完待續(xù)]
2018年3月18日凌晨于杭州家中