盤點(diǎn):Unity SDK開(kāi)發(fā)中有哪些坑?

責(zé)任編輯:yang

2021-08-25 17:08:09

摘自:中國(guó)IT產(chǎn)經(jīng)新聞

引言

Untiy作為游戲引擎和內(nèi)容開(kāi)發(fā)平臺(tái),吸引了眾多游戲開(kāi)發(fā)者,基于其開(kāi)發(fā)的游戲更是不勝其數(shù)。具體請(qǐng)參見(jiàn)1。

1.jpg

環(huán)信作為領(lǐng)先的即時(shí)通訊云服務(wù)商,在游戲行業(yè)也進(jìn)行了持續(xù)的探索和研發(fā)投入。在產(chǎn)品發(fā)布的早期(2015年)就推出了Unity SDK,幫助游戲開(kāi)發(fā)者快速實(shí)現(xiàn)游戲場(chǎng)景下諸如世界頻道,游戲公會(huì)、組隊(duì)群聊,1對(duì)1私聊等功能,安全穩(wěn)定的服務(wù)也為游戲玩家?guī)?lái)了極佳的實(shí)時(shí)溝通體驗(yàn)。

2021年第二季度,環(huán)信IM Unity SDK進(jìn)行了重構(gòu)改版,環(huán)信IM Unity SDK 2.0正式發(fā)布,主要改進(jìn)包括如下:

  1、迭代更新,更加實(shí)用的API接口

  2、IM+Push增強(qiáng)功能的補(bǔ)全

  3、C#語(yǔ)言層面引入了版本7.0 – 9.0之后的一些新語(yǔ)法改進(jìn)

  4、特別的,增加了PC端Unity Editor環(huán)境下編譯調(diào)試支持,大大提升了開(kāi)發(fā)效率

 

在過(guò)去的一段時(shí)間里,筆者也參與了相應(yīng)的研發(fā)工作。在整個(gè)過(guò)程中,為了解決各種問(wèn)題,不僅要到處翻閱資料,還要嘗試各種方法和參數(shù)組合。其間也經(jīng)歷了各種程序崩潰甚至系統(tǒng)崩潰,詭異的程序表現(xiàn)一次次讓開(kāi)發(fā)人員束手無(wú)策,四處碰壁,當(dāng)真像深夜里行走在迷宮之中,手里還拿著一個(gè)待破解的魔方。“此路不通,請(qǐng)繞行!”,是在一次次的嘗試后無(wú)奈的慨嘆和難舍的放棄。而一旦問(wèn)題最后得到圓滿解決,又宛如飛入云端,以上帝視角俯瞰一片片迷宮,一切又顯得那么理所當(dāng)然,繁復(fù)瑣細(xì)但又絲絲入扣,這樣的苦盡甘來(lái)也算是做程序員能享受到的巨大喜悅和滿足。

不敢獨(dú)享,特記錄下一些心得供大家參考,也歡迎.NET平臺(tái)資深玩家批評(píng)指正。以下,Enjoy!

開(kāi)發(fā)概覽:非托管插件開(kāi)發(fā)(Native/Unmanaged Plugin)

Unity是基于Microsoft .Net Framework開(kāi)發(fā)的游戲引擎2,它采用了開(kāi)源的.NET Platform,并依賴此框架來(lái)實(shí)現(xiàn)跨硬件設(shè)備和運(yùn)行時(shí)(操作系統(tǒng))的目標(biāo),也是所謂的”Write once, run anywhere”。在語(yǔ)言方面,Unity選擇C#作為主要的腳本編程語(yǔ)言,雖然.NET平臺(tái)本身支持的語(yǔ)言有很多種。

進(jìn)一步,Unity支持Mono和ILC2PP兩種腳本框架(Scripting Backends)。特別的,Unity Editor采用的是Mono腳本框架。

