深入理解xLua熱更新原理


熱更新簡介

熱更新是指在不需要重新編譯打包游戲的情況下,在線更新游戲中的一些非核心代碼和資源,比如活動運營和打補丁。熱更新分為資源熱更新和代碼熱更新兩種,代碼熱更新實際上也是把代碼當成資源的一種熱更新,但通常所說的熱更新一般是指代碼熱更新。資源熱更新主要通過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
2
3
4
5
6
7
8
9
10
11
[Hotfix]
public static List<Type> by_property
{
get
{
// 需要using System.Linq;
return (from type in Assembly.Load("Assembly-CSharp").GetTypes()
where type.Namespace == "XXXX"
select type).ToList();
}
}

4)、新建一個MonoBehavior腳本並掛載到需要熱更新的場景中,然后在Awake函數中新建一個Lua虛擬機用於加載和執行Lua熱更新腳本文件。代碼如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 需要using XLua;
void Awake()
{
// 新建一個Lua虛擬機,為減少開銷,建議全局唯一。
LuaEnv luaEnv = new LuaEnv();
// DoString表示執行Lua代碼,由於Unity不能識別.lua文件,只能把Lua文件變成文本文件進行讀取。
// require用於加載Lua文件,內置多個Loader加載器,我們也可以自己寫Loader。
luaEnv.DoString("require 'hotfix'");
}

// 在游戲對象被銷毀時,釋放Lua虛擬機內存。
void OnDestroy()
{
luaEnv.Dispose();
}

5)、由於xLua內置了從Resources目錄下加載Lua文本文件,因此我們新建一個hotfix.lua.txt文本文件,然后在里面用Lua實現熱更新邏輯。代碼如下:

1
2
3
4
5
// CS.XXX表示在C#代碼中打[HotFix]標簽的XXX類,"Start"表示XXX類中要進行更改的Start函數, 
// function(self)表示Start函數更改后的函數邏輯,待熱更新完后XXX類的Start函數就會執行function(self)中的代碼邏輯。
xlua.hotfix(CS.XXX, "Start", function(self)
print("hello world")
end)

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
2
3
4
5
6
7
8
9
10
11
12
13
14
[Hotfix]
public class Test : MonoBehaviour
{
void Start ()
{
// 接下來對Start函數進行熱更新,改為輸出Hello World。
Debug.Log("test");
}

void Update ()
{

}
}

Test類打上[HotFix]標簽后,執行XLua/Generate Code后,xLua會根據內置的模板代碼生成器在XLua目錄下的Gen目錄中生成一個DelegatesGensBridge.cs文件,該文件在XLua命名空間下生成一個DelegateBridge類,這個類中的__Gen_Delegate_Imp*函數會映射到xlua.hotfix中的function。代碼如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
namespace XLua
{
public partial class DelegateBridge : DelegateBridgeBase
{
// DelegateBridge類的關鍵函數__Gen_Delegate_Imp*
public void __Gen_Delegate_Imp0(object p0)
{
RealStatePtr L = luaEnv.rawL;
// luaReference就是指向xlua.hotfix(CS.XXX, "Start", function(self))的function
int errFunc = LuaAPI.pcall_prepare(L, errorFuncRef, luaReference);
ObjectTranslator translator = luaEnv.translator;
translator.PushAny(L, p0);
PCall(L, 1, 0, errFunc);
LuaAPI.lua_settop(L, errFunc - 1);
}
}
}

生成適配器代碼后,執行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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
[Hotfix(HotfixFlag.Stateless)]
public class Test : MonoBehaviour
{
// 構造函數對應的DelegateBridge變量
private static DelegateBridge _c__Hotfix0_ctor;
private static DelegateBridge __Hotfix0_Start;
private static DelegateBridge __Hotfix0_Update;
private static DelegateBridge __Hotfix0_TestFunc;

public Test()
: this()
{
_c__Hotfix0_ctor?.__Gen_Delegate_Imp0(this);
}

private void Start()
{
DelegateBridge _Hotfix0_Start = __Hotfix0_Start;
// 如果lua腳本里定義了熱更新函數,就執行對應的熱更新函數邏輯。
if (_Hotfix0_Start != null)
{
_Hotfix0_Start.__Gen_Delegate_Imp0(this);
}
else
{
Debug.Log((object)"test");
}
}

private void Update()
{
__Hotfix0_Update?.__Gen_Delegate_Imp0(this);
}

private void TestFunc()
{
__Hotfix0_TestFunc?.__Gen_Delegate_Imp0(this);
}
}

從反編譯的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]標簽。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM