0. 前言
2016年8月從網(wǎng)易“畢業(yè)”河闰,在新的公司開始新的工作,這其中的波折與故事暫時不提伟叛,等以后有時間的時候再另開文章總結(jié)回顧私痹。全新的手游項目從零開始,使用Unity引擎開發(fā)一款手游項目统刮,本系列札記主要針對開發(fā)過程中重要的部分進(jìn)行記錄和總結(jié)紊遵,一方面方便自己日后回顧,也希望可以給遇到類似問題的朋友一些提醒和啟發(fā)侥蒙。當(dāng)然暗膜,自己在Unity引擎和Lua語言方面都是新手,更加希望拋磚引玉鞭衩,針對遇到的問題進(jìn)行更廣泛的討論和更多大牛的提點学搜。
具體到本篇文章的主題娃善,主要是Lua語言和Unity引擎的集成。這是我最近一個月左右的時間一直在嘗試解決的問題瑞佩,本文主要記錄方案的選擇和對比聚磺,以及針對選擇的方案自己進(jìn)行的一些改造和思考。
1. 為什么要集成Lua語言
有句從知乎開始發(fā)展起來的名言叫做——“先問是不是炬丸,再問為什么”瘫寝,類似地,在做一個技術(shù)方案的時候稠炬,“先問為什么焕阿,再考慮如何做”。那我們第一個問題就是要解決這個項目“為什么要集成Lua語言”首启?
在網(wǎng)易內(nèi)部暮屡,一向遵守的傳統(tǒng)是邏輯用腳本來做,比如Python毅桃、Lua等褒纲,好處主要有如下幾點:
- 利用腳本語言的動態(tài)特性,客戶端可以做Hotfix疾嗅,服務(wù)端可以做Refresh外厂,無論在運營還是開發(fā)期這一特性都很有用;
- 腳本語言運行在虛擬機中代承,它把游戲進(jìn)程搞掛的概率相比C/C++等靜態(tài)語言要低;
- 腳本語言相對好學(xué)習(xí)一些渐扮,對于新手來說上手難度較低论悴,比如Python,當(dāng)然要精通也需要時間和經(jīng)驗的積累墓律;
當(dāng)然還有其他的優(yōu)點膀估,對應(yīng)的缺點就在于運行效率比C/C++低不少,相對于靜態(tài)語言在編譯器有完備的語法檢查耻讽,動態(tài)語言更容易出一些運行時的錯誤稻爬,調(diào)試難度相對大一些宁炫。
而對于Unity引擎,因為它已經(jīng)選擇了C#作為對應(yīng)的腳本語言,因此再集成一門Lua語言顯得有些多余束倍。核心的原因還是在IOS設(shè)備上因為使用了IL2CPP,無法實現(xiàn)像Android上面那樣直接替換DLL的方式來進(jìn)行更新台汇,這導(dǎo)致游戲邏輯如果出現(xiàn)錯誤砾淌,不但無法Hotfix修復(fù),甚至連Patch都不能修復(fù)具帮,只能重新提包博肋。雖然APP Store現(xiàn)在對于應(yīng)用的審核速度已經(jīng)變快低斋, 但是仍然需要2-3天以上的時間,這對于需要快速反應(yīng)的商業(yè)游戲來說是無法容忍的匪凡。
目前了解到的業(yè)內(nèi)常用的做法主要有如下幾種:
- 純C#開發(fā)的方式膊畴,比如騰訊這種大廠,某些工作室的做法就是完全使用C#來進(jìn)行開發(fā)病游,盡量做到功能邏輯可配置唇跨,這樣出現(xiàn)某些重大問題可以通過更新數(shù)據(jù)的方式把邏輯暫時關(guān)閉掉。邏輯的更新安卓使用替換DLL的方式礁遵,IOS使用重新提包的方式轻绞。對外測試以安卓為主,并且大廠有比較好的QA團(tuán)隊進(jìn)行質(zhì)量保證佣耐,因此可以做到IOS最終上線的品質(zhì)和bug都是相對少的政勃。
- C#做核心邏輯,Lua做UI和活動玩法等執(zhí)行頻率低兼砖,需求變動較大的部分奸远。這是目前了解到的一些創(chuàng)業(yè)團(tuán)隊使用得比較多的做法,在效率和可更新性之間的一個折中讽挟。
- 以Lua為主的方式懒叛。也了解到一些公司的團(tuán)隊,包括網(wǎng)易內(nèi)部的一些項目耽梅,使用邏輯都以Lua語言來寫的方式進(jìn)行開發(fā)薛窥。從網(wǎng)易之前的經(jīng)驗來看,邏輯使用純腳本的方式并不會有太大問題眼姐。
我們要開發(fā)的產(chǎn)品是一款商業(yè)游戲诅迷,對于出現(xiàn)問題快速響應(yīng)的需求相對強烈,因此在Unity中使用Lua語言是必不可少的众旗,至于多大范圍地應(yīng)用它罢杉,初步是計劃大部分功能都是用Lua語言來開發(fā),并制定每隔一段時間周期進(jìn)行性能測試和評估的方式來確保性能可以滿足需求贡歧。
2. 怎樣集成Lua語言
在決定要使用Lua語言之后滩租,要面臨的問題就是如何在Unity中去集成它±洌可選的方案有很多律想,各種方案的實現(xiàn)原理也不盡相同,早期有各種在C#語言內(nèi)部實現(xiàn)Lua虛擬機的哗咆,也有利用反射動態(tài)查找腳本的蜘欲,但是目前比較主流的兩種方案是ToLua#和SLua這兩種方案。
2.1 性能對比測試
這兩個方案的原理都相似晌柬,基于LUAInterface姥份,在開發(fā)時將C#的接口導(dǎo)出為Lua的版本郭脂,通過LuaState的棧結(jié)構(gòu)來進(jìn)行兩種語言之間方法調(diào)用。這兩個開源項目針對性能對比在網(wǎng)上打了不少口水仗澈歉,到底誰更優(yōu)秀很難公允地評價展鸡,因為作為一個中間件性質(zhì)的開源項目,除了性能之外還有生態(tài)圈埃难、易用性等各個方面的問題需要考量莹弊。網(wǎng)上有不少對比的帖子可以自己搜索一下,這里不進(jìn)行詳述了涡尘,以免引起論戰(zhàn)忍弛。
在這一部分我們最終選擇了ToLua#,原因我是自己在安卓設(shè)備上進(jìn)行測試了結(jié)果考抄。錢康來前段時間發(fā)了一個帖子來對比幾款Unity中Lua集成方案的性能细疚,Unity常見lua解決方案性能比較,這篇文章也整理投稿到了UWA博客中川梅,我自己基于測試用例在錘子T2上進(jìn)行了簡單的性能測試疯兼,結(jié)論和這篇博客中的基本一致,未整理的數(shù)據(jù)如下表所示贫途。
框架 | test1 | test2 | test3 | test4 | test5 | test6 |
---|---|---|---|---|---|---|
SLua | 755.004 | 623.619 | 34.126 | 6812.41 | 1648.68 | 0.6352 |
ToLua# | 634 | 871.2 | 297.8 | 3056.2 | 1139.4 | 1.206 |
數(shù)據(jù)的單位是毫秒吧彪,測試是進(jìn)行五次測試的平均值,使用錘子T2進(jìn)行丢早。這次測試并不嚴(yán)謹(jǐn)姨裸,只是為了親自驗證一下兩者之間的性能差異到底是什么樣子的。每一個測試用例的代碼可以參考前文提到文章怨酝,這里只簡單進(jìn)行說明:
- test1是簡單的屬性操作啦扬;
- test2和test3是向量的操作;
- test4是GameObject的創(chuàng)建凫碌;
- test5是創(chuàng)建GameObject并進(jìn)行一些屬性操作;
- test6是對四元數(shù)進(jìn)行操作胃榕。
2.2 性能差異的可能原因之一
個人感覺ToLua#在屬性操作方面性能較好盛险,而Vector的向量操作,因為可能會有Lua層的優(yōu)化勋又,即在Lua層完全實現(xiàn)了對應(yīng)的操作苦掘,因此需要針對源碼進(jìn)行詳細(xì)的對比。至于性能差異的原因楔壤,我沒有從Lua虛擬機的實現(xiàn)部分分析鹤啡,只是查看兩種生成Warp后的接口進(jìn)行一個簡單的猜想。
選取同一個接口進(jìn)行對比蹲嚣,UnityEngine.Animator的GetFloat接口递瑰,ToLua#的實現(xiàn)如下:
[MonoPInvokeCallbackAttribute(typeof(LuaCSFunction))]
static int GetFloat(IntPtr L)
{
try
{
int count = LuaDLL.lua_gettop(L);
if (count == 2 && TypeChecker.CheckTypes(L, 1, typeof(UnityEngine.Animator), typeof(int)))
{
UnityEngine.Animator obj = (UnityEngine.Animator)ToLua.ToObject(L, 1);
int arg0 = (int)LuaDLL.lua_tonumber(L, 2);
float o = obj.GetFloat(arg0);
LuaDLL.lua_pushnumber(L, o);
return 1;
}
//此處省略另一個重載接口
else
{
return LuaDLL.luaL_throw(L, "invalid arguments to method: UnityEngine.Animator.GetFloat");
}
}
catch(Exception e)
{
return LuaDLL.toluaL_exception(L, e);
}
}
SLua生成的代碼如下:
[MonoPInvokeCallbackAttribute(typeof(LuaCSFunction))]
static public int GetFloat(IntPtr l) {
try {
int argc = LuaDLL.lua_gettop(l);
if(matchType(l,argc,2,typeof(int))){
UnityEngine.Animator self=(UnityEngine.Animator)checkSelf(l);
System.Int32 a1;
checkType(l,2,out a1);
var ret=self.GetFloat(a1);
pushValue(l,true);
pushValue(l,ret);
return 2;
}
//此處省略另一個重載接口
pushValue(l,false);
LuaDLL.lua_pushstring(l,"No matched override function to call");
return 2;
}
catch(Exception e) {
return error(l,e);
}
}
我們注意到祟牲,這一函數(shù)只需要一個返回值的,但是SLua往棧里pushValue了兩個值抖部,然后返回2说贝,第一個值是一個bool值,它應(yīng)該是用于標(biāo)識函數(shù)調(diào)用是否成功慎颗。在不了解其他地方是否有性能差別的情況下乡恕,這里應(yīng)該是ToLus#和SLua在簡單的接口調(diào)用上的性能差別的原因之一。SLua使用一個單獨的值來表示函數(shù)運行結(jié)果俯萎,這對于錯誤可以進(jìn)行更好的處理傲宜,但是多出的壓棧和出棧操作有額外的性能消耗。
2.3 導(dǎo)出方式對比
ToLua#導(dǎo)出使用的是白名單的方式夫啊,在CustomeSettings.cs文件中定義的接口才會導(dǎo)出函卒,也提供了導(dǎo)出引擎所有的接口的功能;而SLua是以黑名單的方式進(jìn)行涮母,默認(rèn)提供的功能是導(dǎo)出除了黑名單中的所有模塊接口谆趾,也提供了一個導(dǎo)出最簡接口的方式。
從使用角度來看叛本,SLua黑名單的方式在開發(fā)期比較方便沪蓬,默認(rèn)會導(dǎo)出所有接口,因此不需要每次想要增加一個已經(jīng)存在的類的Lua接口都要自己定義然后重新導(dǎo)出来候,發(fā)布的時候也可以使用最簡接口的方式導(dǎo)出跷叉。維護(hù)起來ToLua#因為所有的導(dǎo)出類都是我們自己定義的,因此更加清晰明確营搅。
鑒于這部分內(nèi)容有源碼可以進(jìn)行修改云挟,因此不是一個核心需要考慮的內(nèi)容,兩種方式各有利弊转质。
2.4 我們的選擇
至于這一點是否是性能差別的主要原因园欣,因為沒有時間和精力閱讀其他部分的源碼,暫時也不太好進(jìn)行對比和評價休蟹。出于性能的考慮沸枯,我們項目決定使用ToLua#作為Lua部分集成的方案,并且以接口的形式進(jìn)行封裝赂弓,來保證后面替換的可能性绑榴。
3. 如何使用Lua語言
在進(jìn)行了初步集成之后,怎樣讓開發(fā)人員可以更好地使用Lua語言是接下來要面臨的問題盈魁。ToLua#對應(yīng)有一套之前ulua作者開發(fā)的LuaFramework翔怎,這一個框架集成了腳本打包和二進(jìn)制腳本讀取、UI制作流程等多個功能,但是也如作者自己所說赤套,這一框架最初源自一個示例形式的Demo飘痛,因此其中代碼有很多部分是和示例寫死綁定的邏輯,比如啟動邏輯于毙、Lua二進(jìn)制腳本的加載需要手動指定等等敦冬。
相對應(yīng)的,SLua也有多套已經(jīng)開源的框架唯沮,其中最為完善的是KSFramwork脖旱,這套框架集成了資源打包、導(dǎo)表介蛉、Lua熱重載在內(nèi)的多個功能萌庆,而且代碼質(zhì)量初步看起來還不錯,因此最終我們決定把KSFramwork中的SLua部分替換成ToLua#的部分來結(jié)合使用币旧。
改造的過程還比較簡單践险,由于該部分使用Lua耦合的只有兩塊內(nèi)容,一是UIControler部分吹菱,二是LuaBehavior部分巍虫,所有的接口都由LuaModule模塊提供。因此改造的過程也就比較明確了:
- 刪除源代碼中的SLua部分鳍刷,接入ToLua#的部分占遥;
- 使用ToLua#重寫LuaModule的實現(xiàn);
- 改造LuaUIController输瓜,使用新的LuaModule接口實現(xiàn)之前的功能瓦胎;
- 改造LUABehavior模塊。
代碼刪除和LuaModule模塊的重新實現(xiàn)都比較簡單尤揣,著重介紹一下LuaUIController和LUABehavior模塊的改造搔啊。
3.1 改造初衷
之前的KSFramwork還是一個核心邏輯在C#,Lua只承載UI等邏輯的模塊北戏,這與我之前從網(wǎng)易“繼承”的“輕引擎负芋,重腳本”的思路并不契合。在這一思路下嗜愈,引擎可以看做渲染示罗、資源加載、音效等功能的提供者芝硬,腳本邏輯負(fù)責(zé)使用這些功能構(gòu)建游戲內(nèi)容。那這樣大部分與邏輯相關(guān)的控制權(quán)就應(yīng)該從引擎交給腳本部分來進(jìn)行轧房。Unity作為一個比較特殊的例子拌阴,雖然對于它來說,C#部分已經(jīng)是腳本了奶镶,但是對于希望著重使用Lua腳本的我們來說迟赃,因為C#不可更新陪拘,因此被視作了引擎部分。
最為簡單的設(shè)計就是當(dāng)引擎初始化完畢之后纤壁,通過一個接口調(diào)用把后續(xù)的邏輯都交由腳本來控制左刽,大部分與游戲玩法相關(guān)的模型加載、聲音播放酌媒、特效播放欠痴、動畫播放等由腳本來控制。tick邏輯為了減少調(diào)用次數(shù)秒咨,每幀也由引擎調(diào)用注冊的一個腳本接口進(jìn)行統(tǒng)一調(diào)用喇辽,腳本層自己做分發(fā)。
3.2 LuaUIController的改造
LuaUIController原始的方式是在C#層通過ui模塊的名稱加載對應(yīng)的一個lua文件雨席,獲取一個lua table進(jìn)行緩存菩咨,在比如OnInit等需要接口調(diào)用的地方查找這個table中對應(yīng)的函數(shù)進(jìn)行調(diào)用。這種方式的界面是由C#層的邏輯來驅(qū)動加載和顯示的陡厘,而且在加載過程中要有文件的搜索和檢查過程抽米。
這樣會存在一個問題,就是腳本層的邏輯無法或者很難去控制界面對象的生命周期糙置。針對資源的生命周期云茸,“誰創(chuàng)建誰管理”的策略不再可以很方便地來明確責(zé)任的劃分,因此要進(jìn)行改造罢低。
改造的方向很簡單查辩,將界面加載和顯示的接口開放到Lua層,然后在創(chuàng)建的時候由lua層傳遞一個table對象進(jìn)來网持,C#中進(jìn)行緩存宜岛,當(dāng)界面資源異步加載完畢,需要進(jìn)行接口調(diào)用的地方的實現(xiàn)與之前保存一致功舀。這樣萍倡,界面資源的生命周期全部交由腳本層來管理,在腳本構(gòu)建一個結(jié)構(gòu)合理功能齊全的UIManager來進(jìn)行一些功能的封裝辟汰,就可以滿足大部分的需求列敲。
3.3 LuaBehavior的改造
MonoBehavior是Unity為了放便開發(fā)而提供的一個很好的功能,腳本以組件的方式掛接在GameObject身上帖汞,就可以在Awake戴而、Start、Update等接口中處理想要的邏輯翩蘸。為了能夠繼續(xù)使用Unity的這一特性所意,在Lua層也實現(xiàn)了一個簡單的LuaBehavior封裝。
KSFramwork中的思路非常簡單,同樣根據(jù)名稱來把一個LuaBehavior和一個Lua腳本進(jìn)行綁定扶踊,在對應(yīng)的邏輯中調(diào)用與之對應(yīng)的接口就可以了泄鹏。比如Awake接口的實現(xiàn)如下:
protected virtual void Awake()
{
if (!string.IsNullOrEmpty(LuaPath))
{
Init();
CallLuaFunction("Awake");
} // else Null Lua Path, pass Awake!
}
CallLuaFunction
的實現(xiàn)也很明確,從緩存的lua table中獲取名稱為Awake的function進(jìn)行調(diào)用秧耗。這種方式?jīng)]有問題备籽,但是當(dāng)場景中掛載了LuaBehavior的GameObject很多的時候,每一幀都會有非常多次的update方法調(diào)用分井,這個調(diào)用從C#層傳遞給Lua層车猬,有很多額外的性能消耗。
前文也提到了杂抽,比較好的方式是每幀只有一個C#到Lua層的Update方法調(diào)用诈唬,然后腳本層自己做分發(fā)。因此缩麸,針對這一需求铸磅,我們使用ToLua#自帶的LuaLooper來實現(xiàn)這一功能。
LuaLooper是全局只創(chuàng)建一個的MonoBehaviour杭朱,注意這里只創(chuàng)建一個是由邏輯來決定的阅仔,而不是一個單例模式。這里針對單例模式適用場合的討論不再展開弧械,此處由邏輯來保證只有一個Looper存在是一件比較合理的事情八酒,預(yù)留了一些擴(kuò)展的可能。
LuaLooper以事件的方式將三種Update分發(fā)出去:Update刃唐、LateUpdate羞迷、FixedUpdate,它在自己對應(yīng)的函數(shù)中調(diào)用luaState的對應(yīng)函數(shù)來將事件告知腳本画饥,腳本中需要的模塊向分發(fā)模塊注冊回調(diào)來監(jiān)聽事件衔瓮,就可以做到每幀只有一次Update調(diào)用了。
具體的代碼實現(xiàn)可以去看ToLua#中的LupLooper.cs的類實現(xiàn)抖甘。
注意 這里有一個需要小心的點是當(dāng)事件在腳本層分發(fā)的時候热鞍,要注意執(zhí)行時序問題的影響,最好能夠保證任意的執(zhí)行順序都可以不影響游戲邏輯的結(jié)果衔彻,否則可能會出現(xiàn)很難查的詭異bug薇宠。
對于Awake、Start等一次性調(diào)用的函數(shù)艰额,由于不是頻繁的邏輯澄港,因此保留了原始的實現(xiàn)方式,這樣可以讓Lua層對應(yīng)的代碼實現(xiàn)更加簡潔柄沮。而使用事件注冊的方式慢睡,讓不需要update邏輯的腳本沒有任何額外的性能消耗逐工。
4. 結(jié)語
只有上述的這些部分,對于開發(fā)一款商業(yè)游戲來說還遠(yuǎn)遠(yuǎn)不夠漂辐,但是通過導(dǎo)出的接口和對于KSFramwork的一些改進(jìn),已經(jīng)可以實現(xiàn)一個簡單的由Lua層來驅(qū)動的Demo了棕硫,它可以加載場景髓涯,打開一個打包成AssetBundle的界面,設(shè)置界面上的控件屬性哈扮,為按鈕添加一些回調(diào)時間纬纪,然后切換場景,加載一些打包在AssetBudnle中的Prefab模型滑肉。
這是Lua初步集成的結(jié)束包各,也是在這款游戲中創(chuàng)造萬物的開始。