oynix

于无声处听惊雷,于无色处见繁花

Unity一键导出和打包的辅助工具

最近在写一套模版框架,基于Addressables和ILRuntime,免不了要导出工程、编译打包之类的工作,而这些内容多数都是重复性工作,此时一个辅助工具可以大大减少在这上面花费的时间。

1. 简介

目前主要在做移动端,所以只支持了Android和iOS两种平台,其他的平台暂时还用不到,以后若有需要可能会再加。

Android平台下,Unity支持直接打包,且支持apk和aab两种格式,当然也支持导出一个Android的工程,随后手动操作;而iOS平台,由于独具独特风格,只能通过Xcode打包编译,所以只能导出工程。

按打包方式分,这里可以分为2种,一种是完整包,即需要提交应用商店审核的包,另一种是热更新的补丁包,而这种是基于Addressables的资源更新,所以不需要通过应用商店,资源的catalog更新后,用户端会根据最新的地址加载对应的bundle文件。

打一次完整的应用包,需要配置很多参数,但大多数参数都是一次性配置,即只需要在第一次打包时配置一次,之后便不用再修改,而每次打包都需要改动的只是其中部分配置。

在写之前、以及写的过程中,我一直在犹豫这个工具需要集成进来多少配置项,像那些只需配置一次的参数有没有必要集成到这个工具中来,是追求打包工具的全面性、完整性,还是追求简洁性、最小可用性,反复思虑再三,最终选择了后者,同时兼顾了一点前者,正如标题所言,将其定位成一个辅助工具。

所有配置项中,系统自带的主要就是2个地方,一个是Build Settings窗口里的配置,还有一个是Player Settings里的配置,如果还有其他的,那就是集成的第三方库的配置了,这个要看具体需求具体操作。

2. 版本号

Android平台的版本号没有格式限制,只是一个字符串,但iOS要求是3位,为了兼顾两个平台,决定使用3位格式的版本号,即a.b.c,前两位a.b表示功能版本,小功能迭代增加b,大功能迭代增加a,末位的c表示该版本的修复版本,每修复一次则增加1。

其中,这个修复既可以是热修复,即不通过提审发版本的修复,也可以是提审发版本的修复。热更新并不是可以解决所有的线上问题,当无法通过热修复解决时,便只能通过提交新的应用包来解决。当然,当提交新的审核时,也可以增加b的值,表示小版本功能迭代,而这个版本的内容就是bug fix。

当a或b任一值增加时,c便再次从0开始,新的起点。

因为在热更新时,只会更新资源,而不会更新应用本身的信息,所以版本号需要单独写到一个文件中,以资源的形式存在,这样可以在应用中读到最新的版本号。

3. 一次性配置项

即首次打完整应用包时需要配置的内容。简单来说,就是把Player Settings里的每一项都过一遍,然后按需配置即可,但是前提是需要知道每一个配置项的作用是什么,是用来控制指什么的,以及不同可选值的区别是什么,这些可以到官网的文档查看,每一项都有具体的介绍。

Unity Player Settings介绍文档

4. 打完整应用包流程

如果,辅助工具中集成了以上配置项。是否Hotfix不用勾选,如果没有特殊需求,导出工程也不用勾选,导出格式按需勾选,默认是apk,如果需要aab则勾选。

Profile指的是Addressables的Profile,这里面指定的是资源的Build Path和Load Path,一般按平台或者模式区分,比如ProfileAndroid、ProfileiOS,ProfileDev,如果手头富裕,甚至可以再次细分,比如ProfileAndroidDev、ProfileiOSDev等,而包名一般与Profile成一一对应的关系,但也不强求,因为资源不关心包名。

内部流程是,首先需要更新group信息,即Assets/AddressableAssetsData/AssetsGroup下的配置文件,然后就可以调用打包方法了,

1
BuildPipeline.BuildPlayer(_scenesBuild, outputPath, target, BuildOptions.None);

其中_scenesBuild参数是需要编译的场景,同Build Settings里的场景,关于这个outpuPath,当需要导出工程时它是一个目录的路径,当不需要导出工程时它是一个apk或者aab文件的路径,而target则是指当前的平台,Android/iOS。

这个方法会先生成bundle文件,如果导出工程则直接导出,如果不导出则执行IL2CPP,接着通过Gradle打包成apk/aab。

打包完成后,需要先备份Addressable的配置文件。之前的文章里提过,只要group的配置文件不变,group里的资源不变,那么这个group最后生成的bundle文件的hash就不会变。当我们想要热更的时候,需要以某个版本为基础,来判断本次需要热更的内容都有哪些,所以为了避免开发过程中group的配置文件发生变动,所以除了state bin文件,连同这些配置文件也一同备份,共同复制一份到Assets目录下的Version目录中,结构如下:

1
2
3
4
5
6
7
8
9
Assets
Version
|--iOS
|--Android
|--ProfileDev
|--1.0.0
|--AddressableAssetsData
|--Bundles
|--type.txt

AddressableAssetsData是从Assets下直接复制过来的,Bundles是本次打包后需要上传到远程资源服务器的所有bundle文件,而type.txt则是对此版本的一些数据记录,比如版本号、是完整包还是补丁包等。

测试阶段的资源服务器我用的是git code,所以对于这些bundle文件,只需要复制一份到git仓库下,然后push到远程仓库即可,如果需要其他处理方式,如上传到自己的资源服务器,我定义了一个回调,直接对其复制即可:

1
public static Action<string> UploadBundlesHandler;

