UE 热更新:Questions & Answers

HotPatcher项目开源这一年多以来,经过了不少的更新和优化,也被越来越多的开发者选择作为自己项目的热更新方案,期间有不少人陆陆续续询问 UE4 热更新相关遇到的问题,很多问题比较常见,重复询问的频率也比较多,所以我准备把一些常见的问题进行整理,方便初步上手 UE4 热更新方案的人能够尽快地排查问题。

本篇文章会持续更新 UE4 热更新和 HotPatcher 相关的 Q&A 内容,有疑问的地方也可以直接在本篇文章中评论,我会定期统一回答和整理,也可以加入我的 UE4 热更新群讨论遇到的问题(QQ 群 958363331)。

HotPatcher 相关问题

  1. 是否可以用在商业项目中?
    可以,使用的是 MIT 开源协议。

  2. 是否可以热更 C++?
    不能,只能用来更新 uasset 和 Non-Asset(lua/db/json 等等)。

  3. 支持移动端热更吗?
    支持,本身 HotPatcher 是没有平台限制的,可以打包和管理 UE 支持的任意平台。

注意:使用 HotPatcher 打包时,需要避免一个目录既包含 uasset 又包含 non-asset 的情况,不然会导致未被 cook 的 uasset 打包。

热更新系列文章

我写的 UE4 热更新的系列文章,可以作为工程实践的参考:

pak 的自动挂载目录

以下三个路径中的 Pak 会在引擎启动时自动挂载:

  • Engine/Content/Paks
  • GAME_DIR/Content/Paks
  • GAME_DIR/Saved/Paks
Runtime\PakFile\Private\IPlatformFilePak.cpp
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void FPakPlatformFile::GetPakFolders(const TCHAR* CmdLine, TArray<FString>& OutPakFolders)
{
#if !UE_BUILD_SHIPPING
// Command line folders
FString PakDirs;
if (FParse::Value(CmdLine, TEXT("-pakdir="), PakDirs))
{
TArray<FString> CmdLineFolders;
PakDirs.ParseIntoArray(CmdLineFolders, TEXT("*"), true);
OutPakFolders.Append(CmdLineFolders);
}
#endif

// @todo plugin urgent: Needs to handle plugin Pak directories, too
// Hardcoded locations
OutPakFolders.Add(FString::Printf(TEXT("%sPaks/"), *FPaths::ProjectContentDir()));
OutPakFolders.Add(FString::Printf(TEXT("%sPaks/"), *FPaths::ProjectSavedDir()));
OutPakFolders.Add(FString::Printf(TEXT("%sPaks/"), *FPaths::EngineContentDir()));
}

这三个路径下的 pak 的默认优先级不同(除非通过_1_P.pak 这种形式命名):

Runtime\PakFile\Private\IPlatformFilePak.cpp
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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;
}

Mount Point 的作用

在 Mount Pak 的时候,有一个参数可以指定 MountPoint:

1
2
3
4
5
6
7
/**
* Mounts a pak file at the specified path.
*
* @param InPakFilename Pak filename.
* @param InPath Path to mount the pak at.
*/
bool Mount(const TCHAR* InPakFilename, uint32 PakOrder, const TCHAR* InPath = NULL, bool bLoadIndex = true);

那么它是干什么的呢?
首先从 Mount 函数开始:

1
2
3
4
if (InPath != NULL)
{
Pak->SetMountPoint(InPath);
}

如果在调用 Mount 时传递了 InPath,则通过加载 Pak 的 FPakFile 实例调用SetMountPoint,把 InPath 设置给它。
其实在 FPakFile 中,MountPath 是有默认值的(从 Pak 文件中读取),在 FPakFile 的构造函数中调用了 Initialize(Reader, bLoadIndex);,Initialize 中又调用了LoadIndex,在LoadIndex 中从 Pak 中读取 Pak 的 Mount Point 的逻辑:

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
// Runtime/PakFile/Private/IPlatformFilePak.cpp
void FPakFile::LoadIndex(FArchive* Reader)
{
if (CachedTotalSize < (Info.IndexOffset + Info.IndexSize))
{
UE_LOG(LogPakFile, Fatal, TEXT("Corrupted index offset in pak file."));
}
else
{
if (Info.Version >= FPakInfo::PakFile_Version_FrozenIndex && Info.bIndexIsFrozen)
{
SCOPED_BOOT_TIMING("PakFile_LoadFrozen");

// read frozen data
Reader->Seek(Info.IndexOffset);
int32 FrozenSize = Info.IndexSize;

// read in the index, etc data in one lump
void* DataMemory = FMemory::Malloc(FrozenSize);
Reader->Serialize(DataMemory, FrozenSize);
Data = TUniquePtr<FPakFileData>((FPakFileData*)DataMemory);

// cache the number of entries
NumEntries = Data->Files.Num();
// @todo loadtime: it is nice to serialize the mountpoint right into the Data so that IndexSize is right here
// but it takes this to copy it out, because it's too painful for the string manipulation when dealing with
// MemoryImageString everywhere MountPoint is used
MountPoint = Data->MountPoint;
}
// ...
}
// ...
}

简单的可以理解为:如果 Mount 时不传递 Mount Point 就会从 Pak 文件中读取,如果有传入就设置为传入的值(Pak 文件中的 MountPoint 是 Pak 中所有文件的公共路径)。

那么,给 Pak 设置 MountPoint 的作用是什么呢?
真实目的是,检测要加载的文件是否存在于当前 Pak 中!因为 Pak 的 Mount Point 的默认含义是当前 Pak 中所有文件的公共路径,所以只需要检测要读取的文件是否以这个路径开头,就可以首先排除掉基础路径不对的文件(基础路径都不对,意味着这个文件在 Pak 中也不存在)。

具体逻辑可以看这个函数的实现:

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
// Runtime/PakFile/Public/IPlatformFilePak.h
/**
* Finds a file in the specified pak files.
*
* @param Paks Pak files to find the file in.
* @param Filename File to find in pak files.
* @param OutPakFile Optional pointer to a pak file where the filename was found.
* @return Pointer to pak entry if the file was found, NULL otherwise.
*/
static bool FindFileInPakFiles(TArray<FPakListEntry>& Paks,const TCHAR* Filename,FPakFile** OutPakFile,FPakEntry* OutEntry = nullptr)
{
FString StandardFilename(Filename);
FPaths::MakeStandardFilename(StandardFilename);

int32 DeletedReadOrder = -1;

for (int32 PakIndex = 0; PakIndex < Paks.Num(); PakIndex++)
{
int32 PakReadOrder = Paks[PakIndex].ReadOrder;
if (DeletedReadOrder != -1 && DeletedReadOrder > PakReadOrder)
{
//found a delete record in a higher priority patch level, but now we're at a lower priority set - don't search further back or we'll find the original, old file.
UE_LOG(LogPakFile, Verbose, TEXT("Delete Record: Accepted a delete record for %s"), Filename );
return false;
}

FPakFile::EFindResult FindResult = Paks[PakIndex].PakFile->Find(*StandardFilename, OutEntry);
if (FindResult == FPakFile::EFindResult::Found)
{
if (OutPakFile != NULL)
{
*OutPakFile = Paks[PakIndex].PakFile;
}
UE_CLOG(DeletedReadOrder != -1, LogPakFile, Verbose, TEXT("Delete Record: Ignored delete record for %s - found it in %s instead (asset was moved between chunks)"), Filename, *Paks[PakIndex].PakFile->GetFilename());
return true;
}
else if (FindResult == FPakFile::EFindResult::FoundDeleted)
{
DeletedReadOrder = PakReadOrder;
UE_LOG(LogPakFile, Verbose, TEXT("Delete Record: Found a delete record for %s in %s"), Filename, *Paks[PakIndex].PakFile->GetFilename());
}
}

UE_CLOG(DeletedReadOrder != -1, LogPakFile, Warning, TEXT("Delete Record: No lower priority pak files looking for %s. (maybe not downloaded?)"), Filename );
return false;
}

当我们从 Pak 中读取文件时,通过对游戏中所有 Mount 的 Pak 调用 Find 函数,而 FPakFile::Find 的函数就实现了上述我说的逻辑:

1
2
3
4
5
6
7
8
9
10
11
// Runtime/PakFile/Private/IPlatformFilePak.cpp
FPakFile::EFindResult FPakFile::Find(const FString& Filename, FPakEntry* OutEntry) const
{
QUICK_SCOPE_CYCLE_COUNTER(PakFileFind);
if (Filename.StartsWith(MountPoint))
{
FString Path(FPaths::GetPath(Filename));
// ...
}
// ...
}

所以,MountPoint 的作用就是在从 Pak 中查找文件时,首先判断文件的路径是否与 Pak 中所有文件的 基础路径 相匹配(StartWith),如果不存在也就不会进入后续的流程了。

Pak 无法被挂载

在本体包中开启 signature 后,打包出来的 Pak 无法被挂载
同样是 pak 的 signature 的错误,是因为没有为 pak 生成对应的.sig 文件。
Log 中的内容如下:

1
2
3
LogPakFile: Warning: Couldn't find pak signature file '../../../Pak/Content/Paks/1.0.3_WindowsNoEditor_P.pak'
LogPakFile: Warning: Unable to create pak "../../../Pak/Content/Paks/1.0.3_WindowsNoEditor_P.pak" handle
LogPakFile: Warning: Failed to mount pak "../../../Pak/Content/Paks/1.0.3_WindowsNoEditor_P.pak", pak is invalid

这是因为打出本体包时 Project Setting-Crypto 中的 bEnablePakSigning 被设置成了 true,这样对打出来的包里的所有 pak 都会执行校验,目的就是为了确保 只有自己打包的 pak 才可以被加载

相关的代码处理在:

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
// Runtime/PakFile/Private/SignedArchiveReader.cpp
FChunkCacheWorker::FChunkCacheWorker(FArchive* InReader, const TCHAR* Filename)
: Thread(nullptr)
, Reader(InReader)
, QueuedRequestsEvent(nullptr)
, ChunkRequestAvailable(nullptr)
{
FString SigFileFilename = FPaths::ChangeExtension(Filename, TEXT("sig"));
FArchive* SigFileReader = IFileManager::Get().CreateFileReader(*SigFileFilename);

if (SigFileReader == nullptr)
{
UE_LOG(LogPakFile, Fatal, TEXT("Couldn't find pak signature file '%s'"), *SigFileFilename);
}

Signatures.Serialize(*SigFileReader);
delete SigFileReader;
Signatures.DecryptSignatureAndValidate(Filename);

const bool bEnableMultithreading = FPlatformProcess::SupportsMultithreading();
if (bEnableMultithreading)
{
QueuedRequestsEvent = FPlatformProcess::GetSynchEventFromPool();
ChunkRequestAvailable = FPlatformProcess::GetSynchEventFromPool();
Thread = FRunnableThread::Create(this, TEXT("FChunkCacheWorker"), 0, TPri_BelowNormal);
}
}

所以,如果在用 HotPatcher 打包 pak 时没有与项目指定相同的加密参数,则导致放入包内的 pak 会加载失败(因为验证失败了)。
解决的办法就是,在使用 HotPatcher 时指定与项目相同的加密信息,当直接使用 UE 打出本体包时,会默认在下列路径中生成一个 Crypto.json 文件:

1
PROJECT_DIRECTORY\Saved\Cooked\WindowsNoEditor\PROJECT_NAME\Metadata\Crypto.json

它里面的内容是根据 Project Setting-Crypto 中的选项生产的。
使用方法为:
在 HotPatcher 的 UnrealPak 参数项添加参数:-cryptokeys="Crypto.json"(在 UE4.23+ 中还需要添加 -sign 参数):

重新生成 Pak 就会在 Pak 的目录里生成与 Pak 同名的 .sig 文件了,把 paksig文件一同拷贝到挂载目录里就可以了。

UnrealPak 的参数可以看我之前的一篇文章:UE4 工具链配置与开发技巧 #UnrealPak 的参数

Pak master signature table check failed for pak

  1. 使用 HotPatcher 打包出来的 pak 在挂载时 Crash 并具有 Pak master signature table check failed for pak 提示

这是由于打出本体包的时候在项目设置中设置了 Signing 加密,需要在 HotPatcher 中的 UnrealPak 参数中添加相同的加密参数。

