如何设计打包流程

版本修订记录修订日期
1.0.0Init2023-3-13

目标 & 背景

项目的打包流程是一个细活,但是想设计一套清晰合理,并易于扩展的打包流程不是一件易事。如果公司项目很多,每个项目都设计一套项目自己有的打包流水线,显然是不合理的,那么针对这些情况,设计一套合理的打包流水线就非常有必要

在我司的环境下,我认为一个合理的打包流水线应当包含如下功能

我们这么做,并不能代表在你的项目中必须这么做,每个项目的实际情况完全不一致,开发者熟悉的流程也不尽相同,所以不要陷入语言和工具的争论,适合自己的才是最好的

  • 流程足够灵活,可任意 增/删 或者 组合
  • 以 Package 的形式作为公共组件
  • 大部分流程完全写在 Unity 中
  • 打包渠道区分
  • 打包器区分

为什么大部分流程都写在 Unity 中

在我以往接触到的项目中,有些打包流程是固定写在 CI/CD 这些平台上的,可能是 python 也可能是其他语言写的一些打包过程,然后接着调用 Unity 的静态方法

这么做有一个天然的缺陷,比如当前项目需要做原生的 SDK 接入,打包的频率会非常高,但是由于前期很不稳定,为了保证不影响开发渠道的打包流程,通常都会本地开发,或者单独拉一个对应的分支,在这种情况下,可以顺利完成测试的选项并不多

不同公司的硬件条件可能完全不一样,可能公司的打包机资源较为紧俏,也可能一台打包机承担了多个项目的打包任务,在项目初期时会尤为明显

因此这个问题在我们项目中是必须要解决的,全部集成在 Unity 中实现,这样做还有一个天然的好处,我们可以在 Unity 中 写一个 打包的 GUI,通过提供下拉菜单和默认值的方式,点两下就能完成打包

基于这种设计,对于 CI/CD 来说,工作几乎就是调用一下 Unity 的一个静态入口函数

渠道管理

多渠道是一件非常烦的事情,具体有多烦取决于你们项目的复杂度,比如我们项目要求国内和海外一个工程一键出包,这种情况要做的事情就非常多,具体可参考文章最下面的链接

这里介绍一个我们项目中多渠道要面对的最基础情况,假设当前分成 LocalDevRelease 三种环境,每个环境都对应了 WindowsMacAndroid 以及 iOS,因此通过组合我们可以得到如下渠道内容

  • Local_Windows
  • Local_MacOS
  • Local_Android
  • Local_iOS
  • Dev_Windows
  • Dev_MacOS
  • Dev_Android
  • Dev_iOS
  • Release_Windows
  • Release_MacOS
  • Release_Android
  • Release_iOS

而每个渠道的具体区分可以简单到只有 4 个配置项

  • http 服务器
  • cdn 服务器
  • 当前渠道的版本
  • 当前的渠道名

比如平时在 Editor 开发时,我会选择 Local_MacOS

具体的实现我们这里使用的是 ScriptableObject,每个具体的渠道对应了一份 xxx.asset 文件,在 Editor 提供一个下拉菜单,动态复制这个文件到 Resouces/Appconfig.asset 目录下,作为当前渠道

这里的代码简化了非常多,仅是提供一个思路,请在设计自己项目时,碰到现在这种类似的情况,尽可能的避免使用 来解决, 是最终手段,能不用尽量减少使用

public sealed class AppConfig : ScriptableObject
{
    public static AppConfig app_config
    {
        get
        {
#if UNITY_EDITOR
            // Editor 下因为可能要动态手动切换渠道
            // 因此每次都 Load
            _app_config = UnityEditor.AssetDatabase.LoadAssetAtPath<AppConfig>("Assets/Resources/AppConfig.asset");
#else
            if(_app_config is not null)
            {
                return _app_config;
            }
            // 而真机上,仅需要加载一次
            _app_config = Resources.Load<AppConfig>("AppConfig");
#endif
            return _app_config;
        }
    }
    
