Untiy作为游戏引擎和内容开发平台,吸引了泛滥游戏开发者,基于其开发的游戏更是不胜其数。具体请参见1。
环信作为当先的即时通讯云服务商,在游戏行业也进行了继续的摸索和研发投入。在产品公布的晚期(2015年)就推出了Unity SDK,帮忙游戏开发者疾速实现游戏场景下诸如世界频道,游戏公会、组队群聊,1对1私聊等性能,平安稳固的服务也为游戏玩家带来了极佳的实时沟通体验。
2021年第二季度,环信IM Unity SDK进行了重构改版,环信IM Unity SDK 2.0正式公布,次要改良包含如下:
1、迭代更新,更加实用的API接口
2、IM+Push加强性能的补全
3、C#语言层面引入了版本7.0 – 9.0之后的一些新语法改良
4、特地的,减少了PC端Unity Editor环境下编译调试反对,大大晋升了开发效率
在过来的一段时间里,笔者也参加了相应的研发工作。在整个过程中,为了解决各种问题,不仅要到处翻阅材料,还要尝试各种办法和参数组合。其间也经验了各种程序解体甚至零碎解体,诡异的程序体现一次次让开发人员大刀阔斧,到处碰壁,当真像深夜里行走在迷宫之中,手里还拿着一个待破解的魔方。“此路不通,请绕行!”,是在一次次的尝试后无奈的感慨和难舍的放弃。而一旦问题最初失去圆满解决,又宛如飞入云端,以上帝视角鸟瞰一片片迷宫,所有又显得那么天经地义,简约琐细但又丝丝入扣,这样的苦尽甘来也算是做程序员能享受到的微小喜悦和满足。
不敢独享,特记录下一些心得供大家参考,也欢送.NET平台资深玩家批评指正。以下,Enjoy!
开发概览:非托管插件开发(Native/Unmanaged Plugin)
Unity是基于Microsoft .Net Framework开发的游戏引擎2,它采纳了开源的.NET Platform,并依赖此框架来实现跨硬件设施和运行时(操作系统)的指标,也是所谓的”Write once, run anywhere”。在语言方面,Unity抉择C#作为次要的脚本编程语言,尽管.NET平台自身反对的语言有很多种。
进一步,Unity反对Mono和ILC2PP两种脚本框架(Scripting Backends)。特地的,Unity Editor采纳的是Mono脚本框架。
个别的,游戏类库开发者能够抉择间接用C#语言开发,指标类库能够实现基于.NET Framework根底性能之上的高级性能,这类插件称之为Managed Plugin(托管插件)。因为环信IM外围SDK曾经基于C++开发,因而咱们抉择另一种Native Plugin(本地插件)的形式,正是它把咱们引向了迷宫之旅。两种类型的Plugin介绍,参见3。
可怜的是,Unity网站上对于Native Plugin的相干介绍少只又少,想要理解它的具体细节还要去参考Microsoft MSDN文档。作为中规中矩的文档介绍,微软的文档是合格的,然而,当你真正上手编程时就会发现,这些远远不够:上面记录的一些坑点就很难在相应的文档中失去间接的提醒;而要通过Google大法,联合其余程序员留下的蛛丝马迹,再加上本人一直的调试来最终确认。
在微软文档上下文中,Unity Native Plugin有个另外的名字:Unmanaged Plugin,即非托管插件。简略来讲,Managed Plugin生存在.NET Framework的运行时环境(相似于Java的JVM),而Unmanaged Plugin则生存在这个运行时环境之外,也即和运行时环境是兄弟的关系。如果你本来的类库实现满足微软的COM(Component Object Model)标准,那天然最好是应用COM Interop4的互操作形式;而环信IM SDK自身是纯C++实现,因而采纳了Platform Invoke5(简称P/Invoke)形式,本文剩下的内容均是基于P/Invoke。
下图则概要形容了Managed和Unmanaged区域代码之间相互操作的形式:
更具体的,为了实现对于Unmanaged DLL function的调用,只须要简略的4步6:
1、确认DLL类库中须要被操作的函数;
2、创立一个C#类来关联被操作的这些函数(给函数穿上一个马甲,以便集中管理和重复调用);
3、应用DllImport标记在受管侧(C#)定义函数原型;
4、在受管侧随便调用相干非托管区域函数。
上图中,Standard marshalling service即负责将数据在两个区域进行封装/解封装传送(marshall/unmarshall),它次要定义了数据在两个不同内存区域进行拷贝(Copy)和援用(Reference)的规定7,而迷宫中的坑次要是和这些具体规定无关。
坑王驾到之封送(Marshall/Unmarshall)中的那些坑
坑一:sizeof(bool) = ?
绝大多数的根本类型属于Blittable Types8:如System.Byte, System.Single等。System.Boolean尽管不属于Blittable types,然而Standard Marshalling Service默认将其转换为1,2,4字节的内存存储,当其值为true时,其对应的值为1。如果你想当然的间接将System.Boolean映射到Unmanaged侧的bool类型而不做特地解决的话,你并肯定会了解碰到编译或者运行时谬误,然而如果你严格的测试每个字段是,会诧异的发现这些bool值跟你设想的不尽相同:有时正确,有时谬误。
通过调试跟踪,动静打印sizeof(bool)来确认Unmanaged侧bool类型数据长度后,你会发现System.Boolean默认会被保留为4个字节长度,而在macOS环境下(对于其它环境,须要自行认证),C++定义的bool其实只有一个字节。因而当你在Unmanaged侧取bool值的时候,其实只读取了System.Boolean的1/4个字节而已。而当你申明了多个间断的System.Boolean/bool值时,可能在Unmanaged侧读取的这几个bool值仅仅是第一个System.Boolean值的不同偏移字节而已。
晓得了起因,解决方案天然就进去了,在Managed侧强制申明System.Boolean字段封送到Unmanaged侧时仅应用一个字节:
[MarshallAs(UnmanagedType.U1)]public bool TrueOrFalse;
坑二:字节对齐
对于C++开发者来说,可能晓得当一个数据结构(class or struct)中的各字段在内存中进行排列时,会依照一个设定的装箱长度进行字节对齐,例如:
struct MyStruct {
int one;
short two;
int three;
bool four;
}
假如在咱们的平台上,sizeof(int)=4, sizeof(short)=2, sizeof(bool)=1, 如果问你sizeof(MyStruct)=?,你可能会马上做个加法失去答案,然而答案不肯定对。It depends! 假如咱们是依照4个字节对齐,这下面的构造体在内存中理论排列如下图:
理解这个对于咱们编码有两个意义:
1、通过正当排列字段申明程序来优化存储效率,内存布局中不留空洞;
2、MarshalAsAttribute反对Layout.Explicit来进行相对定位,懂得了字节对齐能够配合Unmanaged侧的内存排列规定以保障字段长度映射正确,不然同样会产生字段长度不统一带来的困扰。
坑三:如何防止Double Free
Standard Marshalling Service/Interop marshaller总是试图开释Unmanaged侧代码调配的内存9,这会带来Double Free的问题,如果碰到这种问题,程序就会间接解体。
援用材料中举了以下例子:
BSTR MethodOne (BSTR b) {
return b;
}
如果这段代码间接从Unmanaged侧DLL中间接执行,不会产生任何额定的内存开释;然而当你从Managed侧调用这个办法时,b会被开释两次。
而更让人抓狂的是,并没有相应的信息提醒到底是哪个指针,哪个字段被Double Free了,你惟一能做的就是一点点加代码来验证本人猜想。所以,严格来说,并没有一个十拿九稳的计划来防止Double Free,你惟一能做的就是通过测试来验证后果(有点盲拧魔方的滋味了)。
有两个根本的办法来解决Double Free的问题:
1、依照官网文档倡议,在Unmanaged侧通过应用CoTaskMemAlloc来分配内存,通过此种办法调配的内存,除非显式调用了CoTaskMemFree办法(在Unmanaged侧或者Managed侧均能够调用),Interop Marshaller会严格保障不去开释该内存。应用这种办法能够灵便的在任意一侧分配内存,并在适合的时候在另一侧开释内存。
2、但下面这种办法貌似仅实用于Windows平台,在macOS下没有方法应用(须要援用win32base.dll相干实现)。在macOS下仅能通过在Mananged侧调用Marshal.AllocCoTaskMem()办法分配内存,并通过Marshal.FreeCoTaskMem()来在同一侧进行开释(依照此办法调配的内存指针传入Unmanaged侧后,不要进行任何开释即可)。另外有一个不太牢靠的workaround是:在Unmanaged一侧创立的内存指针尽量通过IntPtr传递,并在可能的时候将对象中一些指针类型的属性值置空,以防止Double Free的产生。
坑四:virtual函数带来的内存布局变动
vptr和vtable是C++的一个概念:当你定义的类型中有虚函数存在时,内存对象的第一个地位会寄存一个vptr指针,该指针指向vtable(虚函数表)。因而当你开始创立的自定义类型一开始没有虚函数时(包含虚析构函数virtual ~MyClass()),所有运行失常。有一天你重构此类型,减少了一些虚函数:DUANG,所有都崩塌了!起因就在于Unmanaged侧内存对象的排列规定变了,原有的对象字段都被新退出的vptr往后面移位了。此时可能你惟一能做的就是通过Layout.Explicit来手工对齐每一个字段新的地位。
其它坑
坑一:针对M1芯片编译
对于M1芯片的macOS零碎,编译环信IM Unity SDK时候须要留神几个问题:
1、XCode编译时须要Excluded Architecture中排除arm64架构(很奇葩的设置,不是应该排除x86吗?)
2、类库的依赖解决:通过otool -L命令来确认相应的plugin依赖的类库地位都正确(文件门路下文件的确存在),如果相应文件不存在要手工拷贝文件到指定目录:而新的macOS平安架构限度了往系统目录下(如/usr/lib)进行任何改变,一个长期的解决办法是通过install_name_tool工具被动批改类库依赖门路到另一个能够搁置新文件的地位(如home目录)。
坑二:Delegate的正确应用姿态
如果Managed侧的编程语言是C#,则Delegate是实现回调的重要伎俩。在Unmanaged侧实现冀望工作时回调一个FunctionPtr即可实现通用的回调模式,而此FunctionPtr正是对应到Managed侧的Delegate。当你的Delegate绑定到一个类对象上时,你有两种抉择:
namespace ChatSDK {
//delegate definition
public void delegate OnMessageReceived(EMMessage message);
public class MyDelegate {
//Option 1: fieldpublic OnMessageReceived MyMessageReceived;//Option 2: instance methodpublic 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
}
看起来两个形式都没有问题,并且第二个形式看起来更悦目。然而这里暗藏着一个很深的坑,就是你抉择第二个形式的时候,如果你在回调办法实现中采纳http://this.xxx形式援用时,你会发现this = null!这是因为当你应用这种形式传递一个对象的办法作为回调办法指针时,其实曾经失落了Delegate.Target(也就是this)属性。而通过第一种形式传递的是一个对象的属性/字段,它和对象自身的绑定是不会在传递过程中失落的。
至于该Delegate字段的定义能够在此类的构造函数中通过以下形式实现:
...
public MyDelegate() {
MyMessageReceived = (EMMessage message) => { ... }
}
...
参考资料
1、List of Unity Games: https://en.wikipedia.org/wiki...
2、Unity and .NET: https://docs.unity3d.com/Manu...
3、Unity Scripting-Plugins: https://docs.unity3d.com/Manu...
4、COM Interop: https://docs.microsoft.com/en...
5、Platform Invoke: https://docs.microsoft.com/en...
6、如何调用Unmanaged DLL Functions:https://docs.microsoft.com/en...
7、Interop Marshalling:https://docs.microsoft.com/en...
8、Blittable Types: https://docs.microsoft.com/en...
9、Double Free: https://docs.microsoft.com/en...