Unity/Unreal 插件集成iOS/Android 的血泪总结
原创
项目背景
近期我们开发了2个原生的 iOS 和 Android 组件,希望能用到游戏端,为了便于游戏开发人员更轻松的集成原生SDK,我们针对主流的游戏引擎:Unity 和 Unreal Engine (UE) 开发了相应的插件。对于我这样一个之前从未涉足游戏开发领域的人来说,这个过程中遇到了许多挑战,消耗了大量时间来解决一些初学者可能会遇到的问题。许多现在看似简单的问题,我当时都是通过观看 YouTube 视频和阅读大量 Unreal 论坛帖子逐步得到解决的。
为了帮助未来可能需要开发类似游戏原生插件的人少走弯路,我把几乎所有我遇到过的问题进行了总结,并包括了针对 Unity 和 UE编辑器的基础入门教程。
Unity 插件集成原生安卓的 aar 包和 iOS 动态库
Unity 插件开发,对比 UE 的插件开发,要简单不少。
一般而言,Unity 集成原生的插件的目录结构是这样:
Plugins
├── Android
│ ├── SurveyPopupView.aar
├── iOS
│ ├── SurveyPopupView
│ │ ├── SurveyPopupView.framework
│ │ │ ├── Headers
│ │ │ ├── Info.plist
│ │ │ ├── SurveyPopupView
│ │ └── SurveyPopupView.framework.meta
Scripts
├── SurveyPopupView.cs
集成 iOS Framework 动态库
在 Objective-C 中,我们需要把给 C# 使用的函数放在
extern "C"
代码块中:
#ifdef __cplusplus
extern "C" {
#endif
void ImurOpenSurvey(const char *surveyId, const char *params) {
NSString *nsSurveyId = [NSString stringWithUTF8String:surveyId];
NSString *nsParams = [NSString stringWithUTF8String:params];
dispatch_async(dispatch_get_main_queue(), ^{
[[SurveyPopupView sharedInstance] open:nsSurveyId withParams:nsParams];
#ifdef __cplusplus
#endif
extern "C" 的作用
在 Unity 环境中,C# 代码可以通过 IL2CPP(Intermediate Language to C++)技术调用 Objective-C 代码,IL2CPP是一种将.NET Intermediate Language (IL)代码转换为 C++ 代码的编译器技术。通过这种转换,Unity 可以将 C# 代码编译为本地代码,从而提高性能并允许与本地代码(如Objective-C或C++)的交互。
当在 Unity 中编写 C# 代码时,该代码首先被编译为.NET Intermediate Language (IL)。
通过 IL2CPP,这些 IL 代码被转换为 C++ 代码。一旦 C# 代码被转换为 C++ 代码,它可以直接与其他本地代码交互,包括 Objective-C。
使用
extern "C"
语法可以确保函数具有 C 链接约定,从而可以从 C++ 代码(由 IL2CPP 生成)中调用它们。
extern "C"还可以确保跨平台兼容性,特别是在涉及不同编译器和链接器的情况下。在后面部分的 UE 中,我们也需要使用到。
Unity iOS 桥接代码
一般调用不同平台的原生代码,我们会用一个 C# 的文件来桥接,保证调用方不需要考虑平台差异。
使用
DllImport("__Internal")
可以导入和调用 Framework 中的方法,需要注意的是
__Internal
标识是不能修改的,因为
__Internal
被用来指示这些函数是在主执行文件本身中实现的,而不是在外部动态链接库(DLL)中实现的。这是因为 iOS 不允许应用程序加载外部的动态链接库,所有的代码都必须链接到主执行文件中。
[DllImport("__Internal")]
private static extern void ImurOpenSurvey(string surveyId, string urlparams);
Add to Embedded Binaries
在打包成插件的时候,我们需要注意的是,最好把
.framework.meta
文件也一起放进去,因为需要设置 AddToEmbeddedBinaries 属性为 true,不然最终把游戏打包成 iOS 应用的时候,不会自动嵌入我们的 framework。另外一个方案就是在 Unity 编辑器的 Inspector 中手动配置
Add to Embedded Binaries
,参考文档
Manual/PluginInspector
。
集成安卓的 aar 包
Unity 能自动识别并处理
Assets/Plugins/Android
目录下的 .aar文件,包括在构建时将其包含在APK中。
主要是 AndroidJavaClass 和 AndroidJavaObject 类提供了一种在运行时从 C# 调用 Java 的能力。这是通过JNI(Java Native Interface)实现的,它是Java虚拟机(JVM)提供的一种允许 Java 代码与本地代码(例如C或C++代码)交互的接口。
这是我们在 C# 桥接代码中调用原生 Java 的示例:
private static void ImurOpenSurvey(string surveyId, string urlparams)
AndroidJavaClass unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer");
AndroidJavaObject currentActivity = unityPlayer.GetStatic<AndroidJavaObject>("currentActivity");
AndroidJavaClass surveyClass = new AndroidJavaClass("com.tencent.imur.survey.IMurSurveyAdapter");
surveyClass.CallStatic("openSurvey", currentActivity, surveyId, urlparams);
}
【新手篇】如何创建一个Unity项目,并绑定 C# 中的方法
1、创建一个空的 2d 项目

2、添加按钮
在“Hierarchy”窗口中,右键点击 -> UI -> Button。这将创建一个新的按钮对象,并将其添加到当前场景中。在“Inspector”窗口中,你可以看到新按钮的属性。你可以调整它的位置、大小、颜色和文本等。

创建完成之后 Unity 可能会提示你是否想要导入TextMesh Pro(TMP),我们选择 Import,TextMesh Pro 是 Unity 的一个高质量文本渲染和布局系统。
3、创建 C# 脚本
在 Assets 目录下,右键单击选择创建 C# 脚本,命名为 ButtonHandler。

编辑 C# 脚本,添加一个 Public 方法,输出一个 Log 文本用来测试
public class ButtonHandler : MonoBehaviour
public void OnButtonClick()
Debug.Log("Button was clicked!");
}
4、绑定 C# 方法到 Button 的点击事件中
点击左上角的“Hierarchy”窗口中的 Button,展开 Inspector,把 ButtonHandler 脚本拖动到 Inspector 中。
然后,点击 "On Click()" 面板右下角的加号,把 “Hierarchy”窗口中的 Button拖动到 Click 的 Object 中,再选择 ButtonHandler 的 OnButtonClick 方法。

点击运行,可以看到控制台正常输出了我们自定义的 Log:

UE4 插件集成原生安卓的 jar 包和 iOS 动态库
打包成 UE Plugin 之后,调用原生功能的方式会简单很多,可以极大的提高 SDK 接入效率。但是打包一个 UE 的插件是比较复杂的,接下来就详细说明我们是如何做的,以及所有遇到的问题和解决方案。
首先,在 UE 中,一般插件完整的目录结构是这样:(ThirdParty 目录也有放在第一层的)
ImurSurvey
├── ImurSurvey.uplugin
├── Resources
│ └── Icon128.png
└── Source
└── ImurSurvey
├── ImurSurvey.Build.cs
├── ImurSurveyPlugin_Android_UPL.xml
├── ImurSurveyPlugin_iOS_UPL.xml
├── Private
│ └── ImurSurvey.cpp
├── Public
│ ├── ImurSurvey.h
└── ThirdParty
├── Android
│ ├── java
│ └── libs
└── iOS
└── SurveyPopupView.embeddedframework.zip
在这个目录结构中,会把原生的包放在
Source/ThirdParty
对应的平台目录。
集成 iOS Framework 动态库
插件引用 Framework
假设构建好的 framework 名称是
MyFramework.framework
,按照下面的文件目录 zip 压缩。
MyFramework.embeddedframework.zip
> MyFramework.embeddedframework/
> MyFramework.framework/
在 Plugins 下的 .build.cs 文件中,使用 PublicAdditionalFrameworks 方法添加 embeddedframework.zip
PublicAdditionalFrameworks.Add(new Framework("SurveyPopupView", libPath, ""));
如果构建后,在
Binaries/IOS/Payload/xxx.app
中,没有看到对应的 framework,那么打开构建后的app时会crash,报错:
dyld: Library not loaded: @rpath/xxx.framework
看了 Unreal 论坛关于这个问题各种讨论之后,我最终还是使用了通过 UPL.xml 中
copyDir
的方式复制 framework,参考
https://rassadin.net/swift-frameworks-unreal/
Unreal 构建工具会把 embeddedframework.zip 文件解压到
$S(EngineDir)/Intermediate/UnzippedFrameworks
这个目录,绝对路径一般是
/Users/Shared/Epic Games/UE_4.xx/Engine/Intermediate/UnzippedFrameworks
<?xml version="1.0" encoding="utf-8"?>
<copyDir src="$S(EngineDir)/Intermediate/UnzippedFrameworks/SurveyPopupView/SurveyPopupView.embeddedframework/SurveyPopupView.framework" dst="$S(BuildDir)/Frameworks/SurveyPopupView.framework"/>
</init>
</root>
然后在 .Build.cs iOS 部分再添加一行:
AdditionalPropertiesForReceipt.Add("IOSPlugin", Path.Combine(moduleDir, "ImurSurveyPlugin_iOS_UPL.xml"));
调用 Framework 中的方法
因为在我在 object-c 中已经使用
extern "C"
暴露了可供调用的 C 函数,所以在
Public/ImurSurvey.h
头文件中,使用 extern 确保正确的链接规则,并声明这些函数即可:
#ifdef __cplusplus
extern "C" {
#endif
extern void ImurOpenSurvey(const char* surveyId, const char* params);
extern void ImurCloseSurvey(void);
extern void ImurSetSurveyPopupConfig(const char* configJson);
extern void ImurEnableLog(bool enable);
#ifdef __cplusplus
#endif
// 使用
void Open(FString surveyId, FString params)
ImurOpenSurvey(TCHAR_TO_UTF8(*surveyId), TCHAR_TO_UTF8(*params));
}
集成 Android jar 包
在 UE 中,集成原生安卓的包有多种方式,可以使用 Java源码、aar、jar 等方式。在调试阶段,建议先直接使用源码方式,然后再根据情况选择 jar 或者 aar 的方式引入。
先说源码的方式,把 Java 的代码放到
Source/ThirdParty/Android
目录下,保持和原来的结构一致:
Android
├── java
│ ├── res
│ │ ├── drawable
│ │ ├── layout
│ │ ├── values
│ │ └── values-en
│ └── src
│ └── com
└── libs
└── tbs_sdk_thirdapp_v4.3.0.386_44286_sharewithdownloadwithfile_withoutGame_obfs_20230210_114429.jar
因为我们需要在安卓中使用 Dialog 组件,所以必须确保在 UI 线程中调用,使用一个 JNIAdapter 的辅助类来桥接 C++ 的代码:
public static void openSurvey(final NativeActivity activity, final String surveyID, final String urlParams) {
activity.runOnUiThread(new Runnable() {
@Override
public void run() {
openSurvey(activity, surveyID, urlParams);
}
然后在 C++ 中使用 AndroidJNI 的方法调用 JNIAdapter 中 Java 的方法:
#include "Android/AndroidJNI.h"
#include "Android/AndroidApplication.h"
void Open(FString surveyId, FString params)
if (JNIEnv* Env = FAndroidApplication::GetJavaEnv(true))
jclass Class = FAndroidApplication::FindJavaClass("com/tencent/imur/survey/ImurSurveyJNIAdapter");
jmethodID Method = Env->GetStaticMethodID(Class, "openSurvey", "(Landroid/app/NativeActivity;Ljava/lang/String;Ljava/lang/String;)V");
jobject Activity = FAndroidApplication::GetGameActivityThis();
jstring SurveyIdJava = Env->NewStringUTF(TCHAR_TO_UTF8(*surveyId));
jstring ParamsJava = Env->NewStringUTF(TCHAR_TO_UTF8(*params));
Env->CallStaticVoidMethod(Class, Method, Activity, SurveyIdJava, ParamsJava);
Env->DeleteLocalRef(Class);
Env->DeleteLocalRef(SurveyIdJava);
Env->DeleteLocalRef(ParamsJava);
}
记得在 .Build.cs 中的安卓部分引入 Launch 的依赖:
PublicDependencyModuleNames.Add("Launch");
安卓 UPL.xml 文件的编写比 iOS 复杂的多,而且每一项配置都是有意义的,所有有必要说明一下 UPL 中的重点语法:
resourceCopies
<resourceCopies>
<copyDir src="$S(PluginDir)/ThirdParty/Android/libs/" dst="$S(BuildDir)/libs/" />
<copyDir src="$S(PluginDir)/ThirdParty/Android/java/" dst="$S(BuildDir)" />
</resourceCopies>
把源代码复制到 BuildDir,一定要注意文件路径。
buildGradleAdditions
<buildGradleAdditions>
<insert>
allprojects {
repositories {
flatDir {
dirs 'src/main/libs'
dependencies {
compile fileTree(include: '*.jar', dir: 'libs')
</insert>
</buildGradleAdditions>
flatDir 指定了 Gradle 会在这个目录中查找jar文件和aar文件,dependencies 用于指定项目的依赖项的,告诉Gradle在libs目录下查找所有的.jar文件,并将它们作为编译时依赖项添加到项目中。
androidManifestUpdates
<androidManifestUpdates>
<uses-permission android:name="android.permission.INTERNET" />
</androidManifestUpdates>
如果需要获取相关权限,需要使用
androidManifestUpdates
来更新 Android 应用的 Manifest 文件。
gameActivityImportAdditions
<gameActivityImportAdditions>
<insert>
import com.tencent.imur.survey.ImurSurveyJNIAdapter;
</insert>
</gameActivityImportAdditions>
添加额外的导入语句到 GameActivity.java 文件中,该文件是 Unreal 为 Android 应用生成的主活动文件。这里我们把上面创建的桥接java的 JNIAdapter 类导入。
proguardAdditions
<proguardAdditions>
<insert>
-keep class com.tencent.smtt.** { *; }
-keep public class com.tencent.imur.survey.ImurSurveyJNIAdapter
</insert>
</proguardAdditions>
ProGuard 是 Android 中用于缩小、优化和混淆代码的工具,但是,有时ProGuard可能会删除或更改应用中重要的类和方法,这可能会导致运行时错误。使用
keep class
告诉 ProGuard 保留我们所依赖的libs包及其子包中的所有类和它们的所有成员(包括字段和方法)。JNIAdapter 类也一定要保留,确保它不会被 ProGuard 删除或更改,不然在编译安卓阶段会导致依赖找不到的问题。
构建安卓应用时 R 类找不到的问题
IMurLayout.java:17: 错误: 找不到符号
import com.tencent.imur.survey.webview.R;
符号: 类 R
位置: 程序包 com.tencent.imur.survey.webview
R 类是一个在 Android 开发中自动生成的类,它提供了对项目 res(资源)目录中资源的引用,每当你在 res 目录中添加一个新的资源(例如,一个新的布局 XML 文件、图片、字符串资源等),Android 构建系统会在 R 类中为该资源生成一个新的静态字段。但是在 Unreal 中,引用 Android 资源(通过R类)会有些不同,因为 Unreal Engine 的构建系统不会为你的 Java 代码生成一个传统的R类,最好解决方案是在 Java源码中通过
完全限定的资源ID
来引用资源:
context.getResources().getIdentifier("com.example.myapp:id/web_close_btn", null, null);
。
jar 包集成的方式
从 Java 源码集成的方式修改成 jar 包的形式非常简单,保持原有的目录结构和 JNIAdapter 类源码,然后打包成 aar 之后,把 aar 中的 jar 包,放在 lib 文件夹中,然后在 proguardAdditions 中加上
keep class 你的类名
,删除原来的 Java 源码就可以了。
<proguardAdditions>
<insert>
<!-- 其他规则... -->
-keep class com.tencent.imur.** { *; }
</insert>
</proguardAdditions>
最终的目录结构:
Android
├── java
│ ├── res
│ │ ├── drawable
│ │ ├── layout
│ │ ├── values
│ └── src
│ └── com
│ └── tencent
│ └── imur
│ └── survey
│ └── ImurSurveyJNIAdapter.java
└── libs
├── imur_survey_popupview.jar
└── tbs_sdk_thirdapp_v4.3.0.386_44286_sharewithdownloadwithfile_withoutGame_obfs_20230210_114429.jar
【新手篇】如何创建一个UE项目,并绑定 C++ 中的方法
1. 创建空白项目
按照以下步骤,创建一个空白的 UE 项目



如果在mac电脑上遇到 "No compiler was found in order to use C++ template, you must first install Xcode" 这个报错,在 Unreal Editor 的设置中的
Source Code ––> Source Code Editor
选择 "Xcode" 即可。

2. 创建按钮

点击顶部的 "Content" ,然后在"内容浏览器"的空白区域右键单击,选择 "User Interface" => "Widget Blueprint",创建完成之后,可以重命名 Widget,然后双击打开,拖动左边栏的 "common" 下面的 UI 组件,比如 Button 和 Text,在右边的区域可以设置组件的样式、文本等。

修改完成之后,不要忘记点击左上角的 "Compile"。
3. 创建 GameMode
同样的,在"内容浏览器"中右键单击,然后选择 "Blueprint Class"。在弹出的窗口中,选择"GameModeBase"作为父类(或者如果需要更多控制,选择"GameMode"),然后点击"选择"。为新的Blueprint命名,例如"MyGameMode"。

4. 把按钮添加到游戏中
在顶部菜单中选择 "Edit" -> "Project Settings" -> "Maps and Modes",在 Default GameMode 选项中选择刚刚创建的 "MyGameMode"。

点击顶部菜单的"Blueprints" -> "Open Level Blueprint"。

在 Level Blueprint 中,右键单击并添加一个 "Event Begin Play" 节点(如果还没有)。从 "Event Begin Play" 节点拖出一个线,并添加一个 "Create Widget" 节点。
在 "Create Widget" 节点中,从 Class 下拉菜单中选择您的按钮Widget类(例如"MyButtonWidgetBlueprint")。再次拖出一个线,并添加一个 "Add to Viewport" 节点,并连接 "Return Value" 节点。

点击顶部菜单的“Compile”按钮,保存好 Level 之后,关闭 Level Blueprint 编辑,在项目设置的“Maps & Modes”中,选择默认的 Level:

点击顶部菜单的“Play”按钮来运行游戏,就可以看到我们刚刚添加的按钮了。

5. 创建 C++ 文件
在顶部 "file" 菜单中选择 "New C++ class" ,继承
Object
,选择 "Public" class,Path 使用默认的就好。生成文件之后,比如我的 class 名是
MyTestObject
,在
项目根目录/Source/项目名/Public
和
项目根目录/Source/项目名/Private
中可以看到生成的文件。

编辑一下,增加一个 Click 事件,并使用宏绑定到 Blueprint 中
MyTestObject.h
// Fill out your copyright notice in the Description page of Project Settings.
#pragma once
#include "CoreMinimal.h"
#include "UObject/Object.h"
#include "MyTestObject.generated.h"
UCLASS(Blueprintable, BlueprintType)
class EMPTYUEPROJECT_API UMyTestObject : public UObject
GENERATED_BODY()
public:
UFUNCTION(BlueprintCallable, Category = "Button")
void ButtonClicked();
};
MyTestObject.cpp
#include "MyTestObject.h"
void UMyTestObject::ButtonClicked()
UE_LOG(LogTemp, Warning, TEXT("Button clicked!"));
}
修改完成之后,记得编译 C++ 文件。
6. 绑定 C++ 事件到 Button 组件
回到"内容浏览器",双击 ButtonWidget ,进入 Blueprint,并点击 Grapha。新建一个变量,并把类型设置为刚刚创建的 C++ 的类: MyTestObject。变量名的话,我这里使用的是: MyTestObjectInstance。

在编辑器中右键生成
Event Construct
和
Get Game Instance
节点,从
Event Construct
拖出一条线,选择
Construct Object from class
,选择 MyTestObject ,并按照下图连接好各个节点。

把变量拖到编辑器,拖出一条线,选择
ButtonClicked
,然后选择 Button 变量,点击下面的 Click 事件,生成节点,并连接好。

再次点击 Play ,点击按钮,我们就可以在 Output log 中,看到输出的文本了。

UE 打包真机的 iOS 和安卓 app
iOS
iOS 打包配置相对来说更简单,只要选择正确的证书和签名即可:

常见问题
1、
__has_trivial_assign __has_trivial_copy
编译错误
UATHelper: Packaging (iOS): /Users/Shared/Epic Games/UE_4.27/Engine/Source/Runtime/Core/Public/Templates/IsTriviallyCopyAssignable.h:13:17: error: builtin __has_trivial_assign is deprecated; use __is_trivially_assignable instead [-Werror,-Wdeprecated-builtins]
UATHelper: Packaging (iOS): enum { Value = __has_trivial_assign(T) }
UATHelper: Packaging (iOS): /Users/Shared/Epic Games/UE_4.27/Engine/Source/Runtime/Core/Public/HAL/Event.h:122:18: note: in instantiation of template class 'TAtomic<unsigned int>' requested here
UATHelper: Packaging (iOS): TAtomic<uint32> EventStartCycles;
UATHelper: Packaging (iOS): ^
PackagingResults: Error: builtin __has_trivial_copy is deprecated; use __is_trivially_copyable instead [-Werror,-Wdeprecated-builtins]