Unity

Unity/C++混合编程全攻略——Swig篇

1、简介

在之前的文章中已经介绍了如何通过PInvoke来进行C#与C++的互操作,但是在实际项目当中开发者一个个去编写PInvoke接口是非常麻烦的一件事情,它既重复又枯燥。就像Lua这种胶水一样,如果需要手写C API是非常痛苦的事情,所以就像C++中有ToLua一样,C++互操作也有自动生成接口代码工具,这就是Swig。

Swig可以根据不同的语言生成各个语言与C++交互的接口,包括C#、Java、Python、Ruby等等。

这篇文章我们就会通过实际工程来了解Swig的使用方法以提高混合编程的效率。让混合编程变得更加可行与高效。

2、工程结构

Swig是开源并且跨平台的,所以不管你在哪个平台进行开发都能通过Swig生成接口。

不过大家在实际开发中使用PC平台会更多所以这篇文章也会主要通过PC上的Visual Studio来讲述如何通过Swig生成接口。

项目地址:https://github.com/vgvgvvv/Learn-UnityCPP

3、C++工程

首先在VS2017中建立一个空的win32项目,然后在工程目录下建立源代码目录src用于存放代码。

我们建立一个Test.h与Test.cpp放入其中。并且进行编写,还是和上次相同的编写一个简单的Add函数。

最好不要使用#pragma once,因为兼容性不如ifndef,所以使用较旧的编译器时容易出现问题,所以我们使用#ifndef的方式来替换#pragma了。