一般的,游戲類庫(kù)開(kāi)發(fā)者可以選擇直接用C#語(yǔ)言開(kāi)發(fā),目標(biāo)類庫(kù)可以實(shí)現(xiàn)基于.NET Framework基礎(chǔ)功能之上的高級(jí)功能,這類插件稱之為Managed Plugin(托管插件)。由于環(huán)信IM核心SDK已經(jīng)基于C++開(kāi)發(fā),因此我們選擇另一種Native Plugin(本地插件)的方式,正是它把我們引向了迷宮之旅。兩種類型的Plugin介紹,參見(jiàn)3

不幸的是,Unity網(wǎng)站上關(guān)于Native Plugin的相關(guān)介紹少只又少,想要了解它的具體細(xì)節(jié)還要去參考Microsoft MSDN文檔。作為中規(guī)中矩的文檔介紹,微軟的文檔是合格的,但是,當(dāng)你真正上手編程時(shí)就會(huì)發(fā)現(xiàn),這些遠(yuǎn)遠(yuǎn)不夠:下面記錄的一些坑點(diǎn)就很難在相應(yīng)的文檔中得到直接的提示;而要通過(guò)Google大法,結(jié)合其他程序員留下的蛛絲馬跡,再加上自己不斷的調(diào)試來(lái)最終確認(rèn)。

在微軟文檔上下文中,Unity Native Plugin有個(gè)另外的名字:Unmanaged Plugin,即非托管插件。簡(jiǎn)單來(lái)講,Managed Plugin生存在.NET Framework的運(yùn)行時(shí)環(huán)境(類似于Java的JVM),而Unmanaged Plugin則生存在這個(gè)運(yùn)行時(shí)環(huán)境之外,也即和運(yùn)行時(shí)環(huán)境是兄弟的關(guān)系。如果你原本的類庫(kù)實(shí)現(xiàn)滿足微軟的COM(Component Object Model)規(guī)范,那自然最好是使用COM Interop4的互操作方式;而環(huán)信IM SDK本身是純C++實(shí)現(xiàn),因此采用了Platform Invoke5(簡(jiǎn)稱P/Invoke)方式,本文剩下的內(nèi)容均是基于P/Invoke。

下圖則概要描述了Managed和Unmanaged區(qū)域代碼之間互相操作的方式:

 

 

 

 

