粘貼過來的原因闯估,代碼比較亂,知乎原文傳送門:https://zhuanlan.zhihu.com/p/34660501
0. 照舊的碎碎念
轉(zhuǎn)眼間已經(jīng)三月了供炼,2月份的博客因為過年的懶惰和開年之后的忙碌而沒有寫……第二個月就打破了去年總結(jié)時對于2018年的愿望一屋,真是羞恥呢……
年后在準備新的測試版本,斷斷續(xù)續(xù)做了一些優(yōu)化袋哼,更多的精力放在團隊的績效評估冀墨、溝通這樣偏管理的事物上,說實話技術(shù)上可以聊的東西不多涛贯。近期看到UWA群里和問答上聊Lua的使用之類的話題比較多诽嘉,也在看ET這套完全基于C#進行游戲開發(fā)的框架中提到——
“在發(fā)布的時候,定義預(yù)編譯指令I(lǐng)LRuntime就可以無縫切換成使用ILRuntime加載熱更新動態(tài)庫。這樣開發(fā)起來及其方便虫腋,再也不用使用狗屎lua了骄酗。”
Lua是門小而精的語言悦冀,它的確很多地方像狗屎一樣……比如只提供table這樣一種數(shù)據(jù)結(jié)構(gòu)趋翻,而且基于數(shù)組域和哈希域的封裝讓#這樣的操作符號可以坑死不少新手甚至老司機,一個哈希表要取長度還要自己封裝一個遍歷函數(shù)等等諸多不便的地方盒蟆。
我們項目深度使用了Lua踏烙,原因其實在1年多前的一篇文章里已經(jīng)有聊過——《Unity手游開發(fā)札記——Lua語言集成》,有興趣的朋友可以再去看看历等。那篇文章也聊了最初對于一些框架上的改造讨惩,而今天這篇文章我想聊聊我們團隊是如何使用Lua來開發(fā)大型游戲的。一方面讓大家看看我們是如何把Lua這個“狗屎”募闲,捏成巧克力的形狀甚至做出一點點巧克力的味道步脓;另外一方面愿待,也想為糾結(jié)是否使用Lua來做Unity的代碼更新方案的朋友提供一些做決策的參考浩螺。
1. 我的觀點
在聊一些更加具體的經(jīng)驗之前,我想先把我自己的觀點拋出來仍侥,這也是我花時間寫這篇文章最想表達的兩點內(nèi)容:
使用Lua這樣的腳本語言要出,目的不僅僅在于讓代碼可以被Patch更新,而且讓游戲邏輯可以被Hotfix更新农渊。
使用Lua這樣的腳本語言患蹂,調(diào)試bug的效率并不低,甚至可能比C#這樣的靜態(tài)語言還要高砸紊。
先聊下第一點传于,我看很多朋友在聊的時候不斷提到客戶端的熱更新,可能每個人或者公司有自己不同的叫法醉顽,在我的觀點里沼溜,通過在游戲啟動的時候下載新的資源文件替換之前的文件,讓游戲不需要重新安裝就可以更新內(nèi)容的方式叫做“Patch更新”游添,而不是熱更新(Hotfix)系草。
在我的理解中,熱更新(Hotfix)的概念從服務(wù)端來講唆涝,是指不停止服務(wù)的情況下進行的更新找都,此時如果玩家正在進行游戲,玩家是無感知的廊酣,最多感覺到一點頓卡之類的能耻。而對于客戶端來說,玩家正在進行游戲,這時候如果需要玩家退出到登陸界面重新下載Patch內(nèi)容再進入游戲嚎京,打斷了玩家的游戲體驗嗡贺,根本就不能稱之為“熱”更新,雖不至于是冷更鞍帝,最多是“溫”更新……
腳本語言讓游戲邏輯和數(shù)據(jù)可以做到玩家無感知的情況下進行錯誤的修復(fù)诫睬,比如有一個trace導(dǎo)致了玩家某個系統(tǒng)的界面打開后內(nèi)容顯示錯誤,Hotfix應(yīng)用之后帕涌,玩家下次打開這個界面的時候摄凡,trace就已經(jīng)被修復(fù)了,內(nèi)容顯示正確蚓曼,而玩家完全沒有任何更新的感知亲澡,這種才能叫做真正的客戶端熱更新。
第二點纫版,有些朋友認為腳本語言只能通過打log進行調(diào)試床绪,是一件非常痛苦的事情。首先其弊,Python和Lua這樣的腳本語言都有各自的調(diào)試工具癞己,可能沒有那么便利,但基本功能是夠用的梭伐;其次痹雅,在移動網(wǎng)絡(luò)游戲的開發(fā)中,有網(wǎng)絡(luò)因素糊识、異步邏輯绩社、設(shè)備上運行等存在的情況下,有些bug是很難單步調(diào)試來進行重現(xiàn)和分析的赂苗,這種情況下log調(diào)試必不可少愉耙,而且我認為通過分析代碼邏輯精準地添加log快速定位問題并修復(fù)問題的能力,是每一個程序員應(yīng)該掌握的基本技巧拌滋;最后朴沿,結(jié)合動態(tài)語言的reload功能,即使是使用log調(diào)試鸠真,也有很高效的方法悯仙,在加上內(nèi)存查看工具,可以做到很高效的bug定位和修復(fù)吠卷。
這里只是先闡述一下我個人的觀點锡垄,下面我將根據(jù)實際的項目經(jīng)驗來聊聊我們使用Lua的一些方面。
2. 讓Lua代碼更好寫
Lua自身提供的功能很精簡祭隔,精簡也意味著它在很多方面會有些“殘疾”……這會導(dǎo)致團隊的開發(fā)效率比較低货岭,因此必須通過一些基礎(chǔ)內(nèi)容的構(gòu)建來讓團隊更好地使用Lua語言路操。需要注意的是,天下沒有免費的午餐千贯,更快的開發(fā)效率有很多時候意味著更慢的運行效率屯仗。
2.1 全局變量訪問控制
Lua的設(shè)計中有一個特點就是:
當(dāng)你不在變量前使用local關(guān)鍵字的時候,這個變量會被放在_G這個全局表中搔谴。
我在最初學(xué)習(xí)Lua的時候也很難理解這個設(shè)計魁袜,這和之前我使用的編程語言中作用域的概念是相違背的,但是當(dāng)你理解函數(shù)的env概念之后敦第,就很容易理解為什么在Lua語言中峰弹,這樣的設(shè)計反而是最為合理和自洽的。
對于Lua語言自身來說芜果,這種合理和自洽是美的鞠呈,但是它會給使用的人帶來困惑和難以排查的bug,因為你非秤壹兀可能因為遺漏的local聲明蚁吝,導(dǎo)致污染了_G,甚至修改到了了你不想修改的變量舀射,或者你的某個變量被別處的代碼不小心修改了窘茁。因此在我們的工程中,去掉了Lua的這一特性后控,當(dāng)期望使用一個局部變量但是沒有寫local變量的時候庙曙,使用error報出錯誤空镜,所有的全局變量必須顯示地進行聲明浩淘。
實現(xià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
我相信這種強制報錯的設(shè)定可以幫助很多剛剛上手Lua的朋友避免一些錯誤吴攒。上述的代碼也是參考網(wǎng)上的開源工程张抄,需要用的朋友可以直接拿去。
2.2 Class的設(shè)計
雖然面向?qū)ο蟮脑O(shè)計在很多帖子的討論中已經(jīng)過時的洼怔,面向切面編程等等新概念不斷被提出署惯,但是對于一個需要團隊協(xié)作的游戲項目來說,面向?qū)ο蟮脑O(shè)計依然是目前最為常用的邏輯實現(xiàn)方式镣隶。Lua自身沒有Class的概念极谊,提供了metatable來做繼承,但很弱安岂。我們在項目最初的時候就構(gòu)建了Class的機制轻猖,來方便代碼的編寫。雖然和原生支持Class的Python和C#這樣的語言相比易用性和功能上還都有差距域那,但是基本夠用了咙边。
直接提供核心代碼如下:
-- Class.lua-- 類定義,不支持多重繼承l(wèi)ocalGLDeclare=require"Framework/Global"-- 所有定義過的類列表,key為類的類型名稱败许,value為對應(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為類定義對應(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可能會存在問題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)建對象的方法盟戏。classType.new=function(...)-- 該table為對象對應(yīng)的表localobj={}-- 對象的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è)置對象表的metatable為虛表的索引內(nèi)容setmetatable(obj,{__index=__ClassTypeList[classType]})-- 構(gòu)造對象createObj(classType,obj,...)returnobjend-- 類的toString方法。classType.toString=function(self)returnself.typeNameendifclassType._IsSingletonthenclassType.GetInstance=function(...)return__createSingletonClass(classType,...)endendifsuperTypethen-- 有父類存在時黄选,設(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-- 虛表對象。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啡专,屬性從父類身上取-- 注意,此處實現(xiàn)了多層父類遞歸調(diào)用檢索的功能制圈,因為取到的父類也是一個修改過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實現(xiàn)也有參考網(wǎng)上的開源代碼慧库,做了一些自己的改進,主要功能有:
只支持單繼承馋嗜;
原生支持單例齐板,但注意,對于不需要繼承的單例葛菇,比如一些常用的Manager甘磨,其實不推薦使用Class的方式,而是直接使用Lua的Table的形式來做效率更高眯停;
支持super來調(diào)用父類的方法济舆,但是調(diào)用的時候必須使用ClassName.super(self, ...)這樣的方式來顯示地把self傳遞給父類,否則父類拿到的self會是錯誤的對象庵朝;
支持構(gòu)造函數(shù)ctor吗冤,但是這在某些想自動控制構(gòu)造的情況下也是一把雙刃劍……
對于多重集成沒有提供原生支持又厉,本來是可以的,但是多重集成有自身的問題椎瘟,我們提供了一種基于Mixin 的思路來處理覆致,類似于Interface,核心目標功能是合并一些函數(shù)到一個Class中肺蔚,提供一些大類的模塊拆分煌妈,避免出現(xiàn)一個幾千甚至上萬行代碼的類文件。(之前端游項目中宣羊,幾萬行的py文件都有遇到……當(dāng)時eclipse這樣的IDE打開這樣的py文件都要好久……)
-- 將一個table中所有的屬性和方法合并到一個class中璧诵,用于處理一個類比較大的設(shè)計-- 注意,合并的方法的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 常用函數(shù)庫的補充
這一部分是自己來彌補Lua語言函數(shù)庫不豐富的問題之宿,當(dāng)然也要看項目需求,我們引入的主要有:
table相關(guān)的一些操作函數(shù)苛坚,包括長度獲取比被、dump為字符串、深淺拷貝泼舱、深度對比等缀、根據(jù)值獲得索引等等;
json庫娇昙;
int64庫(用的是Lua 5.1)尺迂;
bit操作庫;
Lua socket庫冒掌;
……
這部分跟項目具體需求相關(guān)噪裕,就不一一列舉和給出代碼了。
2.4 IDE
IDE的部分也只說幾句宋渔,我們團隊目前用的比較多的是Sublime Text 3和VS Code州疾,最初我個人還在使用VS+插件的形式辜限,后來也轉(zhuǎn)向了VS Code陣營皇拣。
個人體驗VS Code還是比較不錯的,加上一些自動補全和基于LuaChecker的語法檢查插件薄嫡,基本能夠保證避免開發(fā)中一些很蠢的bug氧急。
如果需要,可以自己導(dǎo)出一下Unity的接口為一個Lua的文件毫深,提升自動補全的體驗吩坝,比如我們最初導(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)和分享
我們團隊的同學(xué)大都有多年使用Python的經(jīng)驗,但是對于Lua還是需要上手時間哑蔫,所以在最初的時候就組織了程序內(nèi)部的Lua培訓(xùn)和分享钉寝,把比如對于table和string使用的坑弧呐、元表、Lua的GC基本原理嵌纲、錯誤處理等等方面在團隊內(nèi)部進行了統(tǒng)一的學(xué)習(xí)和討論俘枫,整體的收獲還是比較大的。在開發(fā)過程中發(fā)現(xiàn)的代碼上的問題逮走,也及時在群內(nèi)進行討論鸠蚪,這些都逐步提高了整個團隊使用Lua進行游戲開發(fā)的能力和效率。
2.6 小結(jié)
Lua語言自身的確是有很多易用性上的問題师溅,前文提到的庫不夠豐富之類的茅信,通過在項目初期添加一些基礎(chǔ)的結(jié)構(gòu)和庫,再加上一些提前規(guī)避錯誤的強制手段墓臭,可以一定程度上改善易用性的問題蘸鲸。然而,即使到現(xiàn)在窿锉,使用Lua有一年多的時候棚贾,我們團隊中還是偶爾有同學(xué)出現(xiàn).和:用錯導(dǎo)致bug的現(xiàn)象。用好一門語言總是需要一個不斷踩坑不斷成長的過程榆综,C#也好妙痹,Python也好,Lua也好鼻疮,都需要不斷地學(xué)習(xí)和改進怯伊,希望我們的一些經(jīng)驗和教訓(xùn)可以幫助剛剛上手Lua的團隊提前規(guī)避一些坑,也期望更多已經(jīng)熟練使用Lua的團隊可以分享你們經(jīng)驗和方法~
總是判沟,Lua這門小而精的語言耿芹,在提供了腳本語言中幾乎最快的運行效率的同時,也有著開發(fā)效率方面的各種問題挪哄,這些問題需要整個團隊的力量去彌補和改進吧秕。 我相信,經(jīng)過積淀的團隊迹炼,在使用Lua進行大型游戲的開發(fā)時砸彬,可以達到不差于任何其他語言的開發(fā)速度。
[未完待續(xù)]
2018年3月18日凌晨于杭州家中