(勘误:评论中提醒pragma在现代编译器中已经能很好被支持了,之前没做好功课

现在我们已经完成了简单的C++工程,至于如何打dll在上一篇文章中已经有讲过就不再赘述了。

那现在我们来看如何接入Swig。

首先我们从Swig的官网下载Swig的最新版本,

我们可以选择Swig的源代码版本或者直接下载Windows的可执行文件,我们就直接下载编译完成的可执行文件了:

通常我们会把Swig放在一个存放Library的地方,但是为了方便大家开箱即用,我将Swig放置在了工程目录下的ThirdPart文件夹下以便于调用。

接下去我们开始编写Swig模板。模板是Swig导出接口的关键:

我们创建一个.i文件并且放置在工程目录中:

并且编写模板代码:

我们先不细究其中模板代码的意思,我们继续下一步:

右键.i文件选择属性,

将项类型设置为自定义生成工具,这样我们就可以通过选择Swig作为我们的自定义生成工具来处理.i文件。

点击应用之后会产生新的选项:

在常规中选择命令行并且写入:

echo on
$(SolutionDir)/../../thirdpart/swigwin-3.0.12/swig.exe -c++ -csharp -outdir “$(SolutionDir)/../../../UnityProj/UnityCppLearn/Assets/SwigTools/Interface” “%(FullPath)”
echo off

这段代码的意思就是调用swig,-c++设置源语言为c++ -csharp代表输出语言为C#,最终的-outdir代表的是C#接口的输出目录,而最后的参数代表的是.cxx文件的输出目录。

在这里我选择的C#输出目录是我事先创建的Unity工程中为接口准备的目录。

选择输出并且填入:

%(Filename)_wrap.cxx

代表输出文件的名字。

然后我们对工程进行编译:

发现会生成C#文件以及.cxx文件,这个时候,我们将.cxx文件包含到工程当中:

再次进行编译:

编译完成之后我们在输出目录中得到输出文件:

为了不需要手动拷贝,我们添加编译后事件进行拷贝:

在生成后事件中填入:

copy $(TargetDir)$(TargetFileName) $(SolutionDir)..\..\..\UnityProj\UnityCppLearn\Assets\Plugins\x86_64\$(TargetFileName)
copy $(TargetDir)$(TargetName).pdb $(SolutionDir)..\..\..\UnityProj\UnityCppLearn\Assets\Plugins\x86_64\$(TargetName).pdb

32位也一样 ,只不过把x86_64改为x86。

至此,C++部分的准备工作已经完全做好了。

4、Unity接入

我们在我们想要的目录下创建Unity工程,在这里我选择的工程根目录:

并且在Unity工程中创建如下目录:

SwigTools/Interface是用于存放C#接口的目录。而Plugins则是用于存放各种第三方库的目录,因为本文章只涉及到了windows平台而不涉及到跨平台,所以只创建x86与x86_64目录,代表不同的dll。详细关于Plugins的文章可以自行搜索,Unity中的特殊文件夹可以参照雨松的文章,本身这个属于Unity的基础知识,就不再进行赘述了,后面涉及到跨平台的时候我也会介绍别的平台的文件夹。

我们对C++工程进行重新打包,发现dll以及C#都已经被放入工程了。

现在我们已经可以开始编写代码并且使用了C++库了!

我们创建一个新的代码,我们就同样以上次的代码为例:

我们创建一个场景并且添加一个Text,我们将使用代码来进行计数:

将代码挂到Text上,并且运行。

调用成功了!我们连一句DllImport都没写!

至此,完成的项目我拉出了新分支:Hello_Swig。如果想要看工程目前阶段的情况,可以自行切换到目标分支。

5、Swig常用语法

详细的文档请参考官方文档,这里只做简单的一些介绍。

我在项目中写了一个简单的模板,用到了一些Swig的常用功能:

%module

%module代表的是当前.i模板所在的模块,相对应的,该.i文件也会生成相应的接口文件,命名就与%module声明的一样。所以该语法一般用在模板的开头。

%include

就像C/C++一样,include会将需要生成接口的文件进行生成。是必不可少的语法。

%{%}

这个关键字帮助我们在.cxx中加入一些代码,例如我们最常用的#include,这样我们才可以让.cxx调用到相应的代码。

使用C++/STL

我们可以通过包含各种swig所包含的.i文件来帮助我们实现STL库。

例如%incude “std_string.i”、%include “std_vector.i”

namespace std {
%template(BoolVector) vector<bool>;
};

使用这样的定义方式,Swig会为我们生成一个名为BoolVector的类型而不是未知类型。我们可以在目标语言中创建C++中的STL并且与C++中的Vector进行互操作。

需要注意的是,如果我们使用自定义类型而非基本类型或者使用指针作为模板类型,我们则需要事先导出自定义类型的定义,否则就会得到SWIGTYPE_p_类型名这样定义作为类型模板的Vector定义,这往往不是我们想要的。

使用指针

定义指针的方法如下:

%pointer_class(bool, BoolPointer);

通过这个定义我们Swig会为我们生成指针相对应的类,Swig再会生成类似于SWIGTYPE_p_bool这样的未定义类型,而是直接使用BoolPointer,并且我们能够自己在目标语言中申请内存,并且自己对内存进行管理。

使用数组

定义数组的方法如下。

%array_class(unsigned char, UnsignedCharArray);

通过这种方式我们可以导出相应的数组类型。我们可以在目标语言中创建C++中的数组,并且与C++中的数组进行互操作。

typemap

有时候我们会对导出的内容不满意,例如C++中导出的函数中的参数类型为char*,但是到了CSharp中被自动转换成了string,如果我们同样想用数组来接收,则需要通过typemap来进行类型映射。

我们通过该代码生成相应的接口,我写了一个简单的测试类:

测试

测试普通的Vector生成与使用

测试普通的指针申请与使用

测试普通的数组申请与使用

测试普通的对象申请与使用

至此,我已经将代码拉出一个新的分支:Swig_Usage,如果想要了解具体的项目情况可以直接切换至目标分支。

6、生成代码分析

因为本文章针对的主要是Unity中与C++的互操作,也就是说目标语言就是C#,所以这里仅仅分析的是C++中生成的.cxx文件与生成的CSharp代码。

C++部分

首先我们看一看,生成的.cxx文件:

该文件定义了所有C++需要导出的函数的定义时,我们会看到所有与类相关的函数其实都被导出成了纯函数,这也是我们能够预想到的,通过将namespace与类与函数名进行组合就可以生成独一无二的函数ID了。

其中SWIGEXPORT与SWIGSTDCALL分别是预定义的宏:

不过这也会为我们后面IOS中的IL2CPP导出C++函数带来隐患,这部分内容会放到跨平台部分进行讲解。

同时我们观察导出函数的方式,其实就是将我们的参数类型以及返回类型进行了扫描并且生成一个纯函数,最终进行导出。

除了我们自己导出的函数之外,Swig还提供了关于异常处理的回调。如果涉及到字符串操作Swig还会生成Swig中对String操作的回调。

不过这些代码可以在文档当中都找到对应的用处,在跨平台的时候我们会对一些内置函数也进行特殊处理,在后续的跨平台篇中继续讲。

C#部分

然后我们回到CSharp中,我们会看到生成的接口文件基本上就分为四类,一种类似于C++中的.cxx,命名规则为%moduleName%PINVOKE,里面定义了所有的DllImport函数,一种名为module名,而另外两种导出的接口文件就是我们在实际开发当中都会使用到的生成类型。

如果我们有事先定义类型或者模板,就会导出类似于BoolVector、BoolArray之类的有明确自定义类型名的导出类型,里面通常会有C++中对应的函数以及字段。

其中所有的函数都被导出成单独的纯函数以便被C#使用。

而倘若Swig无法识别导出.h中的类型,则会自动生成一个SWIGTYPE,例如指针如果没有事先进行定义的话就会生成类似于SWIGTYPE_p_%类型名%这样的类型接口,这类接口通常没有实际实现,有点类似于Lua中的UserData,你可以通过它直接传入到C++以达成调用的目的,却无法使用里面任何的函数或者字段。

这个时候你只能传递指针,而不能做其他任何的事情。

内存管理

在C++中我们需要自己管理内存,而C#则有自己的垃圾回收机制,那么我们在互操作当中如何进行内存管理呢?

我们随便打开一个导出的swig对象,我们会发现其实现了IDisposable接口,并且编写了相应的Dispose方法:

我们会发现,当进行析构调用的时候会调用Dispose函数,当指针不为空并且swigCMemOwn为true的时候会执行由Swig导出的delete函数,并且会将swigCPtr置为空,同时引发GC.SuppressFinalize。

那么我们会发现是否调用delete函数决定于swigCmemOwn的值,这个值代码上下文可以看出意义为是否由C#管理内存。

那么我们查找上下文:

我们看到构造函数传入了指针的时候需要同时传入一个cMemoryOwn,也就是决定是否内存有C#管理。

传入一个IntPtr意味着内存由C++申请,或者由外部传入。

然后我们看见其其他构造函数则传入的Own值全部为true,也就是由C#管理。

Swig的内存管理策略也非常清晰了:

C++申请的内存可以由使用者决定是否由C#进行释放,而C#申请的内存则一定会通过垃圾回收进行释放。

个人的建议是C++申请的内存由C++释放,而C#则当做普通对象进行使用。

总结

至此已经大致讲解了Swig的基本使用,想必大家一定有所收获,最好是跟着文章一起写一遍以加深印象,这类工具在使用当中才能慢慢了解到其中的许多功能。

本文中涉及到的也是冰山一角,但是基本的操作都已经列出,一些使用当中的技巧以及方法就见仁见智了,例如:我们在使用当中发现大量new了指针对象,所以就用pyhton改造生成的代码来添加对象池,具体的实施方法大家也可以自己尝试一下。

下一篇文章会讲到比较讨厌的跨平台,在跨平台篇中我们将会使用到CMake来帮助我们做编译的方方面面,工作平台也会从Windows转换到Mac上(毕竟Windows对于跨平台编译来说还是不够友好啊)

本人才疏学浅,如果有疏漏之处还请多多指正哇!

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注