技术盘点:Unity SDK 开发中有哪些大坑?
引言 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 FreeStandard 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 definitionpublic void delegate OnMessageReceived(EMMessage message);
public class MyDelegate {//Option 1: fieldpublic OnMessageReceived MyMessageReceived;
}
//send delegate method to unmanaged sideMyDelegate md = new();NativeMethods.SetOnMessageReceivedCallback(md.MyMessageReceived); //option 1NativeMethods.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/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、如何调用 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-u vv
评论