UE 热更新:需求分析与方案设计

游戏热更新是在玩家不重新安装游戏的前提下获取最新游戏内容的方式,在 PC 和移动端的网络游戏中有很多应用,因为游戏上上线后要快速调整、修复 bug、更新内容等等。如果每修改一点点内容都需要玩家去 AppStore 更新应用,甚至去网站手动下载再安装,而且不同的平台对于游戏的审核规则和反馈时间也不一致,运营也会疯掉。

在其他引擎中的热更应该有比较成熟的方案,但是在 UE 里还没看到有比较全面的文章来讲 UE4 的热更实现的文章,恰好之前分析和实现了 UE4 热更的内容,准备写两篇文章来记录一下思路和实现方案,并会实现一个可以运行的 Demo,希望能对有需要的朋友一点帮助。

为了方便地统一收集和管理热更新和 HotPatcher 常见的问题与解决方案,我新建了一篇文章来记录和整理:UE4 热更新:Questions & Answers,遇到问题可以先去看这个 FAQ 页面。

本篇文章会从 需求分析 方案设计 两个部分入手,主要是研究热更方案时的思考总结。热更的具体实现写到下一篇文章中。

核心需求分析

因为是 热更新,实际上是要把游戏内容的更新推迟到运行时,从最简单但是最核心的流程上来说是下面这样的执行结构:

根据上面的流程图可以把热更要做的具体任务拆分成以下几个要解决的问题:

  1. UE 中哪些内容可以被热更?
  2. 如何打包可以热更的内容和管理热更?
  3. UE 里如何使用热更下载下来的资源包?
  4. 如何对比本地和服务器最新的版本?
  5. 热更文件的下载和校验

热更新的需求里 最重要 的是要解决 如何打包资源 和进行 版本管理 的问题,至于下载流程则不同的业务自己变动,我这里只写最基本可以实现热更的下载更新流程。

一条条来分析这些问题。

在 UE 中哪些内容可以被热更?

首先,从 程序角度 来说,UE 官方提供 C++ 和蓝图作为引擎提供的开发语言和脚本,而 C++ 是个编译型语言,所有的变动都需要执行编译操作,所以 C++ 的代码无法热更。而 蓝图 本质上是 资产 (uasset),资产的更新并不需要重新安装,所以使用 蓝图 所写的游戏逻辑是可以热更的。

但是,蓝图毕竟是 资源,这就要求当每改动一点点蓝图逻辑,都需要执行 Cook 才可以打包,更新起来很不方便,而且蓝图项目协同开发很难管理,所以需要集成一种文本化的脚本语言。

在国内的游戏开发以集成 Lua 为业务脚本的居多,腾讯也开源了两个 UE 集成 Lua 的插件,分别是 sluaunrealUnLua,可以在 UE 内集成 Lua 的脚本来替代蓝图写业务逻辑。

我在项目中选择的是 UnLua,并且我在 UnLua 的官方版本基础上集成了一些常用的 Lua 库、编辑器的拓展、常用的库导出、以及一些 bug 的修复。在 Github 上开源:debugable-unlua,我会 不定期 地合并官方版本。

我之前的一篇文章写了使用 UnLua 的一些内容:UE4 热更新:基于 UnLua 的 Lua 编程指南

其次,工程内的所有 uasset 资源 (地图、蓝图、模型、动画、贴图、UMG、音频、字体等等)资源也是可以更新的,UE 的 uasset 在打包之前都需要 Cook,而 Cook 的含义是把 UE 中 平台无关 的虚幻内部格式转换为特定平台的格式,因为各个平台使用自己的专有格式或者各个平台上具有性能更好的存储格式。

以 Windows 为例,在对 UE 的工程进行打包后会产生出类似下面这种路径关系的文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
D:\Examples\PackageExample\Package>tree /f
卷 Windows 的文件夹 PATH 列表
卷序列号为 BAB5-5234
C:.
└─WindowsNoEditor
│ Manifest_NonUFSFiles_Win64.txt
│ PackageExample.exe

├─Engine
│ ├─ ...

└─PackageExample
└─Content
└─Paks
PackageExample-WindowsNoEditor.pak

其中 Content/Paks 下的 *.pak 文件就是 UE 打包出的所有非代码资源,游戏启动时会从 pak 里读取资源,里面不仅仅只有 uasst 的内容,可以这么理解:pak 就是游戏运行所需要的资源包。pak 中支持的内容就是 UE 热更所支持的内容。

默认情况下(未设置忽略文件)UE4 打包时会默认把工程中的资源都打包到一个 Pak 文件中,具体有以下内容:

以下描述中有几个关键字:PROJECT_NAME项目名,PLATFORN_NAME打包的平台名。

  • Package 时不会检测资源是否有引用,工程内的所有资源都会被 Cook 然后打包到 Pak 里;

  • 引擎 Slate 的资源文件Engine\Content\Slate\,字体 / 图片等等

  • 引擎的 Content\Internationalization 下相关语言的文件

  • 引擎和启用插件目录下的 Content\Localizationlocmeta/locres文件

  • 项目的 uproject 文件,挂载点为../../../PROJECT_NAME/PROJECT_NAME.uproject

  • 项目启用的所有插件的 uplugin 文件,挂载点为插件的相对与 ../../../Engine/ 或者 ../../../PROJECT_NAME/Plugins/ 的路径;

  • 项目目录下 Intermediate\Staging\PROJECT_NAME.upluginmanifest 文件,挂载点为../../../PROJECT_NAME/Plugins/PROJECT_NAME.upluginmanifest

  • 引擎的 ini 文件,在引擎的 Engine/Config 下除了 Editor 的 ini 和 BaseLightmass.ini/BasePakFileRules.ini 之外都包含;

  • 引擎下平台的 ini,在 Engine/Config/PLATFORM_NAME 内的所有 ini 文件;

  • 项目启用的插件的 ini,在插件的目录的 config 下;

  • Cook 出来的AssetRegistry.bin

  • Cook 出的PLATFORN_NAME\Engine\GlobalShaderCache*.bin

  • Cook 出来的 PLATFORM_NAME\PROJECT_NAME\Content\ShaderArchive-*.ushaderbytecode 文件

  • 通过 Project Setting-Packaing-Add Non-Asset Directory* 等添加的非 uasst 文件

以上这些文件在 UE 中都是可以热更的。

可以在 Engine\Config\BaseGame.ini 中看到相关配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
+EarlyDownloaderPakFileFiles=...\Content\Internationalization\...\*.icu
+EarlyDownloaderPakFileFiles=...\Content\Internationalization\...\*.brk
+EarlyDownloaderPakFileFiles=...\Content\Internationalization\...\*.res
+EarlyDownloaderPakFileFiles=...\Content\Internationalization\...\*.nrm
+EarlyDownloaderPakFileFiles=...\Content\Internationalization\...\*.cfu
+EarlyDownloaderPakFileFiles=...\Content\Localization\...\*.*
+EarlyDownloaderPakFileFiles=...\Content\Localization\*.*
+EarlyDownloaderPakFileFiles=...\Content\Certificates\...\*.*
+EarlyDownloaderPakFileFiles=...\Content\Certificates\*.*
; have special cased game localization so that it's not required for early pak file
+EarlyDownloaderPakFileFiles=-...\Content\Localization\Game\...\*.*
+EarlyDownloaderPakFileFiles=-...\Content\Localization\Game\*.*
+EarlyDownloaderPakFileFiles=...\Config\...\*.ini
+EarlyDownloaderPakFileFiles=...\Config\*.ini
+EarlyDownloaderPakFileFiles=...\Engine\GlobalShaderCache*.bin
+EarlyDownloaderPakFileFiles=...\Content\ShaderArchive-Global*.ushaderbytecode
+EarlyDownloaderPakFileFiles=...\Content\Slate\*.*
+EarlyDownloaderPakFileFiles=...\Content\Slate\...\*.*
+EarlyDownloaderPakFileFiles=...\*.upluginmanifest
+EarlyDownloaderPakFileFiles=...\*.uproject
+EarlyDownloaderPakFileFiles=...\global_sf*.metalmap

除了引擎内置的资源类型,要实现 程序热更 最关键的一点在于:也可以把 非资源文件 打包到 Pak 中,所以前面提到的使用 UnLua 来作为业务脚本后的更新问题就可以解决了。

如何打包热更内容和管理版本?

通过上一小节,可以知道了 UE 能够热更的内容有哪些,热更还有一个最关键的点是:如何把想要更新的内容打包出来作为热更的下载文件?

UE 全平台的打包都有包含 pak 文件(有的在包内,有的则是单独的文件),所以如何把需要的资源打出 pak 就是我们的需求。UE 使用 UnrealPak 这个工具把文件打包成一个 pak,同时 UnrealPak 还提供了查看 Pak 中有哪些文件、以及从 Pak 中解压文件的命令,这部分的内容可以在我之前的一篇文章中查看:UE4 工具链配置与开发技巧 #UnrealPak 的参数,网络上也有不少相关的文章。

UE 打包时会调用 UnrealPak 来生成 Pak 文件,通过打包时收集到的资源信息生成 pak-commandlist.txt 里面记录着要打到 Pak 中的资源信息以及挂载点。

基础命令如下:

1
UnrealPak.exe D:\TEST.pak -create="XXXXXXX.txt"