IPlatformFilePak.cpp 中的 RegisterPakFile 中,同样做了判断:

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
// Runtime/PakFile/Private/
uint16* RegisterPakFile(FName File, int64 PakFileSize)
{
uint16* PakIndexPtr = CachedPaks.Find(File);
if (!PakIndexPtr)
{
FString PakFilename = File.ToString();
check(CachedPakData.Num() < MAX_uint16);
IAsyncReadFileHandle* Handle = LowerLevel->OpenAsyncRead(*PakFilename);
if (!Handle)
{
return nullptr;
}
CachedPakData.Add(FPakData(Handle, File, PakFileSize));
PakIndexPtr = &CachedPaks.Add(File, CachedPakData.Num() - 1);
UE_LOG(LogPakFile, Log, TEXT("New pak file %s added to pak precacher."), *PakFilename);

FPakData& Pak = CachedPakData[*PakIndexPtr];

if (SigningKey.IsValid())
{
// Load signature data
FString SignaturesFilename = FPaths::ChangeExtension(*PakFilename, TEXT("sig"));
IFileHandle* SignaturesFile = LowerLevel->OpenRead(*SignaturesFilename);
ensure(SignaturesFile);
FArchiveFileReaderGeneric* Reader = new FArchiveFileReaderGeneric(SignaturesFile, *SignaturesFilename, SignaturesFile->Size());
Pak.Signatures.Serialize(*Reader);
delete Reader;
Pak.Signatures.DecryptSignatureAndValidate(SigningKey, PakFilename);

// Check that we have the correct match between signature and pre-cache granularity
int64 NumPakChunks = Align(PakFileSize, FPakInfo::MaxChunkDataSize) / FPakInfo::MaxChunkDataSize;
ensure(NumPakChunks == Pak.Signatures.ChunkHashes.Num());
}
}
return PakIndexPtr;
}

iOS 热更 metallib 问题

在 4.25 存在不会重新加载 shaderbytecode 的问题,而且引擎内部对加载 metallib 是单独处理的流程,无法服用 usahderbytecode 的流程,所以出 iOS 包尽量使用远程打包的方式,会生成ushaderbytecode,在 4.25 里 LoadLibrary 没有问题,但是如果去加载 metallib 就有问题。

UE 热更 Shader 相关的内容可以看之前的文章:UE4 热更新:Create Shader Patch

UE4.25+ ShaderPatch Crash

这是因为在 4.25+ 引擎内部的 bug 导致的,UE4 热更新:Create Shader Patch#4.25+ ShaderPatch Crash](https://imzlp.com/posts/5867/)这篇文章中提供了修改方案。

热更一个不存在的插件中的资源

打包之后引擎是会从 upluginmanifest 中读取当前工程中具有有哪些插件的,加载插件中的资源先判断插件是否存在,从而实现一个粒度较粗的过滤效果。

所以,当需要把一个在基础包中不存在的插件打包至 pak 中,需要在打包资源的同时需要把项目的 upluginmanifest 文件同步打包,挂载点为:

1
../../../PROJECT_NAME/Plugins/PROJECT_NAME.upluginmanifest

关于 upluginmanifest 的介绍,可以看我之前的笔记:UE4#upluginmanifest

热更的资源没有效果 / 材质丢失

如果热更蓝图,逻辑没有变化,需要检查资源是否被 Cook,可以手动在 Content Browser 中通过 HotPatcher 中提供的功能对选中资源执行 Cook,也可以在打包 Patch 时勾选 bCookAsset 选项。

如果时热更了资源 / 材质,没有效果,需要检查是否把 Shaderbytecode 打包,如果新增材质没有打包 shaderbytecode 是会导致 Shader 获取失败使用默认材质的。

Log 中的错误:

如果不使用引擎启动时自动挂载 pak 的方式,而是运行时手动 Mount 包含新 shaderbytecode 的 pak,则需要在 mount 之后手动重新加载一遍 shaderbytecode,这样引擎才能够读取到最新的 shader,插件中提供了一个辅助函数:

1
2
3
4
5
6
7
#include "ShaderCodeLibrary.h"

void UFlibPatchParserHelper::ReloadShaderbytecode()
{
FShaderCodeLibrary::OpenLibrary("Global", FPaths::ProjectContentDir());
FShaderCodeLibrary::OpenLibrary(FApp::GetProjectName(), FPaths::ProjectContentDir());
}

在 mount 之后调用即可。

AssetRegistry 是否必须热更

看需求,如果 Runtime 的代码中有通过 AssetRegistry 模块获取资源的引用关系、检测资源是否存在,需要热更。但是 AssetRegistry 并不是引擎必要的,如果肯定不会在运行时用到,可以去掉它,会节省一点内存。

具体介绍可以看我之前的笔记:UE4# 控制 AssetRegistry 的序列化

Android 提示 not found uproject