    private static AppConfig _app_config;

    public string channel;
    public string server;
    public string version;
}

这样设计下,如果希望 Editor 连接线上服务器,直接切换到对应的 Release_xxx 配置即可

BuildConfig

这里我们需要实现的就是这张图中对应的 GUI 操作工具,需要注意的是,我们需要将这里拆分成两个部分 BuildConfigBuildConfigSave

名称说明
BuildConfig一个可以序列化的 class
BuildConfigSave继承 SerializedScriptableObject 的资源文件

先实现 BuildConfigSave 的具体内容,这里需要注意的是,由于我们希望整个打包流水线都放在 Package 中进行实现,因此流水线的关键代码外部是无法修改的,所以要提供一个外部可以自定义参数的功能,也就是下方代码的 ACustomBuildParam 多态序列化

// 下拉菜单找到所有 ABuilderImplement
[IncludeMyAttributes]
[ValueDropdown("@BuildImplementAttribute.Get()")]
public class BuildImplementAttribute : Attribute
    public static readonly Dictionary<string, Type> NAME_TO_TYPE = new();

    private static IEnumerable<ValueDropdownItem> Get()
    {
        var list = TypeCache.GetTypesDerivedFrom<ABuilderImplement>();

        yield return new ValueDropdownItem("无", "");

        foreach(var type in list)
        {
            var label_text = type.GetCustomAttribute<LabelTextAttribute>();

            string name = type.Name;

            if(label_text != null)
            {
                name = label_text.Text;
            }

            NAME_TO_TYPE[type.FullName] = type;

            yield return new ValueDropdownItem(name, type.FullName);
        }
    }
}

[Serializable]
public abstract class ACustomBuildParam
{
}

public class BuildConfigSave : SerializedScriptableObject
{
    [SerializeField]
    [BuildImplement]
    [LabelText("打包器")]
    private string _builder;

    [SerializeField]
    [HideLabel]
    public BuildConfig config;

    [SerializeField]
    [BoxGroup("自定义参数")]
    [HideLabel]
    public ACustomBuildParam param;

    [Button("开始打包")]
    protected void _Build()
    {
        if(string.IsNullOrEmpty(_builder))
        {
            return;
        }

        BuildImplementAttribute.NAME_TO_TYPE.TryGetValue(_builder, out var type);

        if(type is null)
        {
            throw new ArgumentNullException("[Build] {_builder} not found");
        }

        if(Activator.CreateInstance(type) is not ABuilderImplement builder)
        {
            throw new ArgumentException("[Build] {_builder} is not a valid build implement");
        }

        _ = BuildHelper.Build(builder, config, param);
    }
}

你会注意到这里的构建实际上调用的是 BuildHelper.Build API,因为我们的打包流水线还需要考虑到 CI/CD 的情况,所以构建的 API 要单独拆分出来

public static class BuildHelper
{
    public static async Task Build(ABuilderImplement builder, BuildConfig config, ACustomBuildParam param = null)
    {
        if(builder is null)
        {
            throw new ArgumentException("[Run] Builder is null!");
        }

        if(EditorUserBuildSettings.activeBuildTarget != config.target)
        {
            if(!EditorUserBuildSettings.SwitchActiveBuildTarget(config.target))
            {
                throw new Exception("[Run] Switching build target failed!");
            }
        }

        try
        {
            builder.Init(config, param);

            var result = await builder.Run(config, param);

            if(!result.is_success)
            {
                throw new Exception("[Build] Build Failed! Failed Task = {result.failed_task}");
            }
        }
        finally
        {
            builder.Destroy(config);
        }
    }
}

BuildConfig 的内容就是底层默认实现的打包参数了,这里的代码就不详细展示了,没啥难度,根据自己项目自行实现即可

[Serializable]
public class BuildConfig
{
    // ...
}

打包步骤

