UE热更新:拆分基础包

UE Hot Update: split the basic package

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

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

在实际的工程实践中,我们抛弃了UE自带的包拆分方案,基于HotPatcher框架实现了一套灵活的包拆分方案,详见文章:资源管理:重塑 UE 的包拆分方案

当项目发展到中后期的时候,会有大量的地图和美术资源,对于手游而言,包体的大小也是比较敏感的,而且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.webp
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上。

Priority打包时失效

当拆分基础包时,想要通过控制PrimaryAssetLabelPriority来减少资源冗余,但是测试发现并不会生效(优先级较高和较低的Chunk中都会包含相同的资源):

看了下引擎中的代码,是在AssetManager::GetPackageManagers中获取资源被哪些PrimaryAssetId管理的:

Engine\Source\Runtime\Engine\Private\AssetManager.cpp
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
bool UAssetManager::GetPackageManagers(FName PackageName, bool bRecurseToParents, TSet<FPrimaryAssetId>& ManagerSet) const
{
IAssetRegistry& AssetRegistry = GetAssetRegistry();

bool bFoundAny = false;
TArray<FAssetIdentifier> ReferencingPrimaryAssets;
ReferencingPrimaryAssets.Reserve(128);

AssetRegistry.GetReferencers(PackageName, ReferencingPrimaryAssets, EAssetRegistryDependencyType::Manage);

for (int32 IdentifierIndex = 0; IdentifierIndex < ReferencingPrimaryAssets.Num(); IdentifierIndex++)
{
FPrimaryAssetId PrimaryAssetId = ReferencingPrimaryAssets[IdentifierIndex].GetPrimaryAssetId();
if (PrimaryAssetId.IsValid())
{
bFoundAny = true;
ManagerSet.Add(PrimaryAssetId);

if (bRecurseToParents)
{
const TArray<FPrimaryAssetId> *ManagementParents = ManagementParentMap.Find(PrimaryAssetId);

if (ManagementParents)
{
for (const FPrimaryAssetId& Manager : *ManagementParents)
{
if (!ManagerSet.Contains(Manager))
{
// Add to end of list, this will recurse again if needed
ReferencingPrimaryAssets.Add(Manager);
}
}
}
}
}
}
return bFoundAny;
}

默认情况下,就完全没有用到Priority这个值。

所以需要修改引擎的这部分实现,让获取资源属于哪个PrimaryAssetId时,获取Priotiry最大(或最大的相同值)的Label列表。但是,如果Label有在运行中使用的话有点违反直觉,建议Priority的功能只在打包时有效,在运行时如果加载PrimaryAssetLabel不会对资源管理造成影响,可以通过FParse::Param获取引擎启动参数来进行检测。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
FPrimaryAssetId AddPrimaryAssetId = PrimaryAssetId;
if(FParse::Param(FCommandLine::Get(),TEXT("labelpriority")))
{
TArray<FPrimaryAssetId> ManagerArray = ManagerSet.Array();
FPrimaryAssetRules Rules = GetPrimaryAssetRules(PrimaryAssetId);
for(const auto& Manager:ManagerArray)
{
FPrimaryAssetRules ExistRules = GetPrimaryAssetRules(Manager);
if(Rules.Priority > ExistRules.Priority)
{
ManagerSet.Remove(Manager);
}
if(Rules.Priority < ExistRules.Priority)
{
AddPrimaryAssetId = FPrimaryAssetId();
}
}
}
if(AddPrimaryAssetId.IsValid())
{
ManagerSet.Add(PrimaryAssetId);
}

在Cook时开启-labelpriority之后,资源就只会在Priority值高的chunk中存在了。

End

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

全文完,若有不足之处请评论指正。

微信扫描二维码,关注我的公众号。

本文标题:UE热更新:拆分基础包
文章作者:查利鹏
发布时间:2021/01/27 21:51
本文字数:3.2k 字
原始链接:https://imzlp.com/posts/13765/
许可协议: CC BY-NC-SA 4.0
文章禁止全文转载,摘要转发请保留原文链接及作者信息,谢谢!
您的捐赠将鼓励我继续创作!