UE4热更新: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支持的任意平台。

热更新系列文章

我写的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都必须要包含../../../前缀,不然会把文件的绝对路径替换了。

OTHER UPDATE

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

本篇文章的内容会持续更新。

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

本文标题:UE4热更新:Questions & Answers
文章作者:查利鹏
发布时间:2021年03月12日 10时45分
本文字数:本文一共有4.3k字
原始链接:https://imzlp.com/posts/16895/
专栏链接:https://zhuanlan.zhihu.com/p/357430122
许可协议: CC BY-NC-SA 4.0
转载请保留原文链接及作者信息,谢谢!
您的捐赠将鼓励我继续创作!