这里的打包步骤,通过组合的方式,组成了不同的打包器,在我们项目中我希望所有的构建流程都是异步的,因此有如下设计

public abstract class ABuildStep
{
    public abstract Task<bool> Run(BuildConfig config, ACustomBuildParam param = null);
}

打包器

这里我们要考虑的内容是,不同的打包机执行的内容和方法可能完全不同,而且有时候打包需要打出 apk,有时候只需要热更资源,因此在上述这些维度下,我们可以做出如下区分

名称说明
DefaultAppBuilder默认的 apk 打包器
DefaultResBuilder默认的 资源热更打包器
Windows_xxx_AppBuilderWindows 环境的 xxx 打包器,可能用于专门打包 apk 或者 exe
Windows_xxx_ResBuilderWindows 环境的 xxx 资源热更打包器

这里我们要先对打包器进行抽象,提供一套供业务实现的标准流程,这里请注意区分每个函数的调用权限,一共分为三个等级 internalprotected 以及 private

public abstract class ABuilderImplement
{
    private readonly HashSet<ABuildStep> _build_tasks = new();
    
    internal void Init(BuildConfig config, ACustomBuildParam param = null)
    {
        // ...
        _Awake(config, param);
    }
    
    internal void Destroy(BuildConfig config, ACustomBuildParam param = null)
    {
        _build_tasks.Clear();
        _Destroy(config, param);
    }

    public async Task<bool> Run(BuildConfig config, ACustomBuildParam param = null)
    {
        try
        {
            var index = 0;
            foreach(var task in _build_tasks)
            {
                index++;
                EditorUtility.DisplayProgressBar("Build","Building->{task.GetType().Name} ({index}/{_build_tasks.Count})",(float) index / _life_cycle_tasks.Count);

                if(await task.Run(config, param))
                {
                    continue;
                }
                return false;
            }
            
            return true;
        }
        finally
        {
            EditorUtility.ClearProgressBar();
        }
    }

    protected abstract Task _Awake(BuildConfig config, ACustomBuildParam param = null);

    protected void _AddTask(ABuildStep step) { _build_tasks.Add(step); }

    protected abstract Task _Destroy(BuildConfig config, ACustomBuildParam param = null);
}

实现一个打包器

我们以 DefaultAppBuilder 为例,过程仅有一个打包的 BuildStep

public class DefaultAppBuilder : ABuilderImplement
{
    protected override Task _Awake(BuildConfig config, ACustomBuildParam param = null)
    {
        _AddTask(new MakeAppBuildStep());
        return Task.CompletedTask;
    }
    
    protected override Task _Destroy(BuildConfig config, ACustomBuildParam param = null)
    {
        return Task.CompletedTask;
    }
}

在现有的这套设计下,我们可以实现任意多个 BuildStep,然后通过 _AddTask 的方式,拼接组合成一个全新的打包器,这样面对任何复杂的情况,我们都有办法应对,下面是我们项目中实际打包器的构建过程,整体还是非常清晰的

// 增加版本号
_AddTask(new IncreaseVersion_BuildStep());
// 拷贝 AppConfig 到 Resources 目录
_AddTask(new CopyConfigStep());
// 拷贝 FMod 音频
_AddTask(new FModCopyBuildIn_BuildStep());
// 制作热更代码
_AddTask(new MakeHotfixTask());
// 打包 AB
_AddTask(new YooAssetBuildStep());
// 构建 App
_AddTask(new MakeAppBuildStep());
// 导出 apk 文件
_AddTask(new RunGradle_BuildStep());
// 上传 CDN
_AddTask(new SFtpUpload_BuildStep());

CI/CD 流程

我司接入的是 UWA Pipeline,所以后面会以 ppl 作为 CI/CD 的载体进行介绍,其他的平台比如 gitlabteamcity 等都是大同小异的,核心都是调用一下 Unity 的静态函数

但是在开始之前,我们需要引入 CommandLine.dll 这个命令行参数解析库,一般来说服务器用的比较多,但是 Unity 也是可以用的,简化一些我们参数构建的流程

