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 文件

后续我们将实现两个渠道的配置,分别为 overseainland 两种,最终生成的 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 源下载时才会修改,因此我们后续文章的介绍就会主要围绕着 launcherunityLibrary 两个 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 中定义的 overseaImplementationinlandImplementation 关键字,注意不要和 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 的显示方式,一般会用到 AndroidProject 两种

在 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 文件