更具體的,為了實(shí)現(xiàn)對(duì)于Unmanaged DLL function的調(diào)用,只需要簡(jiǎn)單的4步6

  1、確認(rèn)DLL類庫(kù)中需要被操作的函數(shù);

  2、創(chuàng)建一個(gè)C#類來(lái)關(guān)聯(lián)被操作的這些函數(shù)(給函數(shù)穿上一個(gè)馬甲,以便集中管理和反復(fù)調(diào)用);

  3、使用DllImport標(biāo)志在受管側(cè)(C#)定義函數(shù)原型;

  4、在受管側(cè)隨意調(diào)用相關(guān)非托管區(qū)域函數(shù)。

上圖中,Standard marshalling service即負(fù)責(zé)將數(shù)據(jù)在兩個(gè)區(qū)域進(jìn)行封裝/解封裝傳送(marshall/unmarshall),它主要定義了數(shù)據(jù)在兩個(gè)不同內(nèi)存區(qū)域進(jìn)行拷貝(Copy)和引用(Reference)的規(guī)則7,而迷宮中的坑主要是和這些具體規(guī)則有關(guān)。

坑王駕到之封送(Marshall/Unmarshall)中的那些坑

坑一:sizeof(bool) = ?

絕大多數(shù)的基本類型屬于Blittable Types8:如System.Byte, System.Single等。System.Boolean雖然不屬于Blittable types,但是Standard Marshalling Service默認(rèn)將其轉(zhuǎn)換為1,2,4字節(jié)的內(nèi)存存儲(chǔ),當(dāng)其值為true時(shí),其對(duì)應(yīng)的值為1。如果你想當(dāng)然的直接將System.Boolean映射到Unmanaged側(cè)的bool類型而不做特別處理的話,你并一定會(huì)理解碰到編譯或者運(yùn)行時(shí)錯(cuò)誤,但是如果你嚴(yán)格的測(cè)試每個(gè)字段是,會(huì)驚訝的發(fā)現(xiàn)這些bool值跟你想象的不盡相同:有時(shí)正確,有時(shí)錯(cuò)誤。

經(jīng)過(guò)調(diào)試跟蹤,動(dòng)態(tài)打印sizeof(bool)來(lái)確認(rèn)Unmanaged側(cè)bool類型數(shù)據(jù)長(zhǎng)度后,你會(huì)發(fā)現(xiàn)System.Boolean默認(rèn)會(huì)被保存為4個(gè)字節(jié)長(zhǎng)度,而在macOS環(huán)境下(對(duì)于其它環(huán)境,需要自行認(rèn)證),C++定義的bool其實(shí)只有一個(gè)字節(jié)。因此當(dāng)你在Unmanaged側(cè)取bool值的時(shí)候,其實(shí)只讀取了System.Boolean的1/4個(gè)字節(jié)而已。而當(dāng)你聲明了多個(gè)連續(xù)的System.Boolean/bool值時(shí),可能在Unmanaged側(cè)讀取的這幾個(gè)bool值僅僅是第一個(gè)System.Boolean值的不同偏移字節(jié)而已。

知道了原因,解決方案自然就出來(lái)了,在Managed側(cè)強(qiáng)制聲明System.Boolean字段封送到Unmanaged側(cè)時(shí)僅使用一個(gè)字節(jié):

 

[MarshallAs(UnmanagedType.U1)]public bool TrueOrFalse;

 

坑二:字節(jié)對(duì)齊

對(duì)于C++開(kāi)發(fā)者來(lái)說(shuō),可能知道當(dāng)一個(gè)數(shù)據(jù)結(jié)構(gòu)(class or struct)中的各字段在內(nèi)存中進(jìn)行排列時(shí),會(huì)按照一個(gè)設(shè)定的裝箱長(zhǎng)度進(jìn)行字節(jié)對(duì)齊,例如:

 

struct MyStruct {
  int one;
  short two;
  int three;
  bool four;
}

 

假設(shè)在我們的平臺(tái)上,sizeof(int)=4, sizeof(short)=2, sizeof(bool)=1, 如果問(wèn)你sizeof(MyStruct)=?,你可能會(huì)馬上做個(gè)加法得到答案,但是答案不一定對(duì)。It depends! 假設(shè)我們是按照4個(gè)字節(jié)對(duì)齊,這上面的結(jié)構(gòu)體在內(nèi)存中實(shí)際排列如下圖:

 

 

 

 

了解這個(gè)對(duì)于我們編碼有兩個(gè)意義:

1、通過(guò)合理排列字段聲明順序來(lái)優(yōu)化存儲(chǔ)效率,內(nèi)存布局中不留空洞;

2、MarshalAsAttribute支持Layout.Explicit來(lái)進(jìn)行絕對(duì)定位,懂得了字節(jié)對(duì)齊可以配合Unmanaged側(cè)的內(nèi)存排列規(guī)則以保證字段長(zhǎng)度映射正確,不然同樣會(huì)發(fā)生字段長(zhǎng)度不一致帶來(lái)的困擾。

坑三:如何避免Double Free

Standard Marshalling Service/Interop marshaller總是試圖釋放Unmanaged側(cè)代碼分配的內(nèi)存9,這會(huì)帶來(lái)Double Free的問(wèn)題,如果碰到這種問(wèn)題,程序就會(huì)直接崩潰。

引用資料中舉了以下例子:

 

BSTR MethodOne (BSTR b) {  
     return b;  
}

 

如果這段代碼直接從Unmanaged側(cè)DLL中直接執(zhí)行,不會(huì)發(fā)生任何額外的內(nèi)存釋放;但是當(dāng)你從Managed側(cè)調(diào)用這個(gè)方法時(shí),b會(huì)被釋放兩次。

而更讓人抓狂的是,并沒(méi)有相應(yīng)的信息提示究竟是哪個(gè)指針,哪個(gè)字段被Double Free了,你唯一能做的就是一點(diǎn)點(diǎn)加代碼來(lái)驗(yàn)證自己猜測(cè)。所以,嚴(yán)格來(lái)說(shuō),并沒(méi)有一個(gè)萬(wàn)無(wú)一失的方案來(lái)避免Double Free,你唯一能做的就是通過(guò)測(cè)試來(lái)驗(yàn)證結(jié)果(有點(diǎn)盲擰魔方的味道了)。

有兩個(gè)基本的方法來(lái)解決Double Free的問(wèn)題:

1、按照官方文檔建議,在Unmanaged側(cè)通過(guò)使用CoTaskMemAlloc來(lái)分配內(nèi)存,通過(guò)此種方法分配的內(nèi)存,除非顯式調(diào)用了CoTaskMemFree方法(在Unmanaged側(cè)或者M(jìn)anaged側(cè)均可以調(diào)用),Interop Marshaller會(huì)嚴(yán)格保證不去釋放該內(nèi)存。使用這種方法可以靈活的在任意一側(cè)分配內(nèi)存,并在合適的時(shí)候在另一側(cè)釋放內(nèi)存。

2、但上面這種方法貌似僅適用于Windows平臺(tái),在macOS下沒(méi)有辦法使用(需要引用win32base.dll相關(guān)實(shí)現(xiàn))。在macOS下僅能通過(guò)在Mananged側(cè)調(diào)用Marshal.AllocCoTaskMem()方法分配內(nèi)存,并通過(guò)Marshal.FreeCoTaskMem()來(lái)在同一側(cè)進(jìn)行釋放(按照此方法分配的內(nèi)存指針傳入U(xiǎn)nmanaged側(cè)后,不要進(jìn)行任何釋放即可)。另外有一個(gè)不太可靠的workaround是:在Unmanaged一側(cè)創(chuàng)建的內(nèi)存指針盡量通過(guò)IntPtr傳遞,并在可能的時(shí)候?qū)?duì)象中一些指針類型的屬性值置空,以避免Double Free的發(fā)生。