下面的 Unity 默认参数,是为了解决 CommandLine 会碰到一些 Unity 传入的参数无法识别导致的构建错误

public class BuildAppOptions
{
    #region Unity 默认参数

    [Option('p', HelpText = "Unity 默认")] public string project_path { get; set; }

    [Option('e', HelpText = "Unity 默认")] public string executeMethod { get; set; }

    [Option('b', HelpText = "Unity 默认")] public bool batch_mode { get; set; }

    [Option('l', HelpText = "Unity 默认")] public string log_file { get; set; }

    #endregion

    #region General

    [Option(longName: "channel", Required = true, HelpText = "Channel to build")]
    public string channel { get; set; }

    [Option(longName: "builder", Required = true, HelpText = "IBuildImplement")]
    public string buider { get; set; }


    #endregion

    #region 自定义参数

    [Option(longName: "publish_server", Default = false)]
    public bool publish_server { get; set; }

    #endregion
}

接着来实现 AppBuilder,由于我们的构建流程希望是异步的,所以入口函数需要写成 async void,这点要注意,如果写成 async Task 会找不到函数报错

public static class AppBuilder
{
    public static async void BuildApp()
    {
        var args = Environment.GetCommandLineArgs();

        await Parser.Default.ParseArguments<BuildAppOptions>(args).WithParsedAsync(_Build);
    }
    
    private static async Task _Build(BuildAppOptions options)
    {
        try
        {
            BuildConfig config = new();
        
            config.channel_name = options.channel;
            // .. 略
            
            var builder = xxxx;
            var build_param = new CustomBuildParam
            {
                publish     = options.publish_server
            }
            await BuildHelper.Build(builder, config, build_param);
            
            EditorApplication.Exit(0);
        }
        catch(Exception e)
        {
            Log.Error(e);
            EditorApplication.Exit(1);
        }
    }
}

接着来到 ppl 的后台,此处我们希望在开始时,发送一个通知到开发群,接着更新 git 仓库、生成配表等操作,然后对 app 进行构建,最后当构建成功后,发送一个成功的 通知

这里的内容虽然也可以放在 Unity 中,但是正常在 Editor 下构建时,往往不需要更新 git,或者发送通知,因此此处我们的设计是这样的

在 Build Apk 这个流水线中,就是调用 Unity 的静态入口函数了,这里要尤其注意,如果你们的项目也同样采用了异步的构建方式,一定不要增加 -quit 参数,否则 5 分钟内没有构建完成,Unity 会认为当前任务失效,然后自杀

"xxx\Unity.exe" ^
-batchmode ^
-executeMethod xxx.AppBuilder.BuildApp ^
-logfile - "xxx\Client\Logs\pipeline_build.log" ^
--channel %channel% ^
--builder %builder% ^
--publish_server %publish_server%

这里涉及到的 %xxx% 参数,都是在运行 ppl 时,手动选择的,一般提供一个默认的参数,直接点击构建即可

最后

本篇文章是在我们项目实际使用的打包流水线的基础上做了比较多的功能删减,和段落优化,主要对项目需要直面的核心问题,提供一套解题思路,因此很可能无法完美匹配你自己项目的实际需求,所以一定要结合自己项目的实际需求,做技术选型,适合自己的才是最好的

推荐阅读

  1. C# 在不同环境下调用 shell 脚本: http://www.liuocean.com/archives/c-zai-bu-tong-huan-jing-xia-diao-yong-shell-jiao-ben
  2. Unity Andorid 多渠道管理: http://www.liuocean.com/archives/unity-andorid-duo-qu-dao-guan-li
  3. Unity 调用 gradle task: http://www.liuocean.com/archives/unity-diao-yong-gradle-task
  4. 如何设计游戏登录流程: http://www.liuocean.com/archives/ru-he-she-ji-you-xi-deng-lu-liu-cheng