使用 CMake 为 Unity3D 制作基于 C++11 的 Android、iOS 跨平台插件

在最近的游戏开发中,我们需要一个 Log 组件,用于在线上游戏运行时实时记录 Log 信息到用户的手机上。由于 Log 组件不能让游戏产生卡顿,对这个 Log 组件的性能有很高的要求,所以我决定使用 mmap 的技术来开发这个 Log 组件。关于这个 Log 组件的技术方案的选择,请参看:
+ 微信终端跨平台组件 mars 系列(一) - 高性能日志模块xlog;
+ 微信跨平台组件mars-xlog架构分析及迁移思路;

Unity 5.6 的 Mono 下的 C# 并不支持 mmap,所以我考虑使用 C++ Native Plugin 来实现这个 Log 组件,需要同时支持 Android、iOS、OSX 等系统。Unity 在不同平台下对 Native Plugin 的支持格式也是不一样的,Android 下如果使用 Java 的方式来开发 Native Plugin,可以使用 Jar 或者 AAR 格式,而如果使用 C++ 来开发,则必须使用 .so 动态链接库的格式;而对于 iOS ,则不支持 .so 格式,可以使用 .m, .mm, .c, .cpp 的源码形式或者打包成 .a 的静态库的形式。类似的,对于桌面平台的 Native 插件,对于 OSX 则必须要打包成 .bundle 格式,对于 Windows 必须要是 .DLL 格式,而 Linux 下面则是 .so 格式。具体请参考 Unity 文档:
+ Building Plugins for iOS ;
+ Building and using plug-ins for Android ;
+ Building Plugins for Desktop Platforms

虽然我们使用 C++11 来开发插件,可以保证在源码上能够共享一套源码,但是同样需要为不同的平台编译成不同格式的库。这就需要一种跨平台的编译方案,我选择了 CMake 的方式,CMake 应该是目前最成熟的跨平台开发编译工具链了,最新版的 Android NDK 开发也改成使用 CMake 来编译 NDK 代码,而对于 CMake 支持比较好的 IDE 有Jetbrain 公司的 CLion 。下面我会实现一个简单版本的 Log 库 libmylog,这个库只是演示目的,真正的高性能 Log 库要远比它复杂。在这个 libmylog 里面我会使用 C++11 的 thread、mutex 等新特性,并演示如何在 Unity C# 和 C++ 层面进行数据的传递和事件的回调。

在 CLion IDE 中新建工程

我们新建一个 CLion 工程,由于我们要实现一个通用库,并且使用 C++11 标准,所以我们选择下图所示的配置信息:

我们会得到如下所示的工程,其中 cmake-build-debug/ 这个文件夹我们不需要关心,这是 CMake 自己使用的;library.h 和 library.cpp 就是代码文件,很简单现在还只是一个 Hello World 的 Demo 代码,后续我们会把这两个文件删除,然后新建我们自己的 C++ 代码源文件;CMakeLists.txt 这是整个工程的 CMake 配置文件,包括你编译哪些文件,如何编译,对 Android 和 iOS 平台如何进行交叉编译等,都会在这个文件里面进行配置。

我们先来粗略的讲解下 CMakeLists.txt 这个文件吧,这里面涉及到 CMake 的语法规则,大家请自行学习。

在上面的 CMake 脚本中,第一行我们规定了 CMake 的最低版本要求,第二行我们给这个工程起了一个名字叫 mylog。然后我们又规定了使用 C++11 标准来进行开发,这个 CMake_CXX_STANDARD 很重要,直接决定了我们是否能够使用 C++11 的新特性。最后一行,使用 add_library 我们新建一个 mylog 库,并让这个库包含 library.h 、library.cpp 这两个源文件。注意,我们在工程 project(mylog) 下面可以有很多个库,只需要使用 add_library 即可创建库。每个库可以相互独立、可以依赖于不同的代码源文件、设置不同的编译选项等。

 add_library(mylog1, ${SOURCE_FILES})
 add_library(mylog2, ${SOURCE_FILES}) 

在开发工程中,我们一般要现在先在 OSX 或者 Windows 上把相关的代码测试通过后,才会着手进行 Android、iOS 上的交叉编译,然后发布到 Android、iOS 真机上进行测试。要测试我们的库,我们就需要一个可执行程序来调用我们的库。在 Unity3D 游戏中这个可执行程序就是我们自己的游戏,我们会在游戏里面调用这个库的相关函数,但是在开发过程中我们一般是写一个 Demo 的程序,在这个 Demo 里面调用库函数,来测试这个库是否功能完善。
我们使用 add_executable 来添加可执行程序 demo,并让这个 demo 依赖于 mylog 库。

