近期我们开发了2个原生的 iOS 和 Android 组件,希望能用到游戏端,为了便于游戏开发人员更轻松的集成原生SDK,我们针对主流的游戏引擎:Unity 和 Unreal Engine (UE) 开发了相应的插件。对于我这样一个之前从未涉足游戏开发领域的人来说,这个过程中遇到了许多挑战,消耗了大量时间来解决一些初学者可能会遇到的问题。许多现在看似简单的问题,我当时都是通过观看 YouTube 视频和阅读大量 Unreal 论坛帖子逐步得到解决的。
为了帮助未来可能需要开发类似游戏原生插件的人少走弯路,我把几乎所有我遇到过的问题进行了总结,并包括了针对 Unity 和 UE编辑器的基础入门教程。
Unity 插件开发,对比 UE 的插件开发,要简单不少。
一般而言,Unity 集成原生的插件的目录结构是这样:
Plugins
├── Android
│ ├── SurveyPopupView.aar
├── iOS
│ ├── SurveyPopupView
│ │ ├── SurveyPopupView.framework
│ │ │ ├── Headers
│ │ │ ├── Info.plist
│ │ │ ├── SurveyPopupView
│ │ └── SurveyPopupView.framework.meta
Scripts
├── SurveyPopupView.cs
在 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
在 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 中,我们也需要使用到。
一般调用不同平台的原生代码,我们会用一个 C# 的文件来桥接,保证调用方不需要考虑平台差异。
使用 DllImport("__Internal")
可以导入和调用 Framework 中的方法,需要注意的是 __Internal
标识是不能修改的,因为__Internal
被用来指示这些函数是在主执行文件本身中实现的,而不是在外部动态链接库(DLL)中实现的。这是因为 iOS 不允许应用程序加载外部的动态链接库,所有的代码都必须链接到主执行文件中。
[DllImport("__Internal")]
private static extern void ImurOpenSurvey(string surveyId, string urlparams);
在打包成插件的时候,我们需要注意的是,最好把 .framework.meta
文件也一起放进去,因为需要设置 AddToEmbeddedBinaries 属性为 true,不然最终把游戏打包成 iOS 应用的时候,不会自动嵌入我们的 framework。另外一个方案就是在 Unity 编辑器的 Inspector 中手动配置Add to Embedded Binaries
,参考文档 Manual/PluginInspector。
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);
}
在“Hierarchy”窗口中,右键点击 -> UI -> Button。这将创建一个新的按钮对象,并将其添加到当前场景中。在“Inspector”窗口中,你可以看到新按钮的属性。你可以调整它的位置、大小、颜色和文本等。
创建完成之后 Unity 可能会提示你是否想要导入TextMesh Pro(TMP),我们选择 Import,TextMesh Pro 是 Unity 的一个高质量文本渲染和布局系统。
在 Assets 目录下,右键单击选择创建 C# 脚本,命名为 ButtonHandler。
编辑 C# 脚本,添加一个 Public 方法,输出一个 Log 文本用来测试
public class ButtonHandler : MonoBehaviour
{
public void OnButtonClick()
{
Debug.Log("Button was clicked!");
}
}
点击左上角的“Hierarchy”窗口中的 Button,展开 Inspector,把 ButtonHandler 脚本拖动到 Inspector 中。
然后,点击 "On Click()" 面板右下角的加号,把 “Hierarchy”窗口中的 Button拖动到 Click 的 Object 中,再选择 ButtonHandler 的 OnButtonClick 方法。
点击运行,可以看到控制台正常输出了我们自定义的 Log:
打包成 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
对应的平台目录。
假设构建好的 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"?>
<root>
<init>
<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"));
因为在我在 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));
}
在 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>
<copyDir src="$S(PluginDir)/ThirdParty/Android/libs/" dst="$S(BuildDir)/libs/" />
<copyDir src="$S(PluginDir)/ThirdParty/Android/java/" dst="$S(BuildDir)" />
</resourceCopies>
把源代码复制到 BuildDir,一定要注意文件路径。
<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>
<uses-permission android:name="android.permission.INTERNET" />
</androidManifestUpdates>
如果需要获取相关权限,需要使用 androidManifestUpdates
来更新 Android 应用的 Manifest 文件。
<gameActivityImportAdditions>
<insert>
import com.tencent.imur.survey.ImurSurveyJNIAdapter;
</insert>
</gameActivityImportAdditions>
添加额外的导入语句到 GameActivity.java 文件中,该文件是 Unreal 为 Android 应用生成的主活动文件。这里我们把上面创建的桥接java的 JNIAdapter 类导入。
<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 删除或更改,不然在编译安卓阶段会导致依赖找不到的问题。
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);
。
从 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 项目
如果在mac电脑上遇到 "No compiler was found in order to use C++ template, you must first install Xcode" 这个报错,在 Unreal Editor 的设置中的 Source Code ––> Source Code Editor
选择 "Xcode" 即可。
点击顶部的 "Content" ,然后在"内容浏览器"的空白区域右键单击,选择 "User Interface" => "Widget Blueprint",创建完成之后,可以重命名 Widget,然后双击打开,拖动左边栏的 "common" 下面的 UI 组件,比如 Button 和 Text,在右边的区域可以设置组件的样式、文本等。
修改完成之后,不要忘记点击左上角的 "Compile"。
同样的,在"内容浏览器"中右键单击,然后选择 "Blueprint Class"。在弹出的窗口中,选择"GameModeBase"作为父类(或者如果需要更多控制,选择"GameMode"),然后点击"选择"。为新的Blueprint命名,例如"MyGameMode"。
在顶部菜单中选择 "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”按钮来运行游戏,就可以看到我们刚刚添加的按钮了。
在顶部 "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++ 文件。
回到"内容浏览器",双击 ButtonWidget ,进入 Blueprint,并点击 Grapha。新建一个变量,并把类型设置为刚刚创建的 C++ 的类: MyTestObject。变量名的话,我这里使用的是: MyTestObjectInstance。
在编辑器中右键生成 Event Construct
和 Get Game Instance
节点,从 Event Construct
拖出一条线,选择 Construct Object from class
,选择 MyTestObject ,并按照下图连接好各个节点。
把变量拖到编辑器,拖出一条线,选择 ButtonClicked
,然后选择 Button 变量,点击下面的 Click 事件,生成节点,并连接好。
再次点击 Play ,点击按钮,我们就可以在 Output log 中,看到输出的文本了。
iOS 打包配置相对来说更简单,只要选择正确的证书和签名即可:
__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]
PackagingResults: Error: builtin __has_trivial_assign is deprecated; use __is_trivially_assignable instead [-Werror,-Wdeprecated-builtins]
一般这种问题是由于 Xcode 版本过高,UE4的话,建议选择 Xcode14及以下版本,使用 xcodes 这个软件可以比较方便的管理 Xcode 版本。
Error PackagingResults CodeSign Failed
Error PackagingResults AutomationTool was unable to run successfully.
Error PackagingResults Failed to Code Sign
之前使用免费证书,在 unreal 编辑器构建一直失败,主要是签名的问题,可以在 Intermediate/ProjectFilesIOS
中打开 xcodeproj ,自己在 xcode 中选择证书和签名并构建。或者充值一个苹果开发者,生成一个 provision ,导入到 project setting 中即可。折腾免费版证书半天之后,我选择了充钱解决。
配置好 teamId 并选择 Automatic Signing 即可:
打包安卓相对麻烦很多,因为依赖的环境更多。首先要安装对应版本的 Java 和 Anroid Studio(最好按照官方指定的版本,不然大概率踩坑,反正我踩过了🤦♂️),可以查看官方的文档:
然后跟着官方的文档一步步操作:https://docs.unrealengine.com/4.27/en-US/SharingAndReleasing/Mobile/Android/Setup/AndroidStudio/
这是我在Mac上的配置:
我也不知道常不常见,反正我都遇到了,🤦♂️
在 Android -> Build 中选择 arm64
使用安卓 build 目录中的 Install_UE4demo-arm64.command
安装
在 .uproject
中禁用 OculusVR 插件
{
"Name": "OculusVR",
"Enabled": false
}
在项目设置的Android Packing 配置中,禁用 OBB:
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。