UE 中有一个 BUG,在 4.25.1 引擎版本中可以复现,步骤如下:

  1. 安装 apk,第一次启动游戏
  2. 打开 UE 的沙盒数据目录 UE4Game/PROJECTNAME,在这个目录下创建Content/Paks 目录
  3. 重新启动游戏

Log 中也有 Project file not found: ../../../FGame/FGame.uproject 提示。

在 Android 上自动挂载的 Pak 文件可以放到 Saved/Paks 下,有时间具体分析一下这个问题。

控制资源不打到基础包中

拆分基础包的实践可看我的这两篇文章:

分析某个平台的包中的资源

可以使用 UE 提供的 Asset Audit 工具,需要在每次打包时备份好 Cooked/PLATFORM/PROJECT_NAME/Metadata 目录中的 DevelopmentAssetRegistry.bin 文件。

也可以使用 UnrealPakViewer 来直接加载 Pak 文件。

具体可以看这篇文章的资产审计小节:UE4 热更新:资产管理与审计工具 #资产审计

UMG 子控件热更不生效

如果 Instanced 的形式引用的 UMG,子 UMG 的变动需要递归包含所有以子控件形式引用的 UMG 资源。我之前在笔记中记录过这个问题:UE4#UMG 的子控件引用热更问题

解决方案:HotPatcher 中具有一个递归分析 UMG 父控件的的选项(bRecursiveWidgetTree),开启即可。

这个问题的具体分析在我 2020 UOD 的演讲中有详细介绍,感兴趣的可以去这里查看视频和 PPT:

Pak 是否可以跨引擎版本使用

不行,要保持打包和使用的引擎版本一致。

从 A 项目打包给 B 项目使用的 Pak

HotPatcher 中做了替换 pakcommand 的功能,可以通过以下参数指定:

注意:From 和 To 都必须要包含 ../../../ 前缀,不然会把文件的绝对路径替换了。

plugin HotPatcher faild

打包之后又如下提示:

应该是纯蓝图项目打包导致的,在项目中新建一个 C++ 类,变成一个 C++ 工程重新打包即可。

打包原始 uasset 资源

目前插件并没有能直接选择打包原始 uasset 资源的功能,但是可以使用一个取巧的方法实现。
可以设置 ReplacePakCommandTexts 把 Cooked 的目录替换为项目的Content 目录

虽然 *pakcommand.txt 里依然会有 uexp 等文件的记录,但是在项目的 Content 下没有,也并不会打包到 pak 中去,会忽略不存在的文件,并有以下 log 输出:

1
LogPakFile: Warning: Missing file "D:/Client/Content/Assets/Scene/Map/LookDev/DemoAssets/Mesh/FFXV/000.uexp" will not be added to PAK file.

算是取巧的一种实现吧,但是可行。

导出跨机器的通用配置文件

Q:HotPatcher 中导出的配置,有些是依赖于本地绝对路径的文件,如 BaseVersion、Non-Asset 文件、SavePath 等,不同的机器这些绝对路径并不能保证一致,能否基于相对路径进行配置?

A:可以。HotPatcher 所有能够指定路径的配置项,都支持标记符替换,可以使用以下标记符来替代绝对路径。

1
2
3
4
5
6
[ENGINEDIR]
[ENGINE_CONTENT_DIR]
[PROJECTDIR]
[PROJECT_CONTENT_DIR]
[PROJECT_SAVED_DIR]
[PROJECT_CONFIG_DIR]

在打包时会自动替换为当前机器的绝对路径,基于相对路径则是完全通用的配置文件。

依赖分析耗时

当项目资源非常多,插件提供的依赖分析功能的耗时十分可观,其主要目的是分析被依赖的资源,防止依赖了但是没有被打包的情况。
如果依赖了引擎、插件中的资源,不进行依赖分析是管理不到的(或者手动指定),而且如果想要剔除没有引用的资源不分析也做不到。不需要的话也是可以关掉的,设置 bAnalysisFilterDependencies 就可以了。
这样只会对配置中所指定的目录下的所有资源、单独指定的资源,与基础版本中的进行 Diff 分析,能够减少依赖分析的耗时(如果资源量非常大,并且能保证所有的资源依赖都在 /Game)下的可以不开启依赖分析。

OTHER UPDATE

使用 Github Gist 管理的动态更新内容,在国内网络可能会无法查看。