只是单纯地打出 pak 的问题可以直接调用 UnrealPak,而热更另一个更重要的点在于: 该如何控制把哪些资源打包到指定的 pak 里?

其实 UE 本身提供了类似 Chunk 的功能,但是用起来很不方便,需要给每个资源指定 chunk id,只有打包才会产生,没有办法很方便地控制每个 pak 中会包含哪些资源。

而且 UE 默认打包的时候只能添加 Content 路径下的非资源文件,这样的限制就导致了很多文件没办法很方便地更新。虽然在 Project Launcher 中也提供了打 Patch 的操作,但是哪些资源会被打包进来是黑盒的,没办法精确地知道这个 Patch 中会包含哪些文件。而且还无法基于一个 Patch 再打出一个 Patch,这些问题导致官方的打包方案解决不了我们的热更需求。

所以我开发了一款插件用来解决这些问题,开源在 Github 上:hxhb/HotPatcher,以及文档介绍:UE4 资源热更打包工具 HotPatcher

它的功能就是为了方便地在 UE 编辑器中来指定把哪些资源、哪个文件打包到哪个 pak 中,而且还提供了一键 Cook 多平台的功能,可以一键打出多个平台的 pak 包,支持导出版本信息,支持迭代 Patch。

核心思路为:打基础包时可以通过 HotPatcher 导出基础包内的资源信息,在变动了工程中内容时,通过导入 基础包内的资源信息 与当前工程中的资源信息进行比对,得到差异资源,将差异资源打包到 Pak 中。

本篇文章不详细介绍 HotPatcher 的用法,具体的使用文档和参数说明可以从上面的文档链接中查看。

总的来说,使用 hxhb/HotPatcher 就可以实现 UE 热更资源的打包和热更版本的管理!

UE 里如何使用热更下载下来的资源包?

这里所说的 资源包 就是上一小节打包出来的 pak 文件。

UE 默认情况下提供了自动挂载 Pak 的三个路径:

1
2
3
4
5
# relative to Project Path
Content/Paks/
Saved/Paks/
# relative to Engine Path
Content/Paks

在我之前的笔记中记录了引擎启动时加载 Pak 的流程:UE4: 引擎启动时 Pak 的加载

把打出来的 Pak 直接放到这三个目录下,在没有开启 Signing 的情况下,是会默认加载这三个路径下的所有 Pak 的。从测试角度来说,可以把打包出来的 pak 文件放到这三个文件夹下的其中一个,再启动游戏,游戏就会自动挂载。

这又衍生出来了另一个问题。怎么让 UE 知道我哪个 Pak 文件是最新的?

因为热更就是要把新更新的资源替换掉之前的旧的资源,必须要能够找到我们指定的最新的 pak 所有的新内容才会生效。

以引擎自动挂载的那三个路径为例,引擎从文件夹层面给这三个路径分别的优先级:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Runtime\PakFile\Private\IPlatformFilePak.cpp
int32 FPakPlatformFile::GetPakOrderFromPakFilePath(const FString& PakFilePath)
{
if (PakFilePath.StartsWith(FString::Printf(TEXT("%sPaks/%s-"), *FPaths::ProjectContentDir(), FApp::GetProjectName())))
{
return 4;
}
else if (PakFilePath.StartsWith(FPaths::ProjectContentDir()))
{
return 3;
}
else if (PakFilePath.StartsWith(FPaths::EngineContentDir()))
{
return 2;
}
else if (PakFilePath.StartsWith(FPaths::ProjectSavedDir()))
{
return 1;
}

return 0;
}

可以看到位于 Content/Paks 目录下并且以项目名为前缀的 pak 文件默认具有最高优先级,其次分别为Project/Content/Paks>Engine/Content/Pak>Saved/Paks

而且,Pak 文件的命名也会对优先级有影响。

_Num_P.pak 结尾的文件,其中 Num 是数字,Patch 包的优先级高于普通的 pak,在 IPlatformFilePak.cpp 中默认给 _P.pakPakOrder加了 100,_P.pak前面的数字越大,其加载的优先级就越高。

这个 优先级 数组会用在 mount pak 时,需要给每个 pak 文件指定,用与引擎在进行资源查找、文件加载时精确地找到哪个 pak 中的文件才是最新的版本。

但是这种情况下如果被恶意者知道了规则,故意把 pak 的文件命名给搞乱了,就会导致程序错误,所以不建议直接使用基于文件命名来确定优先级的规则,自己在热更时应该做一次校验。