坑四:virtual函數(shù)帶來(lái)的內(nèi)存布局變化

vptr和vtable是C++的一個(gè)概念:當(dāng)你定義的類型中有虛函數(shù)存在時(shí),內(nèi)存對(duì)象的第一個(gè)位置會(huì)存放一個(gè)vptr指針,該指針指向vtable(虛函數(shù)表)。因此當(dāng)你開(kāi)始創(chuàng)建的自定義類型一開(kāi)始沒(méi)有虛函數(shù)時(shí)(包括虛析構(gòu)函數(shù)virtual ~MyClass()),一切運(yùn)行正常。有一天你重構(gòu)此類型,增加了一些虛函數(shù):DUANG,一切都崩塌了!原因就在于Unmanaged側(cè)內(nèi)存對(duì)象的排列規(guī)則變了,原有的對(duì)象字段都被新加入的vptr往后面移位了。此時(shí)可能你唯一能做的就是通過(guò)Layout.Explicit來(lái)手工對(duì)齊每一個(gè)字段新的位置。

其它坑

坑一:針對(duì)M1芯片編譯

對(duì)于M1芯片的macOS系統(tǒng),編譯環(huán)信IM Unity SDK時(shí)候需要注意幾個(gè)問(wèn)題:

1、XCode編譯時(shí)需要Excluded Architecture中排除arm64架構(gòu)(很奇葩的設(shè)置,不是應(yīng)該排除x86嗎?)

