Unity Andorid 多渠道管理
Unity Andorid 多渠道管理
版本 | 修订记录 | 修订日期 |
---|---|---|
1.0.0 | Init | 2023-2-4 |
1.0.1 | 增加 IL2Cpp编译 | 2023-2-25 |
目标 & 背景
一般来说手游开发到一定进度,一定会涉及到大量的 native 交互问题,比如 重启 App
、拉起支付
、观看广告
等等,而这些大部分情况下都是第三方提供的 native SDK,而且有时候游戏需要接入的渠道多的离谱,再加上很多公司自己研发的 SDK 由于缺少 Unity 相关经验,最终导致 native SDK 接入非常非常痛苦
我司同样有自研的 natvie SDK,也同样因为不了解 Unity,接入过程也是同样的痛苦,为了减少接入成本,并以一种更符合 Unity 开发者的方式对现有 SDK 进行改造,才有了这篇博客
在说明具体方案之前,我期望达成如下目标
- Unity 一次 Build 可以导出 N 个渠道包
- C# 代码只需要维护一份
- SDK 接入成本要尽可能的低
- 业务逻辑编写时,尽可能的不会使用到任何宏
- 异步回调的原生代码统一使用 async 封装
C# Android 原生交互
对于 Unity 项目而言,我们需要考虑的平台一般有 3 个
- Android
- iOS
- Other
这里 Other 的定义是指 Windows、MacOS 以及 Editor,根据自己项目实际情况调整即可,本篇文章不会涉及到 iOS 相关的部分,因此仅会介绍 Android 相关的具体实现,后续的具体实现都以接入一个广告接口为例
一般广告提供的原生 API 会分为两个部分
- 调用
- 结束后的结果回调
接口设计
我们将原生相关的接口拆成 3 个部分
接口名 | 描述 |
---|---|
ISDKAdaptor | 业务可以调用的所有 API |
ISDKCaller | 与原生交互的 API |
ISDKCallback | 原生 callback 回 C#的 API |
因此 C# 代码部分如下
public interface ISDKCaller
{
public void ShowADCaller();
}
// 此处和 java 代码保持一致
public interface ISDKCallback
{
// 一般是一个 int 的 code,此处就写成 bool 了
public void OnAdResult(bool result);
}
public interface ISDKAdaptor : ISDKCallback, ISDKCaller
{
public UniTask<bool> ShowAd();
}
需要额外定义的 java 接口如下,文中出现的所有 java 代码,均可存放在 Plugins/Andorid
文件夹下
package com.sdk.ad;
public interface SDKProxy {
public void OnAdResult(bool result);
}
安卓平台具体实现
这里我们要引入三个新的概念
类名 | 作用 |
---|---|
AndroidJavaClass | 获取一个 java 类 |
AndroidJavaObject | 获取一个 java 的属性 |
AndroidJavaProxy | 继承一个 java 接口 |
注意这里的
AndroidJavaProxy
很多文章在讲 java 代码如何 call 回 C# 时都喜欢用UnityPlayer.UnitySendMessage
的 java 方法,这个性能是 极其糟糕 的,看到这里就忘了它吧
接着我们来看具体实现
public class AndroidSDKAdaptor : AndroidJavaProxy, ISDKAdaptor
{
private readonly AndroidJavaObject _j_obj;
private UniTaskCompletionSource<bool> _ad_tcs;
public AndroidSDKAdaptor() : base("com.sdk.ad.SDKProxy")
{
using var jc = new AndroidJavaClass("com.unity3d.player.UnityPlayer");
_j_obj = jc.GetStatic<AndroidJavaObject>("currentActivity");
// MainActivity.java 中提供了 C# 设置 proxy 的方法
// 方便广告播放完毕 call 回 C#
_j_obj.Call("setProxy", this);
}
// 业务实际调用的 API
public UniTask<bool> ShowAd()
{
_ad_tcs?.TrySetCanceled();
_ad_tcs = new UniTaskCompletionSource<bool>();
// 告诉 SDK 拉起广告
ShowADCaller();
// 等待广告结束
return _ad_tcs.Task;
}
public void ShowADCaller()
{
_j_obj.Call("showAd");
}
// java 处广告播放完毕,会调用这个函数
public void OnAdResult(bool result)
{
// 设置广告播放的结果
_ad_tcs?.TrySetResult(result);
}
}
这里我们使用了 UniTaskCompletionSource
来完成异步的封装,由于原生平台的交互一般都没那么频繁,因此每次 new 产生的 GC 也问题不大,但是如果你的项目有实际需求,可以参考我之前给 YooAsset 封装的 UniTask 适配代码
统一外部调用
最后我们需要再封装一个 SDKHelper
的静态类,对 ISDKAdaptor
接口中的所有方法做平台统一的封装,业务只需要关心这个文件中所有 public
的方法即可
public static class SDKHelper
{
private static ISDKAdaptor _ADAPTOR;
static SDKHelper()
{
#if UNITY_EDITOR || UNITY_STANDALONE || UNITY_EDITOR_OSX || UNITY_STANDALONE_OSX
_ADAPTOR = new OtherSDKAdaptor();
#elif UNITY_ANDROID
_ADAPTOR = new AndroidSDKAdaptor();
#elif UNITY_IOS
_ADAPTOR = new iOSSDKAdaptor();
#else
throw new NotImplementedException();
#endif
}
public static UniTask<bool> ShowAd()
{
return _ADAPTOR.ShowAd();
}
}
改造思路
假设我们要同时接入 国内
和 海外
两个渠道的广告 SDK,出包时一次 Unity Build 同时导出两个 apk
,除此之外两个 apk
的所有功能完全一致。此时我们就需要借助 AS 的 flavor
功能,其整体过程就是在 Unity 生成的 AS 工程基础上(IPostGenerateGradleAndroidProject
),进行二次改造,因此我们需要在 Unity 中完成一些 Editor 的配置功能
具体界面参考如下
这里我们将配置分成了如下三个大类
类型 | 说明 |
---|---|
Java | 不同渠道拥有各自的 MainActivity.java |
Libs | 不同渠道引用的 aar 和 jar 文件不同 |
Share Libs | 所有渠道都要引用的 aar 文件 |
后续我们将实现两个渠道的配置,分别为 oversea
和 inland
两种,最终生成的 AS 工程路径参考如下,后续我们将对具体实现细节做详细的介绍
├── launcher
│ ├── build.gradle
│ └── src
│ ├── inland
│ ├── main
│ └── oversea
└── unityLibrary
├── build.gradle
├── libs
└── src
├── inland
├── main
└── oversea
增加 ProjectSetting 配置
想要让 Setting 出现在 ProjectSetting 配置中也很简单,具体代码如下
public class SDKEditorSetting : SerializedScriptableObject
{
public static SDKEditorSetting GetOrCreateSettings()
{
var guids = AssetDatabase.FindAssets("t:SDKEditorSetting");
switch(guids.Length)
{
case 0:
var settings = CreateInstance<SDKEditorSetting>();
settings._ResetAll();
if(!Directory.Exists("Assets/Editor"))
{
Directory.CreateDirectory("Assets/Editor");
}
AssetDatabase.CreateAsset(settings, "Assets/Editor/SDKEditorSetting.asset");
return settings;
default:
var path = AssetDatabase.GUIDToAssetPath(guids[0]);
return AssetDatabase.LoadAssetAtPath<SDKEditorSetting>(path);
}
}
}
static class SDKEditorSettingRegister
{
private static PropertyTree _PROPERTY;
[SettingsProvider]
public static SettingsProvider Create()
{
var provider = new SettingsProvider("Project/SDK/AD", SettingsScope.Project)
{
label = "AD SDK",
guiHandler = _ =>
{
if(_PROPERTY is null)
{
var settings = SDKEditorSetting.GetOrCreateSettings();
var so = new SerializedObject(settings);
_PROPERTY = PropertyTree.Create(so);
}
_PROPERTY.Draw();
}
};
return provider;
}
}
此处我希望使用 Odin 来绘制这个 Setting,所以借助 PropertyTree,即可轻易的完成 GUI 的绘制接管
Java 文件转移
此时在 Assets/Plugins/Android
路径下,我们有两个 MainActivity.java
文件
- MainActivity.java 对应海外渠道
- MainActivity_CN.java 对应国内渠道
由于 Unity 在导出 AS 工程时,所有的 java 文件会统一存放在 unityLibrary/src/main/java
文件夹中,因此需要对配置的文件进行转移,整体上就是从 A 目录拷贝到 B 目录
inland 最终的目录参考如下,oversea 也是同理(相对于
unityLibrary/src
目录)
├── inland
│ ├── java
│ │ ├── com
│ │ │ ├── sdk
│ │ │ │ ├── ad
│ │ │ │ │ └── MainActivity.java
├── main
Libs 文件转移
首先,在 Unity 中,所有的 aar/jar
文件,我们都存放在 Plugins/Android/libs
文件夹下,为了让配置工具可以更好的完成自动化工作,需要对 libs
文件夹进行划分,因此有如下目录
相对于
Assets/Plugins/Android
文件夹
└── libs
├── inland
│ ├── xxx.aar
│ └── xxx.aar.meta
├── inland.meta
├── oversea
│ ├── xxx.aar
│ └──xxx.aar.meta
└── oversea.meta
需要对每个渠道使用到的具体 aar/jar
文件放在不同的文件夹下,这样 SDKEditorSetting
就可以自动搜集了,当安卓工程进行构建时,我们就需要根据配置对 jar
文件进行转移
注意此处我们只转移
jar
文件,aar
文件必须放在unityLibrary/libs
文件夹下,否则引入时无法找到,转移后的目录层级参考如下(相对于unityLibrary/src
目录)
├── main
├── inland
│ ├── libs
│ │ └── xxx.jar
└── oversea
└── libs
└── xxx.jar
Share Libs 文件转移
由于 Unity 导出 AS 工程时,如果 aar/jar
文件名字重复,会导致导出错误,因此,对于多个渠道需要共用的文件,我们需要放在 Assets/Plugins/Android/lib/share
文件夹下
如果项目中有一些第三方的
aar
文件,并没有放在这个目录下,可以在自动生成部分的逻辑中,扫描所有文件
var guids = AssetDatabase.FindAssets("a:all");
// 然后排除 Libs 中引用的所有 aar 即可
// 注意此处我们只需要搜集 aar 文件
gradle 的基础概念
正常来说,Unity 会替我们生成三个 gradle
文件
名字 | 对应AS | 后续简称 |
---|---|---|
baseProjectTemplate.gradle | export gradle | base |
launcherTemplate.gradle | launcher gradle | launcher |
mainTemplate.gradle | unityLibrary gradle | unityLibrary |
一般情况下对于 base
修改频率很低,只在一些 SDK 需要从指定 maven 源下载时才会修改,因此我们后续文章的介绍就会主要围绕着 launcher
和 unityLibrary
两个 gradle
文件进行改造
gradle 改造
launcher 变化
首先我们需要移除 Unity 默认的 implementation project(':unityLibrary')
,并改成如下内容
android {
// 略
}
// 这个放在最后
dependencies {
overseaImplementation project(':unityLibrary')
inlandImplementation project(':unityLibrary')
}
接着声明 flavor
相关内容
android {
flavorDimensions "game"
productFlavors{
oversea{
applicationId 'com.company.demo'
dimension "game"
}
inland{
applicationId 'com.company.demo.cn'
dimension "game"
}
}
}
unityLibrary 变化
首先移除 Unity 默认的 **DEPS**
关键字,它会辅助生成项目中所有的 aar
文件引用,此处我们希望使用 Share Libs 中配置的 aar
文件进行替换,最终内容如下
此时工具要做的事情就是替换这里的 START 和 END 中的内容为
implementation(name: 'xxx', ext: 'aar')
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
// SHARE START
// 此处会追加共享的 aar 文件
// SHARE END
}
接着是 flavor 相关的改造,注意这里的 impl
写法,此处使用的是在 launcher
中定义的 overseaImplementation
和 inlandImplementation
关键字,注意不要和 Share Libs 中生成的部分搞混
同样的此时工具需要动态替换 START 和 END 中 的内容为
overseaImplementation(name: 'xxx', ext: 'aar')
或者是inlandImplementation(name: 'xxx', ext: 'aar')
android {
flavorDimensions "game"
productFlavors{
// 海外渠道
oversea{
dimension "game"
dependencies {
// 在海外渠道中, 不使用 libs 目录下的 jar 包
overseaImplementation fileTree(dir: 'src/oversea/libs', include: ['*.jar'])
// OVERSEA START
// 此处会追加海外渠道的依赖
// OVERSEA END
}
}
inland{
dimension "game"
dependencies {
inlandImplementation fileTree(dir: 'src/inland/libs', include: ['*.jar'])
// INLAND START
// 此处会追加国内渠道的依赖
// INLAND END
}
}
}
}
最后,我们需要为不同渠道指定 java
文件的源,也就是 Unity 项目中的 MainActivity.java
文件存放的路径
android {
sourceSets {
// 此处根据渠道不同, 选择不同的源码目录
oversea {
java.srcDirs = ['src/oversea/java', 'src/oversea/java']
}
inland {
java.srcDirs = ['src/inland/java', 'src/inland/java']
}
}
}
获取当前 flavor
当我们声明好 flavor 后,安卓工程构建时,会生成一个对应的 BuidConfig.java
文件,其中 BuildConfig.FLAVOR
就是当前构建的类型,你可以取到 oversea
或者是 inland
同样的你也可以定义自己的
BuidConfig
内容这里就不做介绍了
AndroidStudio 相关操作
在截图的位置,你可以切换 Project 的显示方式,一般会用到 Android
和 Project
两种
在 Build Variants 中可以根据 Flavor 进行动态切换
在 Gradle 中可以快速执行一些定义好的 task
但是如果你看到的只有 test 相关的 task,此时就需要把下图的开关关闭
其他适配方法
本篇文章主要介绍的是一种较为复杂情况的解决方案,但是可能实际项目中碰到的需求会较为简单,比如可能项目需要打的包变化的只有配置文件,但是又不希望每次都从 Unity 完整打一次包
这种情况下就可以自己进行二次打包,因为不管是 apk
还是 aab
他们都是一个 zip
文件,然后写代码读取即可。又或者生成 AndroidStudio 后,再跑一个脚本,去动态替换某个内容,或者是目录,最后调用 gradlew
进行生成等等
最后
希望本篇文章可以帮助你在接入国内流氓般的渠道SDK 时,提供一个解决方案,减少你们打包的整体耗时。同时也谴责那些 demo 都没有做好的 SDK 公司们
IL2Cpp 自动编译补充
如果你的项目使用的是 IL2Cpp,在做完上述操作后,会碰到 apk 可以正确编译,但是 IL2Cpp 的 so 文件缺无法自动编译的情况,因此我们需要在原 gradle 生成的基础上增加如下内容
android {
afterEvaluate {
if (project(':unityLibrary').tasks.findByName('mergeOverseaDebugJniLibFolders'))
project(':unityLibrary').mergeOverseaDebugJniLibFolders.dependsOn BuildIl2CppTask
if (project(':unityLibrary').tasks.findByName('mergeOverseaReleaseJniLibFolders'))
project(':unityLibrary').mergeOverseaReleaseJniLibFolders.dependsOn BuildIl2CppTask
}
}";
正常来说 Unity 导出项目后,会自动生成 IL2Cpp 的自动编译任务,但是由于我们对项目分了 flavor,这样会导致 Unity 生成的代码在 Build 时,无法执行。所以上述的代码在 Build 时,检查渠道以及 Debug Release 版本,去自动编译不同版本的 so 文件
- 感谢你赐予我前进的力量