深入理解xLua熱更新原理
- 2020 年 9 月 14 日
- 筆記
熱更新簡介
熱更新是指在不需要重新編譯打包遊戲的情況下,在線更新遊戲中的一些非核心程式碼和資源,比如活動運營和打修補程式。熱更新分為資源熱更新和程式碼熱更新兩種,程式碼熱更新實際上也是把程式碼當成資源的一種熱更新,但通常所說的熱更新一般是指程式碼熱更新。資源熱更新主要通過AssetBundle來實現,在Unity編輯器內為遊戲中所用到的資源指定AB包的名稱和後綴,然後進行打包並上傳伺服器,待遊戲運行時動態載入伺服器上的AB資源包。程式碼熱更新主要包括Lua熱更新、ILRuntime熱更新和C#直接反射熱更新等。由於ILRuntime熱更新還不成熟可能存在一些坑,而C#直接反射熱更新又不支援IOS平台,因此目前大多採用更成熟的、沒有平台限制的Lua熱更新方案。
為什麼需要熱更新
一般情況下,遊戲開發並測試完後就要提交應用商店審核,其中蘋果商店審核周期最長,審核通過後才能上線發布,這時玩家才能下載安裝遊戲。在如今快節奏的手游時代,遊戲的生命周期大幅縮短而且更新還很頻繁,如果每次遊戲更新都要重新編譯遊戲打包,然後等待審核發布,最後用戶再下載安裝遊戲,那玩家的耐性早沒了。
而且遊戲安裝包還不能太大,不然玩家還沒等到遊戲下載安裝好就失去興趣了。正確的方式是將遊戲中一些非核心的資源打包並上傳伺服器,等遊戲下載安裝好實際運行時才在線動態載入資源,從而減少遊戲安裝包的大小。因此,我們急需一種不需要重新編譯打包就能在線更新遊戲中的一些非核心程式碼和資源,而這種方式就是熱更新。
熱更新分為資源熱更新和程式碼熱更新,資源熱更新主要是指將遊戲中一些資源打包成AB包,並上傳伺服器,等遊戲運行時才從伺服器上載入資源。通過這種方式可以減少遊戲安裝包的大小,減少用戶下載遊戲的時間。其次,可以通過這種方式動態載入遊戲中的資源,比如節假日有活動運營時,可以直接在線更新遊戲中的場景,不需要重新發布遊戲和重新下載安裝遊戲,進而提高玩家的遊戲體驗。
程式碼熱更新,實際上也是一種資源熱更新,它可以在不需要重新編譯打包的情況下在線更新遊戲的非核心程式碼,比如遊戲中的活動運營、修補程式修復和添加小功能等。如果沒有程式碼熱更新技術,每次遊戲一有改動就需要重新編譯打包發布。試想如果新版本遊戲變化不大,只能更新幾個小功能,卻需要重新下載安裝遊戲,玩家會種有浪費時間和被欺騙的感覺,這會極大地降低玩家的遊戲體驗。更何況App Store的嚴格審核機制,長期更新打包發布遊戲會丟失大量用戶。因此,熱更新是手游開發的必備技術之一。
由於Unity開發大多採用C#作為腳本語言,而C#是一門編譯型語言,只有編譯後才能運行,而移動平台不支援C#編譯,即使把C#程式碼像資源一樣下載到移動平台也無法運行。因此,不能直接用C#進行熱更新,除非採用ILRuntime熱更新和C#直接反射熱更新,但這兩種方式都有各自的局限性,最好的方式是用一種不需要編譯就可以直接在移動平台上運行的腳本語言進行熱更新,而小而精的Lua就是最好的選擇。
三種熱更新方案
Lua熱更新
Lua熱更新解決方案是通過一個Lua熱更新插件(如ulua、slua、tolua、xlua等)來提供一個Lua的運行環境以及和C#進行交互。Lua是一門非常小巧的語言,用C語言編寫而成,幾乎可以在任何作業系統和平台上運行,具體語法參考Lua教程。目前用的人最多,性能最好的當屬xlua熱更新插件對應的熱更新解決方案。xLua是騰訊開源的熱更新插件,有大廠背書和專職人員維護,插件的穩定性和可持續性較強。
由於Lua不需要編譯,因此Lua程式碼可以直接在Lua虛擬機里運行,Python和JavaScript等腳本語言也是同理。而xLua熱更新插件就是為Unity、.Net、Mono等C#環境提供一個Lua虛擬機,使這些環境里也可以運行Lua程式碼,從而為它們增加Lua腳本編程的能力。藉助xLua,這些Lua程式碼就可以方便的和C#相互調用。這樣平時開發時使用C#,等需要熱更新時再使用Lua,等下次版本更新時再把之前的Lua程式碼轉換成C#程式碼,從而保證遊戲正常運營。
ILRuntime熱更新
ILRuntime項目是掌趣科技開源的熱更新項目,它為基於C#的平台(例如Unity)提供了一個純C#、快速、方便和可靠的IL運行時,使得能夠在不支援JIT的硬體環境(如iOS)能夠實現程式碼熱更新。ILRuntime項目的原理實際上就是先用VS把需要熱更新的C#程式碼封裝成DLL(動態鏈接庫)文件,然後通過Mono.Cecil庫讀取DLL資訊並得到對應的IL中間程式碼(IL是.NET平台上的C#、F#等高級語言編譯後產生的中間程式碼,IL的具體形式為.NET平台編譯後得到的.dll動態鏈接庫文件或.exe可執行文件),最後再用內置的IL解譯執行虛擬機來執行DLL文件中的IL程式碼。
由於ILRuntime項目是使用C#來完成熱更新,因此很多時候會用到反射來實現某些功能。而反射是.NET平台在運行時獲取類型(包括類、介面、結構體、委託和枚舉等類型)資訊的重要機制,即從對象外部獲取內部的資訊,包括欄位、屬性、方法、構造函數和特性等。我們可以使用反射動態獲取類型的資訊,並利用這些資訊動態創建對應類型的對象。只不過ILRuntime中的反射有兩種:一種是在熱更新DLL中直接使用C#反射獲取到System.Type類對象;另一種是在Unity主工程中通過appdomain.LoadedTypes來獲取繼承自System.Type類的IType類對象,因為在Unity主工程中無法直接通過System.Type類來獲取熱更新DLL中的類。
C#直接反射熱更新
由於Android支援JIT(Just In Time)即時編譯(動態編譯)的模式,即可以邊運行邊編譯,支援在運行時動態生成程式碼和類型。從Android N開始引入了一種同時使用JIT和AOT的混合編譯模式。JIT的優點是支援在運行時動態生成程式碼和類型,APP安裝快,不佔用太多記憶體。缺點是編譯時佔用運行時資源,執行速度比AOT慢。比如,C#中的虛函數和反射都是在程式運行時才確定對應的重載方法和類。因此,Android平台可以不藉助任何第三方熱更新方案,直接使用C#反射執行DLL文件。實際開發時通過System.Reflection.Assembly類載入程式集DLL文件,然後再利用System.Type類獲取程式集中某個類的資訊,還可以通過Activator類來動態創建實例對象。
而IOS平台採用AOT(Ahead Of Time)預先編譯(靜態編譯)的模式,不支援JIT編譯模式,即程式運行前就將程式碼編譯成機器碼存儲在本地,然後運行時直接執行即可,因此AOT不能在運行時動態生成程式碼和類型。AOT的優點是執行速度快,安全性更高。缺點是由於AOT需要提前編譯,所以APP的安裝時間長且占記憶體。Mono在IOS平台上採用Full AOT模式運行,如果直接使用C#反射執行DLL文件,就會觸發Mono的JIT編譯器,而Full AOT模式下又不允許JIT,於是Mono就會報錯。因此,IOS平台上不允許直接使用C#反射執行DLL文件來實現熱更新。
1
|
ExecutionEngineException: Attempting to JIT compile method '...' while running with --aot-only.
|
xLua熱更新步驟
學編程,先跑起來,再去研究原理。下面是xLua熱更新的步驟:
1)、下載xLua插件,解壓後將該目錄中Assets文件夾下的所有資源複製到Unity工程的Assets文件夾下。
2)、在Unity編輯器(File->Build Settings->Player Settings->Other Settings->Scripting Define Symbols)下中添加HOTFIX_ENABLE宏以支援xLua熱更新,Unity編輯器和各個手機平台都要添加。建議平時用Lua寫業務邏輯時可以關閉HOTFIX_ENABLE宏,當打包手機版本或者在編輯器下開發修補程式時才添加HOTFIX_ENABLE宏。
3)、對所有較大可能變動的類型加上[Hotfix]標籤。如果可能變動的類比較多,手動添加比較麻煩,一般遊戲初次上線時,由於不確定添加哪些類,因此我們可以用反射將當前程式集下的所有類自動加上[Hotfix]標籤,還可以按某個namespace或目錄等條件進行設置。程式碼如下:
1
|
[Hotfix]
|
4)、新建一個MonoBehavior腳本並掛載到需要熱更新的場景中,然後在Awake函數中新建一個Lua虛擬機用於載入和執行Lua熱更新腳本文件。程式碼如下:
1
|
// 需要using XLua;
|
5)、由於xLua內置了從Resources目錄下載入Lua文本文件,因此我們新建一個hotfix.lua.txt文本文件,然後在裡面用Lua實現熱更新邏輯。程式碼如下:
1
|
// CS.XXX表示在C#程式碼中打[HotFix]標籤的XXX類,"Start"表示XXX類中要進行更改的Start函數,
|
6)、點擊Unity編輯器的XLua/Generate Code工具,該操作會收集所有打上[HotFix]標籤的類並生成適配程式碼。
7)、點擊Unity編輯器的XLua/Hotfix inject in Editor工具,該操作會對所有打上[HotFix]標籤的類進行IL注入。
8)、運行遊戲,若發現XXX類的Start函數輸出了hello world,則表示熱更新成功,即整個熱更新流程就走完了。
xLua熱更新原理
從上面看出,xLua實際上是C#和Lua進行交互的橋樑,因此xLua不僅可以用於熱更新,還可以藉助它用Lua實現遊戲中一些性能要求不高的業務邏輯。經過上面的步驟,我們對xLua熱更新的流程應該有了一定的了解,現在我們就來深入分析下xLua熱更新的原理。以該類為例:
1
|
[Hotfix]
|
Test類打上[HotFix]標籤後,執行XLua/Generate Code後,xLua會根據內置的模板程式碼生成器在XLua目錄下的Gen目錄中生成一個DelegatesGensBridge.cs文件,該文件在XLua命名空間下生成一個DelegateBridge類,這個類中的__Gen_Delegate_Imp*函數會映射到xlua.hotfix中的function。程式碼如下:
1
|
namespace XLua
|
生成適配器程式碼後,執行XLua/Hotfix inject in Editor後,xLua會使用Mono.Cecil庫對當前工程下的Assembly-CSharp.dll程式集進行IL注入。IL是.NET平台上的C#、F#等高級語言編譯後產生的中間程式碼,該中間程式碼IL再經.NET平台中的CLR(類似於JVM)編譯成機器碼讓CPU執行相關指令。由於移動平台無法把C#程式碼編譯成IL中間程式碼,所以絕大多數熱更新方案都會涉及到IL注入,只有這樣Unity內置的VM才能對熱更新的程式碼進行處理。下面是Unity使用Mono VM的腳本編譯執行過程:
Mono是社區對.NET Framework的跨平台實現方案,實現了.NET Framework的絕大部分類庫,因此基於Mono研發的Unity引擎才具有跨平台能力。而Mono VM就是基於Mono框架實現的,不同的平台實現不同的Mono VM,從而可以不同平台上執行C#腳本。由於IL程式碼是C#程式碼編譯而來的,因此我們可以借用ILSpy工具對C#編譯出來的程式集DLL文件進行反編譯得到C#源程式碼,看看IL注入後打上[HotFix]標籤的類的變化。注入後的C#程式碼如下:
1
|
[Hotfix(HotfixFlag.Stateless)]
|
從反編譯的C#程式碼看出,xLua進行IL注入時會為打上[Hotfix]標籤的類的所有函數創建一個DelegateBridge變數,同時添加對應的判斷條件。如果Lua腳本中添加了對應的熱更新函數,DelegateBridge變數就不為空,並將DelegateBridge變數中的__Gen_Delegate_Imp0方法指向xlua.hotfix(CS.XXX, 「Start」, function(self))中的具體function。這時由於DelegateBridge變數不為空,所以C#中的函數就會執行Lua腳本中對應的熱更新函數邏輯。但如果沒有定義對應的熱更新函數,或對應的熱更新函數為nil,DelegateBridge變數就為空,則C#中的函數依然執行原有的函數邏輯。因此,xLua熱更新實際上就是在運行時用Lua函數替換對應的C#函數。
與xLua熱更新相關的標籤還包括:[LuaCallCSharp]、[ReflectionUse]和[CSharpCallLua],這三個標籤都需要生成適配程式碼,但不需要IL注入。[LuaCallCSharp]標籤表示如果一個C#類型添加了該標籤,xLua會生成這個類型的適配程式碼(包括構造該類型實例,訪問其成員屬性、方法,靜態屬性、方法),否則將會嘗試用性能較低的反射方式來訪問。比如,Lua腳本中想調用某個C#函數,就可以在該C#函數上添加[LuaCallCSharp]標籤,這時Lua就會去尋找該函數的適配程式碼,然後進行調用。如果沒有添加該標籤,xLua就會嘗試用反射的方式進行調用,但性能低於適配程式碼,而且在IL2CPP下還有可能因為程式碼剪裁而導致無法訪問。IL2CPP是Unity推出的用來替代Mono VM的編譯器,IL2CPP的腳本編譯過程如下:
從上圖看到,IL2CPP實際上是將C#編譯得到的IL程式碼轉換成C++程式碼,然後再由各個平台的原生C++編譯器將C++程式碼編譯成原生彙編程式碼(ASM彙編指令)。雖然程式碼轉換成了C++程式碼,但我們知道C#中的記憶體是由GC自動管理,而C++需要手動管理記憶體,因此還需要一個IL2CPP VM用於GC管理等操作。IL2CPP的優點性能得到提升,運行速度更快,其次是編譯成C++後反編譯更難,進而安全性更高。缺點就是IL2CPP打包速度慢,而且轉換後的C++程式碼量猛增,進而可能超過iOS平台可執行文件大小的限制。從2019年8月開始,Google Play上架的APP必須支援64位,因此只能發布時只能採用IL2CPP了,但平時開發調試時還是可以採用Mono,因為Mono出包快。
要想解決這個問題就要對UnityEngine下的程式碼進行Strip裁剪,但這容易導致反射時找不到對應的類型。因為Unity在程式運行前會對程式碼中沒用引用到的地方進行裁剪,而反射必須在程式運行時才能確定要引用的類,如果進行裁剪可能會導致程式運行時通過反射找不到對應的類或函數,從而報錯。唯一的解決方法就是在Assets目錄下新建一個名為link.xml的XML文件,告訴Unity哪些類型不能被裁剪。[ReflectionUse]標籤就是表示如果一個類打上該標籤,xLua就把該類型添加進link.xml以阻止il2cpp的程式碼剪裁。因此,要想在各個平台上都能通過Lua訪問到C#的類型,就必須在C#類型上添加[LuaCallCSharp]或[ReflectionUse]標籤。
[CSharpCallLua]標籤,表示如果C#想要訪問Lua中函數或Table,就要在C#中對應的Delegate或Interface添加該標籤。儘管還有其他映射方式,但最好通過Delegate來映射Lua中的函數,通過Interface來映射Lua中的Table。
在實際開發時,這些標籤可以通過自定義配置來自動添加,配置文件放在XLua目錄下的Editor文件夾中,下面是具體的配置建議:
1)、遊戲剛上線不確定哪些類需要添加[Hotfix]標籤時,可以使用反射把當前程式集下的所有類型都加上[Hotfix]標籤,還可以設置條件進行過濾。
2)、用反射找出所有函數參數、欄位、屬性、事件涉及的delegate類型,標上[CSharpCallLua]用於C#映射Lua中的函數。
3)、業務程式碼、引擎API、系統API等需要在Lua里高性能訪問的類型,標上[LuaCallCSharp],這樣就Lua就會從生成的適配程式碼里找從而性能更高,不然Lua會嘗試用反射的獲取對應的類型,這會產生大量的性能消耗。
4)、引擎API、系統API在IL2CPP下可能被程式碼剪裁(C#無引用的地方都會被剪裁),這樣Lua採用反射的方式獲取對應的類型時就會出錯。因此,如果覺得可能會新增C#程式碼之外的API調用,那麼這些API所在的類型就必須添加[LuaCallCSharp]或[ReflectionUse]標籤。