2、類庫(kù)的依賴解決:通過(guò)otool -L命令來(lái)確認(rèn)相應(yīng)的plugin依賴的類庫(kù)位置都正確(文件路徑下文件確實(shí)存在),如果相應(yīng)文件不存在要手工拷貝文件到指定目錄:而新的macOS安全架構(gòu)限制了往系統(tǒng)目錄下(如/usr/lib)進(jìn)行任何改動(dòng),一個(gè)臨時(shí)的解決方法是通過(guò)install_name_tool工具主動(dòng)修改類庫(kù)依賴路徑到另一個(gè)可以放置新文件的位置(如home目錄)。

坑二:Delegate的正確使用姿勢(shì)

如果Managed側(cè)的編程語(yǔ)言是C#,則Delegate是實(shí)現(xiàn)回調(diào)的重要手段。在Unmanaged側(cè)完成期望工作時(shí)回調(diào)一個(gè)FunctionPtr即可實(shí)現(xiàn)通用的回調(diào)模式,而此FunctionPtr正是對(duì)應(yīng)到Managed側(cè)的Delegate。當(dāng)你的Delegate綁定到一個(gè)類對(duì)象上時(shí),你有兩種選擇:

 

namespace ChatSDK {

  //delegate definition
  public void delegate OnMessageReceived(EMMessage message);

  public class MyDelegate {
    //Option 1: field
    public OnMessageReceived MyMessageReceived;

    //Option 2: instance method
    public void OnMessageReceived(EMMessage message)
    {
     ...
    }
  
  }

  //send delegate method to unmanaged side
  MyDelegate md = new();
  NativeMethods.SetOnMessageReceivedCallback(md.MyMessageReceived); //option 1
  NativeMethods.SetOnMessageReceivedCallback(md.OnMessageReceived); //option 2
  
}

 

看起來(lái)兩個(gè)方式都沒(méi)有問(wèn)題,并且第二個(gè)方式看起來(lái)更順眼。但是這里隱藏著一個(gè)很深的坑,就是你選擇第二個(gè)方式的時(shí)候,如果你在回調(diào)方法實(shí)現(xiàn)中采用this.xxx方式引用時(shí),你會(huì)發(fā)現(xiàn)this = null!這是因?yàn)楫?dāng)你使用這種方式傳遞一個(gè)對(duì)象的方法作為回調(diào)方法指針時(shí),其實(shí)已經(jīng)丟失了Delegate.Target(也就是this)屬性。而通過(guò)第一種方式傳遞的是一個(gè)對(duì)象的屬性/字段,它和對(duì)象本身的綁定是不會(huì)在傳遞過(guò)程中丟失的。

至于該Delegate字段的定義可以在此類的構(gòu)造函數(shù)中通過(guò)以下方式實(shí)現(xiàn):

 

...
public MyDelegate() {
  MyMessageReceived = (EMMessage message) => { ... }
}
...

 

參考資料

1、List of Unity Games: https://en.wikipedia.org/wiki/List_of_Unity_games

2、Unity and .NET: https://docs.unity3d.com/Manual/overview-of-dot-net-in-unity.html

3、Unity Scripting-Plugins: https://docs.unity3d.com/Manual/Plugins.html

4、COM Interop: https://docs.microsoft.com/en-us/dotnet/standard/native-interop/cominterop

5、Platform Invoke: https://docs.microsoft.com/en-us/dotnet/standard/native-interop/pinvoke

6、如何調(diào)用Unmanaged DLL Functions:https://docs.microsoft.com/en-us/dotnet/framework/interop/consuming-unmanaged-dll-functions

7、Interop Marshalling:https://docs.microsoft.com/en-us/dotnet/framework/interop/interop-marshaling

8、Blittable Types: https://docs.microsoft.com/en-us/dotnet/framework/interop/blittable-and-non-blittable-types

9、Double Free: https://docs.microsoft.com/en-us/dotnet/framework/interop/default-marshaling-behavior

鏈接已復(fù)制,快去分享吧

企業(yè)網(wǎng)版權(quán)所有?2010-2024 京ICP備09108050號(hào)-6京公網(wǎng)安備 11010502049343號(hào)