UE 热更新:拆分基础包

在之前的几篇文章中,分别介绍了 UE 热更新的实现机制,以及热更的自动化流程,近期打算继续写几篇文章介绍下 UE 里热更新中资源包管理的流程和规则。

当然,不同类型的项目会有不同的打包策略,资源管理也没有通用的最佳策略。本篇文章主要介绍热更新流程中基础包的拆分的工程实践,涉及修改引擎实现 Android/IOS 通用拆分方式的方法,希望对不同业务的项目能提供一些有用的思路。

当项目发展到中后期的时候,会有大量的地图和美术资源,对于手游而言,包体的大小也是比较敏感的,而且 Android 还是 2G 的包体大小限制,所以当项目进行到一定阶段,拆分包体就是需要考虑的事情了,并且巨型的安装包也不利于推广。

在具有热更的情况下拆分基础包需要兼顾两个因素:

  • 减小包体的同时不能影响玩法
  • 要减少玩家的下载等待时间

这两个因素的取舍和实现或多或少都是需要与具体的游戏业务相关的,这里仅从实现层面来拆分基础包,不同的业务根据适合自己的业务规则来拆分就好。

UE 本身的资源管理,是可以在打包时进行拆分资源的,就是通过 Asset Manager 或者 AssetPrimaryLable 对资源按照类型、目录、地图、依赖分析等粒度进行资源的划分,也就是所谓的 Chunk 机制。
Chunk 划分的操作方法可以看 UE 的文档:

UE 的 Chunk 机制,可以把默认情况下打出来一个单独的巨型 pak 根据设置的拆分粒度打包成数个小的 pak(在 Priority 相同的情况下会具有一份资源在多个 chunk 中存在的情况),如果以地图和其依赖的资源作为 chunk 的划分机制,就可以让基础包内包含初始的关键地图,在热更或者运行时把其余的资源动态下载下来。

其实拆分基础包的关键就两点:

  1. 能够按照自定义分类拆分资源(chunk)
  2. 能够自己控制资源打包到基础包内的规则

第一点可以通过 Asset Manager 的 Chunk 机制实现,那么第二点如何实现呢?

Android

对于 Android 平台,因为有超过 2G 就会出包失败的问题,所以 UE 对 Android 默认提供了 ObbFilter 的功能,可以指定哪些文件要被添加到 Obb 中(pak/mp4)等。

控制方法只需要添加配置即可。

1
2
3
4
5
# Config/DefaultEngine.ini
[/Script/AndroidRuntimeSettings.AndroidRuntimeSettings]
+ObbFilters=-pakchunk1-*
+ObbFilters=-pakchunk2-*
+ObbFilters=-pakchunk3-*

ObbFilters 的规则以 - 开头就是排除规则,会把基础包中的 chunk1-3 的 pak 给过滤掉,可以用于后续的下载流程。

也可以指定 ExcluteInclude规则组合来用:

1
2
+ObbFilters=-*.pak
+ObbFilters=pakchunk0-*

第一步忽略掉所有的 pak 文件,然后把 pakchunk0-*.pak 显式添加至 obb 中。

IOS

但是 ,IOS 并没有提供这个功能,为了实现 IOS 与 Android 一样的过滤机制,我翻了下 UE 中打包 IOS 的代码,可以通过以下方式实现(需要修改iPhonePackager 的代码),思考和实现过程记录如下。

注意:我使用的 IPA 打包方式是远程构建,详见之前的文章:UE4 开发笔记:Mac/iOS 篇

在前面提到了 UE 为 Android 提供了打包到 obb 中的文件过滤规则:

1
2
3
# Config/DefaultEngine.ini
[/Script/AndroidRuntimeSettings.AndroidRuntimeSettings]
+ObbFilters=-pakchunk1-*

但是 UE 并没有为 IOS 提供相应的操作,默认情况下会把 IOS 的所有的 pak 文件都打包至 IPA 中。

为了统一 Android 和 IOS 的基础包规则,我自己实现了 IOS 上类似 Android 那种指定过滤规则的功能,做个简单的介绍。

我使用的是 Mac 远程打包,流程是在 Mac 上编译代码生成 IPA,拉回 Win,在 Win 上进行 Cook,生成 Pak 文件,最后把原始 IPA 解包,再添加 Pak 等文件组合成最终 IPA。