set(DEMO_SOURCE_FILES Test/main.cpp)
add_executable(demo ${DEMO_SOURCE_FILES})
target_link_libraries(demo mylog)

Reload CMake Project 后你就可以看到运行选型里面出现了 demo 这个目标。

然后,我们只需要在 main.cpp 里面调用库函数就可以测试了。

实现 mylog 库

我们很容易的可以实现下面的演示代码,由于我们是在 Mac 平台上进行测试,所以可以把 mylog 设置成静态库,然后可执行程序 Demo 中的 main.cpp 中是测试 mylog 代码。



编译运行之后,你会看到左侧生成了 libmylog.a 库和 demo 可执行程序,在底部的控制台中也输出了对应的 Log 输出信息。我们的 Demo 做好了,而且使用到了 C++11 的 thread 和 mutex 新特性。为了在 Mac 或者 Windows 下面能够打包出针对 Android 或者 iOS 的库,我们就需要在编译的根据 Android 或者 iOS 的真机下面的工具链和指令架构进行编译,这就是 CMake 交叉编译。

CMake 交叉编译

由于在不同的平台上需要的库的类型都不一样,所以我们自然需要在 CMakeLists.txt 中按照平台类型进行区分。需要注意的是,Android 上面使用 C++11 要记得 set(CMAKE_ANDROID_STL_TYPE c++_shared),否则 std::thread 、std::mutex 等特性是无法使用的。而当集成到 Unity 时,除了要拷贝 libmylog.so 外,还要记得拷贝 libc++_shared.so 这个动态库,否则会找不到 C++ 库。具体的交叉编译配置过程我不再赘述,直接上截图,在代码里我都写了注释。
CMakeLists.txt 内容如下:

cmake_minimum_required(VERSION 3.9)
project(mylog)

# 设置使用 C++11 标准
set(CMAKE_CXX_STANDARD 11)

# 编译哪些源码文件
set(SOURCE_FILES ./src/MyLog.cpp ./src/MyLog.h)

# 编译 Debug 模式还是 Release 模式
set(CMAKE_BUILD_TYPE Release)

# 根据 OSX、Android、iOS 等不同平台,分别设置不同的配置
if (OSX)
    add_library(mylog MODULE ${SOURCE_FILES})

    # OSX 下面的 Unity 插件要是 mylog.bundle 格式的
    set_target_properties(mylog PROPERTIES
            BUNDLE TRUE
            )

    # 设置编译器
    set(CMAKE_C_COMPILER clang)
    set(CMAKE_CXX_COMPILER clang++)

    # 设置代码库的输出路径
    set(OUT_PATH ${CMAKE_CURRENT_SOURCE_DIR}/out/OSX/)

elseif(IOS)
    add_library(mylog STATIC ${SOURCE_FILES})

    # 设置编译器, 由于 iOS 和 OSX 使用的是相同的编译器,所以此处设置为 Mac 上的Clang 的路径
    # 但是对于 Android 来讲,交叉编译的环境为 NDK 的编译器环境,所以需要对 Android 设置为 NDK 下面的编译器路径。
    set(CMAKE_C_COMPILER clang)
    set(CMAKE_CXX_COMPILER clang++)

    # 最低支持的 iOS 版本
    set(CMAKE_XCODE_ATTRIBUTE_IPHONEOS_DEPLOYMENT_TARGET "8.1")

    # 支持的指令架构
    set(CMAKE_OSX_ARCHITECTURES "armv7 arm64")

    # 使用哪个 iOS SDK 进行打包,这里需要注意是对 iPhone 真机打包还是 iPhone 模拟器打包,二者的 SDK 是不同的。
    # 我们这里使用的是 iPhone 真机的 SDK。
    set(CMAKE_OSX_SYSROOT
            /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS11.0.sdk)

    # 设置代码库的输出路径
    set(OUT_PATH ${CMAKE_CURRENT_SOURCE_DIR}/out/iOS/)

elseif(ANDROID)
    add_library(mylog SHARED ${SOURCE_FILES})

    # 由于 Unity 在 Android 下面对 C++ 只支持 .so 的动态库格式,
    # 而如果你用 Mac 机编译时,默认的动态库格式为 .dylib,
    # 所以我们需要显示的使用 .so 后缀。
    set_target_properties(mylog PROPERTIES
            SUFFIX ".so"
            )

    # 设置代码库的输出路径
    set(OUT_PATH ${CMAKE_CURRENT_SOURCE_DIR}/out/Android/${ANDROID_ABI}/)

else()
    add_library(mylog STATIC ${SOURCE_FILES})
    set(CMAKE_C_COMPILER clang)
    set(CMAKE_CXX_COMPILER clang++)
    set(OUT_PATH ${CMAKE_CURRENT_SOURCE_DIR}/out/else/)