备份完成后,就会调用这个Action,参数则是一个目录的全路径,其下的所有文件都需要上传到资源服务器。

5. 打热更资源的流程

若要打热更资源,只需要把Hotfix勾选上,没用的配置都会隐藏,只剩下几个必须的选项。对于当下选择的Profile,基于的版本里会列出所有可用的版本,以及每个版本的信息,版本名则按照所选的基于的版本设置即可。

当开始构建后,首先会根据选择的基于版本,用该版本的addressable配置文件替换当前的配置文件,然后在此配置的基础上,刷新group配置生成最新的配置,然后调用方法,检测哪些资源发生了变化,并提示:

如上图,本次只改了一个版本号,所以只有一个文件发生变化,注意,如果是远程可变的group发生了变化,则不会在这里提示。然后将这些资源抽取出来,单独放到一个group中,一般叫做ContentUpdate,即补丁包。此时的group配置状态,则是最终的状态,根据此状态下的group,打包资源。

打包完成后,同上面操作一样,将当前版本的配置备份,将远程bundle上传。

6. 部分实现代码

打完整包

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
private void BuildAll()
{
var target = EditorUserBuildSettings.activeBuildTarget;
var configPath = Path.Join(Application.dataPath, "AddressableAssetsData", _platformName);
EditorUtils.TryDeleteDir(configPath);

// 刷新group
UpdateGroups();

// aab/apk 输出路径
EditorUtils.TryDeleteDir(_exportOutputPath);

var outputPath = _exportOutputPath;
if (target == BuildTarget.Android && !EditorUserBuildSettings.exportAsGoogleAndroidProject)
{
var extension = EditorUserBuildSettings.buildAppBundle ? "aab" : "apk";
var filaName = $"{PlayerSettings.productName}_{PlayerSettings.bundleVersion}.{extension}";
outputPath = Path.Join(_exportOutputPath, filaName);
}

BuildPipeline.BuildPlayer(_scenesBuild, outputPath, target, BuildOptions.None);

BackupVersion();

EditorUtility.RevealInFinder(_exportOutputPath);
}

打热更资源包

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
private void BuildPatch()
{
if (_hotfixReliedVerIndex >= _versionPaths.Count)
throw new ArgumentException("no replied on version found");

// VersionAndroid/ProfileDev/1.0.0
var repliedVersionPath = _versionPaths[_hotfixReliedVerIndex];
if (!Directory.Exists(repliedVersionPath))
throw new ArgumentException($"{repliedVersionPath} not exist");

// Version/Android/ProfileDev/1.0.1/AddressableAssetsData
var repliedConfigPath = Path.Join(repliedVersionPath, "AddressableAssetsData");
if (!Directory.Exists(repliedConfigPath))
throw new ArgumentException($"{repliedConfigPath} not exist");

var statePath = Path.Join(repliedConfigPath, $"{_platformName}/addressables_content_state.bin");
if (!File.Exists(statePath))
throw new ArgumentException($"{statePath} not exist");

// 将原有config删掉,用rely on的配置替换
var configPath = Path.Join(Application.dataPath, "AddressableAssetsData");
EditorUtils.TryDeleteDir(configPath);
EditorUtils.CopyDirectory(repliedConfigPath, configPath, false);

// 刷新group
UpdateGroups();


var entries = ContentUpdateScript.GatherModifiedEntries(_addressable, statePath);
var sb = new StringBuilder();
foreach (var entry in entries)
{
sb.AppendLine(entry.AssetPath);
}

var ok = EditorUtility.DisplayDialog("Assets Changed", sb.ToString(), "OK", "Cancel");
if (ok)
{
var gn = $"ContentUpdate_{DateTime.Now:yyyyMMddhhmmss}";
ContentUpdateScript.CreateContentUpdateGroup(_addressable, entries, gn);
ContentUpdateScript.BuildContentUpdate(_addressable, statePath);

var backupVersionPath = BackupVersion();

EditorUtility.RevealInFinder(backupVersionPath);
}
}

资源配置备份

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
private string BackupVersion()
{
var profileName = _addressable.profileSettings.GetProfileName(_addressable.activeProfileId);

var versionBackPath = Path.Join(_backupPath, profileName, PlayerSettings.bundleVersion);

var configPath = Path.Join(Application.dataPath, "AddressableAssetsData");
var backupPath = Path.Join(versionBackPath, "AddressableAssetsData");
EditorUtils.CopyDirectory(configPath, backupPath, false);

var bundleBuildPath = Tools.GetAddressableRemoteBuildPath();
var bundleBackupPath = Path.Join(versionBackPath, "Bundles");
EditorUtils.CopyDirectory(bundleBuildPath, bundleBackupPath, false);

if (_platformName == "Android" && !_isHotfix)
{
EditorUtils.CopyFiles(_exportOutputPath, versionBackPath,
fn => fn.EndsWith(".apk") || fn.EndsWith(".aab"));
}

var typePath = Path.Join(versionBackPath, "type.txt");
if (_isHotfix)
{
var repliedVersion = Path.GetFileName(_versionPaths[_hotfixReliedVerIndex]);
File.WriteAllText(typePath, $"Hotfix, Version Code: {GetVersionCode()}, Rely on {repliedVersion}");
}
else
{
File.WriteAllText(typePath, $"All, Version Code: {GetVersionCode()}");
}

return versionBackPath;
}

------------- (完) -------------
  • 本文作者: oynix
  • 本文链接: https://oynix.com/2023/07/147fd5ec7e49/
  • 版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!

欢迎关注我的其它发布渠道