我的需求是,自定义指定过滤规则,可以把某些文件忽略,不打包到 IPA 中。那么这一步的操作其实就位于把 IPA 解包再打包的流程里,经过翻阅 UE 的代码,发现这个操作是通过 iPhonePackager 这个独立程序来实现的,那么就需要对这个程序的代码进行改造了。

经过调试分析,发现真正实现重新打包 IPA 的操作是在以下函数中执行的:

Programs/IOS/iPhonePackager/CookTime.cs
1
2
3
4
/** 
* Using the stub IPA previously compiled on the Mac, create a new IPA with assets
*/
static public void RepackageIPAFromStub();

该函数位于 iPhonePackager-CookTime 类中。

1
2
3
4
5
6
7
8
9
10
11
12
static public void RepackageIPAFromStub()
{
// ...
string SourceDir = Path.GetFullPath(ZipSourceDir);
string[] PayloadFiles = Directory.GetFiles(SourceDir, "*.*", Config.bIterate ? SearchOption.TopDirectoryOnly : SearchOption.AllDirectories);
foreach (string Filename in PayloadFiles)
{
// read file to memory,add to ZipFileSystem
// generate stub and ipa
}
//...
}

需要做的操作就是介入这个过程,把 PayloadFiles 中的文件列表通过我们自定义的规则来执行过滤。

从流程上分为以下几个步骤:

  1. 从项目中读取 Filter 的配置
  2. 创建出真正的过滤器
  3. RepackageIPAFromStub 遍历文件的流程里使用过滤器进行检测是否需要被打入 ipa

只需要几十行代码就可以实现,首先需要添加一个 IniReader 的类:

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
using Tools.DotNETCommon;
using System.Runtime.InteropServices;
using Ini;

namespace Ini
{
public class IniReader
{
private string path;

[DllImport("kernel32")]
private static extern int GetPrivateProfileString(string section, string key, string def,
StringBuilder retVal, int size, string filePath);

public IniReader(string INIPath)
{
path = INIPath;
}

public string ReadValue(string Section, string Key)
{
StringBuilder ReaderBuffer = new StringBuilder(255);
int ret = GetPrivateProfileString(Section, Key, "", ReaderBuffer, 255, this.path);
return ReaderBuffer.ToString();
}
}
}

然后在 RepackageIPAFromStub 函数中创建过滤器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
FileFilter IpaPakFileFilter = new FileFilter(FileFilterType.Include);
{
string ProjectDir = Directory.GetParent(Path.GetFullPath(Config.ProjectFile)).FullName;
// Program.Log("ProjectDir path {0}", ProjectDir);
string EngineIni = Path.Combine(ProjectDir,"Config","DefaultEngine.ini");
// Program.Log("EngineIni path {0}", EngineIni);
IniReader EngineIniReader = new IniReader(EngineIni);
string RawPakFilterRules = EngineIniReader.ReadValue("/Script/IOSRuntimeSettings.IOSRuntimeSettings", "IPAFilters");
Program.Log("RawPakFilterRules {0}", RawPakFilterRules);
string[] PakRules = RawPakFilterRules.Split(',');
// foreach(string Rule in PakRules) {Program.Log("PakRules {0}", Rule);}

List<string> PakFilters = new List<string>(PakRules);
if (PakFilters != null)
{
IpaPakFileFilter.AddRules(PakFilters);
}
}

这里从项目的 Config/DefaultEngine.ini 的[/Script/IOSRuntimeSettings.IOSRuntimeSettings]项读取 IPAFilters 的值,规则与 Android 相同,也可以指定 ExcluteInclude规则,但是要把规则都写在一行,多个规则以逗号分隔。

1
2
[/Script/IOSRuntimeSettings.IOSRuntimeSettings]
IPAFilters=-*.pak,pakchunk0-*