endif()


# 设置代码库的输出路径, 对 Debug 和 Release 模式都设置成同一个路径
set_target_properties(mylog PROPERTIES
        LIBRARY_OUTPUT_DIRECTORY ${OUT_PATH}
        ARCHIVE_OUTPUT_DIRECTORY ${OUT_PATH}
        LIBRARY_OUTPUT_DIRECTORY_RELEASE ${OUT_PATH}
        ARCHIVE_OUTPUT_DIRECTORY_RELEASE ${OUT_PATH}
        LIBRARY_OUTPUT_DIRECTORY_DEBUG ${OUT_PATH}
        ARCHIVE_OUTPUT_DIRECTORY_DEBUG ${OUT_PATH}
        LIBRARY_OUTPUT_DIRECTORY_RELEASE ${OUT_PATH}
        ARCHIVE_OUTPUT_DIRECTORY_RELEASE ${OUT_PATH}
        )

###############################################################################
#
# Executable
#
###############################################################################
#set(DEMO_SOURCE_FILES Test/main.cpp)
#add_executable(demo ${DEMO_SOURCE_FILES})
#target_link_libraries(demo mylog)

只需要运行 shell 脚本即可编译成对应的代码库,比如 android_builder.sh:

#!/bin/sh

####################################### arch of x86 ########################################
ARCH=x86
PROJECT_DIR="build/Android/"${ARCH}
rm -rf ${PROJECT_DIR}
mkdir ${PROJECT_DIR}

cmake -DCMAKE_TOOLCHAIN_FILE=./ToolchainFiles/android.cmake \
-DANDROID_ABI=${ARCH} \
-H. -B${PROJECT_DIR}

cmake --build ${PROJECT_DIR}


######################################## arch of armeabi-v7a ########################################
ARCH=armeabi-v7a
PROJECT_DIR="build/Android/"${ARCH}
rm -rf ${PROJECT_DIR}
mkdir ${PROJECT_DIR}

cmake -DCMAKE_TOOLCHAIN_FILE=./ToolchainFiles/android.cmake \
-DANDROID_ABI=${ARCH} \
-H. -B${PROJECT_DIR}

cmake --build ${PROJECT_DIR}

上述脚本中使用了 android.cmake 文件,内容如下:

set(ANDROID True)
set(CMAKE_SYSTEM_NAME Android)
set(CMAKE_SYSTEM_VERSION 16) # API LEVEL
set(CMAKE_ANDROID_ARCH_ABI ${ANDROID_ABI})
set(CMAKE_ANDROID_NDK /Users/potu/Library/Android/ndk/android-ndk-r10e)
set(CMAKE_ANDROID_NDK_TOOLCHAIN_VERSION clang)
set(CMAKE_ANDROID_STL_TYPE c++_shared)

上面几个代码是主要的代码,完整的工程请从下面这个链接下载 mylog.zip

集成到 Unity3D

我们运行上面的 android_builder.sh 、ios_builder.sh 、osx_builder.sh 后会得到对应格式的代码库,如下图所示。

要把这些代码库拷贝到 Unity 工程中,并记得设置相应的 Platform Setting。

为了方便在 C# 使用这些代码库,我们一般会写一个 C# 的 Wrapper 来封装这些 Native 的代码,如下所示:

using System.Runtime.InteropServices;

public static class MyLogWrapper
{
#if UNITY_IOS && !UNITY_EDITOR
    private const string LIB_NAME = "__Internal"; 
#else
    private const string LIB_NAME = "mylog"; 
#endif
    
    
    [DllImport(LIB_NAME)]
    private static extern int pt_func1(int a, int b, string str);
    
    [DllImport(LIB_NAME)]
    private static extern string pt_func2(string str);
    
    [DllImport(LIB_NAME)]
    private static extern void pt_func3();
    
    
    
    public static int Func1(int a, int b, string str)
    {
        return pt_func1(a, b, str);
    }
        
            
    public static string Func2(string str)
    {
        return pt_func2(str);
    }
    
    public static void Func3()
    {
        pt_func3();
    }
    
}

需要注意的是 C# 里面的 string 对应于 C++ 里面的 char *,而不是 std::string,还需要注意需要导出到 C# 里面的 C++ 函数,需要用 extern "C" 包住,防止 C++ 的 Name mangling 。关于如何在 C# 和 C++直接传递复杂的结构,可以进一步阅读 Marshaling with C#

在 Android、iOS 真机上进行测试

现在我们代码库都写好了,下面我们就要在真机上进行测试了,这部分我不赘述,可以配合 Android Studio 和 XCode 的 Log Console 来查看 Log 输出,判读代码库是否工作正常。