
1. 项目概述Unity与DLL的“握手”协议在Unity项目的开发中尤其是涉及到性能敏感计算、复用已有C/C库或是需要与特定硬件、操作系统API深度交互时直接使用C#重写所有逻辑往往不是最高效的选择。这时动态链接库DLL就成了连接Unity的C#世界与底层原生代码世界的桥梁。简单来说这个过程就像是UnityC#向一个外部的、用C或C等语言编写的“黑盒”发送指令并接收其计算结果。我最近在一个需要处理复杂物理模拟和实时图像处理的项目中就大量使用了这个技术它成功地将核心算法的执行效率提升了数倍但整个集成过程也踩了不少坑。这个“加载与相互调用”的过程远不止是写两行[DllImport]那么简单。它涉及到平台差异Windows、macOS、Android、iOS、编译环境x86, x64, ARM、内存管理、错误处理以及发布流程等一系列环环相扣的细节。一个疏忽就可能导致编辑器里运行正常打包后却弹出“DllNotFoundException”或引发难以追踪的内存访问冲突。本文将基于我的实战经验拆解从DLL创建、Unity加载到实现双向调用的完整步骤并重点分享那些官方文档可能不会提及的“血泪教训”和实用技巧。2. 核心思路与方案选型为何以及如何选择P/Invoke2.1 为什么要在Unity中使用DLL在深入步骤之前我们先明确几个核心的使用场景这决定了你是否真的需要引入DLL性能瓶颈突破对于密集的数学运算如矩阵计算、信号处理、复杂的AI推理或特定的图形算法C/C的实现通常比C#的托管代码有显著的性能优势。将这部分逻辑封装成DLL是提升帧率的有效手段。代码复用与集成你的团队或公司可能已经拥有大量经过验证的、用C/C编写的核心业务库。通过DLL集成可以避免在Unity中重写保护既有投资并确保逻辑一致性。访问系统或硬件特定功能某些操作系统底层API或专用硬件驱动如特定的采集卡、传感器SDK只提供了C风格的接口。通过DLL调用是Unity访问这些功能的唯一途径。保护核心算法虽然可以被反编译但将关键算法编译成原生DLL相比托管C# DLL能增加一定的逆向工程难度。2.2 技术方案选型P/Invoke vs 托管C DLL在Unity中调用原生DLL主要依靠平台调用服务即P/Invoke。这是.NET框架包括Unity使用的Mono或IL2CPP提供的标准机制。另一种常被混淆的方案是创建“托管C DLL”即使用C/CLI这种DLL本身运行在.NET运行时上可以无缝与C#交互。但在Unity移动平台iOS/Android的IL2CPP后端下对C/CLI的支持非常有限或不存在因此对于需要跨平台尤其是包含移动端的Unity项目标准的、使用纯C/C编写的原生DLL配合P/Invoke是主流且可靠的选择。选择P/Invoke方案意味着你需要面对显式的函数声明在C#中必须精确地声明DLL中函数的名称、调用约定、参数和返回类型。手动内存与数据封送在C#的托管堆和DLL使用的非托管堆之间传递数据时需要仔细处理数据类型的转换和内存的分配/释放。平台特定的DLL文件你需要为每个目标平台Windows、macOS、Linux、Android ARMv7/ARM64、iOS分别编译对应格式的DLL。3. 实操全流程从编写DLL到Unity调用下面我将以一个具体的例子贯穿始终我们创建一个简单的数学运算DLL包含一个加法函数和一个修改传入数组的函数。3.1 第一步创建原生C/C DLL我们使用Visual Studio创建一个“动态链接库(DLL)”项目命名为NativeMathLib。核心头文件 (NativeMathLib.h):// 使用 extern C 来防止C编译器进行名称重整确保C#能通过原函数名找到它。 // __declspec(dllexport) 是Windows特有的导出声明Linux/macOS下通常用 __attribute__((visibility(default))) #ifdef _WIN32 #define EXPORT_API __declspec(dllexport) #else #define EXPORT_API __attribute__((visibility(default))) #endif extern C { // 函数1简单的整数加法 EXPORT_API int Add(int a, int b); // 函数2修改传入的浮点数数组例如每个元素乘以一个系数 // 注意这里传递指针和长度由调用者C#负责分配和管理数组内存。 EXPORT_API void MultiplyArray(float* array, int length, float multiplier); }核心源文件 (NativeMathLib.cpp):#include pch.h // VS预编译头 #include NativeMathLib.h int Add(int a, int b) { return a b; } void MultiplyArray(float* array, int length, float multiplier) { if (array nullptr) return; for (int i 0; i length; i) { array[i] * multiplier; } }注意这里有一个关键点MultiplyArray函数直接修改了传入的指针所指向的内存。这意味着C#端在调用此函数时传递的数组必须在非托管堆上有对应的、固定的内存块。我们稍后在C#部分会详细处理。编译时你需要根据目标平台选择正确的配置。例如为Unity Windows Standalone 64位目标编译就选择Release和x64。编译成功后你会在输出目录找到NativeMathLib.dll。3.2 第二步在Unity中准备DLL文件与C#封装组织DLL文件在Unity项目的Assets文件夹下创建一个合理的目录结构来存放不同平台的DLL。一种常见的结构是Assets/ └── Plugins/ ├── x86/ (存放 32位 Windows 的 .dll) │ └── NativeMathLib.dll ├── x86_64/ (存放 64位 Windows 的 .dll) │ └── NativeMathLib.dll ├── Android/ │ ├── armeabi-v7a/ (存放 ARMv7 的 .so) │ ├── arm64-v8a/ (存放 ARM64 的 .so) │ └── x86/ (存放 Android x86 的 .so) └── iOS/ (存放 .a 静态库iOS不支持动态加载 .dll/.so) └── libNativeMathLib.aUnity在构建时会根据目标平台自动选取对应文件夹下的插件。将上一步编译好的NativeMathLib.dll放入Plugins/x86_64/目录。创建C#封装类在Unity C#脚本中使用DllImport特性来声明外部函数。using System; using System.Runtime.InteropServices; using UnityEngine; public class NativeMathWrapper : MonoBehaviour { // 定义DLL的名称。不同平台下DLL的文件扩展名不同。 // Unity在运行时会自动处理这些平台差异。 #if UNITY_EDITOR_WIN || UNITY_STANDALONE_WIN private const string DllName NativeMathLib; // Windows下会自动查找 .dll #elif UNITY_EDITOR_OSX || UNITY_STANDALONE_OSX private const string DllName NativeMathLib; // macOS下会自动查找 .bundle #elif UNITY_ANDROID private const string DllName NativeMathLib; // Android下会自动查找 .so #elif UNITY_IOS private const string DllName __Internal; // iOS静态库使用固定名称 #else private const string DllName NativeMathLib; #endif // 声明Add函数 // CallingConvention.Cdecl 指定C语言的调用约定这是C/C DLL的常见约定。 [DllImport(DllName, CallingConvention CallingConvention.Cdecl)] public static extern int Add(int a, int b); // 声明MultiplyArray函数 // 参数类型float* 对应 C# 的 IntPtr (指针)但我们可以用更安全的方式。 [DllImport(DllName, CallingConvention CallingConvention.Cdecl)] public static extern void MultiplyArray(IntPtr array, int length, float multiplier); // 一个对开发者更友好的重载直接接受float[]数组。 // 此方法内部处理了将托管数组固定并获取指针的复杂操作。 public static void MultiplyArray(float[] array, float multiplier) { // 关键使用GCHandle固定托管数组防止GC在非托管调用期间移动内存。 GCHandle handle GCHandle.Alloc(array, GCHandleType.Pinned); try { IntPtr ptr handle.AddrOfPinnedObject(); // 获取数组首元素的内存地址 MultiplyArray(ptr, array.Length, multiplier); } finally { // 确保无论如何都释放GCHandle避免内存泄漏。 if (handle.IsAllocated) handle.Free(); } } void Start() { // 测试简单函数调用 int sum Add(5, 3); Debug.Log($Add(5, 3) {sum}); // 测试数组操作 float[] myArray { 1.0f, 2.0f, 3.0f, 4.0f }; Debug.Log(Original Array: string.Join(, , myArray)); MultiplyArray(myArray, 2.5f); Debug.Log(After MultiplyArray by 2.5: string.Join(, , myArray)); // 预期输出 2.5, 5.0, 7.5, 10.0 } }3.3 第三步处理更复杂的数据类型与回调实际项目中传递的不仅仅是基本类型和数组。结构体、字符串和回调函数是更常见的需求。1. 传递和返回结构体需要在C#中定义一个与C/C端内存布局完全一致的结构体并使用[StructLayout(LayoutKind.Sequential)]特性确保顺序有时还需要用[MarshalAs]指定字符串的封送方式。C端 (NativeMathLib.h):typedef struct { int id; float x; float y; char name[32]; // 固定长度的字符数组 } MyVector; EXPORT_API MyVector ProcessVector(MyVector vec);C#端:[StructLayout(LayoutKind.Sequential, CharSet CharSet.Ansi)] // 匹配C端的char public struct MyVector { public int id; public float x; public float y; [MarshalAs(UnmanagedType.ByValTStr, SizeConst 32)] public string name; } [DllImport(DllName, CallingConvention CallingConvention.Cdecl)] public static extern MyVector ProcessVector(MyVector vec);2. 实现DLL到C#的回调函数指针让DLL在特定事件发生时能调用回C#的函数。这需要将C#委托delegate转换为函数指针传递给DLL。C端 (NativeMathLib.h):// 定义回调函数类型 typedef void (*LogCallback)(const char* message); // 设置回调的函数 EXPORT_API void SetLogCallback(LogCallback callback);C#端:// 定义与C函数指针匹配的委托 [UnmanagedFunctionPointer(CallingConvention.Cdecl)] public delegate void LogCallbackDelegate(string message); [DllImport(DllName, CallingConvention CallingConvention.Cdecl)] public static extern void SetLogCallback(LogCallbackDelegate callback); // 一个具体的C#回调方法 private static void OnNativeLog(string msg) { Debug.Log($[Native Log]: {msg}); } void Start() { // 创建委托实例并传递 LogCallbackDelegate callback new LogCallbackDelegate(OnNativeLog); SetLogCallback(callback); // **极其重要**必须将委托实例保存在一个不会被GC回收的类变量中 // 否则委托被GC后DLL持有的函数指针将变成“悬空指针”导致崩溃。 _callbackHolder callback; } private static LogCallbackDelegate _callbackHolder; // 用于保持委托存活实操心得回调函数是内存泄漏和崩溃的重灾区。除了上述保存委托引用外还要确保DLL不会在回调结束后还试图调用它。通常需要在C#端提供一个Cleanup函数通知DLL清空回调指针并在OnDestroy或OnApplicationQuit时调用。4. 跨平台编译与构建部署详解这是将方案从Windows编辑器扩展到全平台的关键也是最容易出错的环节。4.1 为不同平台编译DLLWindows (x86/x64)使用Visual Studio在配置管理器中选择对应的解决方案平台即可。macOS通常使用Xcode创建Bundle或使用gcc/clang命令行编译为.dylib。在Unity中macOS插件也常用.bundle格式。Linux使用gcc编译为.so文件。Android需要使用Android NDK工具链进行交叉编译。你需要编写Android.mk或CMakeLists.txt文件并使用ndk-build或CMake生成针对armeabi-v7a、arm64-v8a等不同ABI的.so文件。iOSiOS对动态库的限制非常严格。通常的做法是编译成静态库.a文件在构建Xcode工程时链接进去。使用Xcode或CMake指定目标为iOS并选择正确的架构arm64, armv7等。4.2 Unity插件设置Inspector面板在Unity编辑器中选中你的DLL文件例如Plugins/x86_64/NativeMathLib.dll在Inspector面板中可以进行精细的平台控制Select platforms for plugin勾选该DLL生效的平台如只勾选Editor和Standalone下的Windows。Load SettingsLoad on StartupDLL是否在游戏启动时自动加载。通常保持默认勾选。Preload预加载。对于核心插件建议勾选。Platform Settings可以为每个平台单独设置CPU架构x86, x86_64, ARMv7, ARM64等。为Android.so文件设置尤为重要选中Plugins/Android/arm64-v8a/libNativeMathLib.so在Inspector中确保Android平台被勾选并且CPU选项是ARM64。其他ABI的.so文件同理。4.3 IL2CPP与代码裁剪AOT编译当Unity使用IL2CPP后端尤其是对于iOS和部分Android发布选项时它会将C#代码转换为C然后编译为原生代码。这个过程会进行积极的代码裁剪Code Stripping移除它认为未被使用的代码。致命问题如果你的C#代码通过反射、动态加载或像DllImport这样隐式的方式调用DLL函数IL2CPP的静态分析可能无法识别这些调用从而将相关的封装类、方法甚至整个DLL依赖裁剪掉导致运行时找不到函数。解决方案链接.xml文件在Assets目录下创建或使用已有的link.xml文件告诉IL2CPP不要裁剪特定的类型或程序集。linker assembly fullnameAssembly-CSharp !-- 保留整个 NativeMathWrapper 类及其所有成员 -- type fullnameNativeMathWrapper preserveall/ /assembly /linker使用[Preserve]特性在封装类或方法上添加UnityEngine.Scripting.Preserve特性。[UnityEngine.Scripting.Preserve] public class NativeMathWrapper { ... }在Player Settings中降低代码裁剪等级Edit - Project Settings - Player - Other Settings - Configuration - Code Stripping尝试设置为Low或Minimal。但这会增加包体大小应作为最后手段。5. 调试、排错与性能优化实战记录5.1 常见错误与排查清单错误现象可能原因排查步骤DllNotFoundException1. DLL文件未放入正确的Plugins子目录。2. DLL文件名或路径在DllImport中声明错误。3. 目标平台不对如x86 DLL用在x64进程。4. DLL依赖的其他动态库缺失。1. 检查构建日志看DLL是否被正确复制到StagingArea。2. 使用Process Explorer(Windows)或otool -L(macOS)检查DLL的依赖项。3. 在编辑器下确认DLL的Inspector平台设置正确。EntryPointNotFoundException1. C#中声明的函数名与DLL导出名不匹配注意名称重整。2. 调用约定(CallingConvention)不匹配。3. 函数签名参数/返回值类型不匹配。1. 使用dumpbin /exports YourDll.dll(Windows)或nm -gU YourLib.so(macOS/Linux)查看确切的导出函数名。2. 确保C头文件中使用了extern C。AccessViolationException1. 传递了无效的指针如IntPtr.Zero。2. 在非托管代码中访问了已释放的托管内存如GCHandle过早释放。3. 缓冲区溢出如数组长度传递错误。1. 检查所有传入DLL的指针是否有效。2. 确保GCHandle的生命周期覆盖整个非托管调用。3. 在C端加入边界检查在C#端验证参数。Marshaling异常托管与非托管数据类型映射错误。1. 仔细核对结构体的[StructLayout]和字段顺序、对齐。2. 字符串传递使用[MarshalAs]明确指定格式ANSI/Unicode。仅在打包后失败1. IL2CPP代码裁剪问题。2. 发布构建的插件设置与编辑器不同。3. 移动平台权限问题如Android未请求网络权限但DLL需要联网。1. 检查并配置link.xml。2. 对比编辑器与发布版的Plugins文件夹内容。3. 检查Player Settings中的权限设置。5.2 性能优化与内存安全要点减少P/Invoke调用开销每次P/Invoke调用都有固定的开销。避免在每帧的Update循环中调用非常简单的DLL函数。取而代之的是将批量数据一次性传入DLL处理或者将频繁调用的逻辑完全移至DLL内部。固定与复用缓冲区对于需要频繁在C#和DLL之间传递的大型数组如图像数据不要每次调用都创建新的数组并用GCHandle.Alloc固定。应该在初始化时分配一块固定的非托管内存使用Marshal.AllocHGlobal并在整个生命周期内复用这块内存最后再释放。这能显著减少GC压力和固定开销。private IntPtr _sharedBuffer IntPtr.Zero; private int _bufferSize 1024 * 1024; // 1MB void Initialize() { _sharedBuffer Marshal.AllocHGlobal(_bufferSize); } void ProcessWithDLL() { // 将C#数据复制到固定缓冲区 // Marshal.Copy(sourceArray, 0, _sharedBuffer, length); // 调用DLL处理 _sharedBuffer // 将结果从 _sharedBuffer 复制回C#数组 } void OnDestroy() { if (_sharedBuffer ! IntPtr.Zero) { Marshal.FreeHGlobal(_sharedBuffer); _sharedBuffer IntPtr.Zero; } }明确所有权与生命周期谁分配谁释放。如果DLL返回了一个需要C#端释放的内存指针DLL必须提供一个明确的FreeMemory函数。C#端必须成对调用否则会造成内存泄漏。文档和约定至关重要。6. 高级应用在DLL中创建Unity可用的原生插件接口对于更复杂的交互比如DLL需要直接操作Unity的渲染纹理或调用Unity的API上述基于P/Invoke的简单函数调用就不够了。这时需要用到Unity的原生插件接口Native Plugin Interface。这允许你的C代码直接包含Unity的头文件如IUnityGraphics.h,IUnityInterface.h并获取Unity的渲染事件回调、图形设备指针等。你可以实现一个UnityRenderingEvent回调在特定的渲染阶段如kUnityRenderingEventBeforeDrawCall执行你的原生渲染命令。你也可以通过IUnityInterfaces获取ID3D11Device(DirectX)或GLuint(OpenGL)来进行底层图形操作。这个主题非常深入通常用于开发高级图形效果、VR/AR SDK集成或特定的中间件。其核心步骤包括在C项目中包含Unity原生插件头文件。实现UnityPluginLoad和UnityPluginUnload函数用于初始化和清理。通过IUnityInterfaces获取所需的Unity系统接口。注册自定义的回调函数如渲染事件、日志回调。在C#端使用GL.IssuePluginEvent或CommandBuffer.IssuePluginEvent来触发这些原生渲染事件。这种方式的集成度更高功能更强大但复杂性和对平台底层知识的依赖也呈指数级增长。在决定走这条路之前务必评估是否真的有必要因为大部分功能通过传统的P/Invoke数据交换结合Unity自身的C# API已经可以实现。