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里,关于引擎打包时会cook哪些资源,后续有事件会补充文章;

  • 引擎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可以比较方便地来完成这个工作,至于下载和验证的流程,我的这部分只做参考,可以根据自己的流程自己实现。

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

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

热更新系列文章

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

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

本文标题:UE热更新:需求分析与方案设计
文章作者:查利鹏
发布时间:2020年05月16日 11时22分
本文字数:本文一共有5.8k字
原始链接:https://imzlp.com/posts/17371/
许可协议: CC BY-NC-SA 4.0
文章禁止全文转载,摘要转发请保留原文链接及作者信息,谢谢!
您的捐赠将鼓励我继续创作!