最终,还需要在 RepackageIPAFromStub 遍历 Payload 文件的循环中进行检测是否匹配我们指定的过滤规则:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static public void RepackageIPAFromStub()
{
// ...
string SourceDir = Path.GetFullPath(ZipSourceDir);
string[] PayloadFiles = Directory.GetFiles(SourceDir, "*.*", Config.bIterate ? SearchOption.TopDirectoryOnly : SearchOption.AllDirectories);
foreach (string Filename in PayloadFiles)
{
if (!IpaPakFileFilter.Matches(Filename))
{
Program.Log("IpaPakFileFilter not match file {0}", Filename);
continue;
}
// Program.Log("IpaPakFileFilter match file {0}", Filename);
}
//...
}

这样再执行打包 IOS,就会按照指定的过滤规则来添加文件了,实现了与 Android 上一致的行为。

打包过程中的 Log 如下(上文代码已注释):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Saving IPA ...
ProjectDir path C:\BuildAgent\workspace\PackageWindows\Client
EngineIni path C:\BuildAgent\workspace\PackageWindows\Client\Config\DefaultEngine.ini
RawPakFilterRules -*.pak,pakchunk0-*
PakRules -*.pak
PakRules pakchunk0-*
IpaPakFileFilter match file C:\BuildAgent\workspace\PackageWindows\Client\Saved\StagedBuilds\IOS\Assets.car
IpaPakFileFilter match file C:\BuildAgent\workspace\PackageWindows\Client\Saved\StagedBuilds\IOS\Info.plist
IpaPakFileFilter match file C:\BuildAgent\workspace\PackageWindows\Client\Saved\StagedBuilds\IOS\LaunchScreenIOS.png
IpaPakFileFilter match file C:\BuildAgent\workspace\PackageWindows\Client\Saved\StagedBuilds\IOS\Manifest_DebugFiles_IOS.txt
IpaPakFileFilter match file C:\BuildAgent\workspace\PackageWindows\Client\Saved\StagedBuilds\IOS\Manifest_NonUFSFiles_IOS.txt
IpaPakFileFilter match file C:\BuildAgent\workspace\PackageWindows\Client\Saved\StagedBuilds\IOS\mute.caf
IpaPakFileFilter match file C:\BuildAgent\workspace\PackageWindows\Client\Saved\StagedBuilds\IOS\ue4commandline.txt
IpaPakFileFilter match file C:\BuildAgent\workspace\PackageWindows\Client\Saved\StagedBuilds\IOS\cookeddata\fgame\content\movies\logo.mp4
IpaPakFileFilter match file C:\BuildAgent\workspace\PackageWindows\Client\Saved\StagedBuilds\IOS\cookeddata\fgame\content\movies\sparkmore.mp4
IpaPakFileFilter match file C:\BuildAgent\workspace\PackageWindows\Client\Saved\StagedBuilds\IOS\cookeddata\fgame\content\paks\pakchunk0-ios.pak
IpaPakFileFilter not match file C:\BuildAgent\workspace\PackageWindows\Client\Saved\StagedBuilds\IOS\cookeddata\fgame\content\paks\pakchunk1-ios.pak
IpaPakFileFilter not match file C:\BuildAgent\workspace\PackageWindows\Client\Saved\StagedBuilds\IOS\cookeddata\fgame\content\paks\pakchunk2-ios.pak
...

可以看到,过滤规则已经生效了,除此之外不会对打出来的包有任何其他影响(当然这种默认情况下是丢失了资源的,还要实现一套下载机制,可以参考我之前热更系列的文章)。

注意:因为 iPhonePackager 是个 Program 类型的程序,并不依赖引擎,所以将其编译完之后是可以拷贝到非源码版引擎使用的。

注意 ,在非远程构建,直接在 Mac 中打 IOS 包的并不能修改 IphonePackager 的代码,因为非远程构建不会用到它。实现相同的效果需要修改IOSPlatform.Automation.cs 中的流程,把上面的代码加到 Package 函数中,实现过滤行为。

而且,默认 UE 在 mac 上应该也不会编译 csharp 的 program 的工程,可以在 Win 上修改了 AutomationTool 后拷贝到 Mac 上。

End

通过上面的操作,可以实现 Android/IOS 相同的基础包过滤规则,把工程内最关键的资源打包到基础包中,其余的 pak 可以在打完基础包之后从 Saved/StagedBuilds 中提取出来,放到热更平台启动时下载或者根据项目的类型和需求设计运行时下载的方案。