还有,为了热更需求,需要自己控制 pak 的挂载时机,这就需要自己来调用 MountPak 的函数,我写了一个封装函数:

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
bool MountPak(const FString& PakPath, int32 PakOrder, const FString& InMountPoint)
{
bool bMounted = false;
#if !WITH_EDITOR
FPakPlatformFile* PakFileMgr=(FPakPlatformFile*)FPlatformFileManager::Get().GetPlatformFile(FPakPlatformFile::GetTypeName());
if (!PakFileMgr)
{
UE_LOG(LogTemp, Log, TEXT("GetPlatformFile(TEXT(\"PakFile\") is NULL"));
return false;
}

PakOrder = FMath::Max(0, PakOrder);

if (FPaths::FileExists(PakPath) && FPaths::GetExtension(PakPath) == TEXT("pak"))
{
const TCHAR* MountPount = InMountPoint.GetCharArray().GetData();
if (PakFileMgr->Mount(*PakPath, PakOrder,MountPount))
{
UE_LOG(LogTemp, Log, TEXT("Mounted = %s, Order = %d, MountPoint = %s"), *PakPath, PakOrder, !MountPount ? TEXT("(NULL)") : MountPount);
bMounted = true;
}
else {
UE_LOG(LogTemp, Error, TEXT("Faild to mount pak = %s"), *PakPath);
bMounted = false;
}
}

#endif
return bMounted;
}

当 Pak 文件从服务器下载下来时就可以调用这个函数来把 Pak 挂载的到引擎中,只要 保证 PakOrder 没问题,之后就不需要管 pak 的问题了,加载文件时引擎会自动找到最新版本的资源。

注意:一般情况下都是在一个新地图中执行热更的流程,热更之前不要执行任何加载游戏资源的行为,不然如果一个资源在热更之前被加载了一遍,之后就算把新的 pak 挂载上,已经加载的资源也不会更新。

如何对比本地版本与服务器最新的版本?

不同的业务可以做不同的处理,关键思路就是:游戏启动时热更模块会最先去请求服务器上所有的热更版本信息,然后客户端会根据本体包的版本信息来扫描本地已经下载的 patch,把两个信息进行比对之后就可以分析出需要下载哪些版本。

简单的思路是写一个 json 的文件,记录着所有的 patch 信息(文件名、MD5 值),每次客户端启动时会去下载这个 json 的文件,并解析出来。然后客户端本地从指定的文件夹中去扫描 pak 文件,把文件名和 MD5 值进行比对检查,分析出本地合法的 pak 列表,再与服务器的版本列表进行比对,把差异部分下载即可。

热更文件的下载与校验?

上一部分写到了从服务器请求和本地版本扫描分析出来当前用户需要下载的 Patch 版本,这一部分就讲在 UE 中如何下载和校验。

UE 本身提供了跨平台的的 HTTP 库,可以使用引擎中 Online 下的 HTTP 模块:

1
2
3
4
5
6
HttpRequest = FHttpModule::Get().CreateRequest();
HttpRequest->OnRequestProgress().BindUObject(this, &UDownloadProxy::OnDownloadProcess);
HttpRequest->OnProcessRequestComplete().BindUObject(this, &UDownloadProxy::OnDownloadComplete);
HttpRequest->SetURL(InternalDownloadFileInfo.URL);
HttpRequest->SetVerb(TEXT("GET"));
HttpRequest->ProcessRequest();

但是 ,我又要说但是了,直接默认使用这样 HTTP 下载,需要在文件下载完毕时再写入到文件中,如果文件很大写入很花时间,会阻塞。而且,需要对下载的文件进行校验,我选择的是 MD5,MD5 是摘要算法,需要把文件从头到尾读一遍才能计算出结果,如果 下载 存储 校验 三步都拆开来做,其实浪费了很多时间。

所以我简单封装了一个下载库,支持 边下边存 / 边下边计算 MD5,这样当文件下载完也已经存到本地了,并且还计算出了 MD5 值可以供校验用。还支持暂停 / 继续 / 分片下载,自己改一下也可以改成断点续传的。

同样是 UE 的插件,并开源在 Github 上:ue4-dtkit,支持 IOS/Android/Windows/Mac 四个平台。

在这个插件我还封装了一个 MD5Wrapper.hpp 可以用来在其他地方的 MD5 计算,使用的是 OpenSSL 的库。

在下载之后拿到了文件的 MD5 之后再与服务器的版本信息里的 MD5 进行比对,完全匹配的情况下就可以 MountPak 了。

结语

本篇文章的主要内容是介绍了 UE 里热更的思路和可以使用的工具,主要的重点在于 UE4 里的资源打包,使用我写的 hxhb/HotPatcher 可以比较方便地来完成这个工作,至于下载和验证的流程,我的这部分只做参考,可以根据自己的流程自己实现。

下一篇热更新的文章会根据本篇文章的思路和工具来具体实现热更的例子,工程和代码也会开源,有时间再来写。不过根据本篇文章的思路和提供的工具自己实现一个问题也不大。

本篇文章列举的工具和文档:

热更新系列文章