UE热更新:一次资源异常的故障分析

UE hot update: Fault analysis of a resource anomaly

近期遇到了一个极为诡异的bug,分别有两张地图,其中一张地图A的PAK放在引擎自动挂载路径下可以进入,但是在热更目录不行,另一张地图B则完全相反,在自动挂载目录异常,但是在热更目录正常。

乍一看这个问题完全难以捉摸,两个互斥的行为出现在同一个表现逻辑中。而且热更挂载与自动挂载只是时机不同,优先级不同而已,按理说应该不会出现这个问题。

虽然最终这个问题可以在业务侧逻辑解决,但是这个表现也牵扯到了引擎中另一个极为隐蔽的路径,搞懂为什么以及它的原理是十分必要的。所以,我根据这种表现分析了引擎的代码,并得出了合理的结论,并制定一种检测和规避这种问题的方法。

本篇文章默认读者具有一些UE热更新的基础知识,如有疑问可查阅本博客中热更新系列的其他文章。

注意:本篇文章基于UE4.25引擎版本进行调试分析,其他引擎版本的代码会有些区别,但我着重于分析过程和故障原因,可以在其他版本的引擎中作为参考。

挂载时机分析

一般情况下,对于引擎挂载PAK而言,我们只需要关注“优先级”以及资源加载是否在最高优先级的PAK挂载之后,来确保最新的资源能够被访问到。

但是引擎的自动挂载与游戏内手动挂载,在逻辑上确实会有一些区别,取决于挂载时当前引擎的状态,也就是挂载的时机。

具体来说,就是在引擎的FPakPlatformFile::Mount挂载成功之后,是否会调用FCoreDelegates::NewFileAddedDelegate.Broadcast(Filename)

这里只是检查FCoreDelegates::NewFileAddedDelegate是否被绑定,那么影响到它是否执行的条件,就是挂载PAK的时机是否在绑定之后。

绑定该代理的代码,在Obj.cpp中,绑上了FLinkerLoad::OnNewFileAdded

那么引擎启动时,执行那几个自动挂载目录下PAK的挂载,时机非常早,在PreInitPreStartupScreen中:

其实也能够理解,引擎的各种配置、属性、以及资产,全部都存储在PAK中,它们当然要优先挂载才能被找到,才能够让引擎顺利执行起来。

而对于Obj.cpp中的InitUObject函数的调用,则在PreInitPreStartupScreen调用到AppInit时。

所以它是晚于引擎的自动挂载时机的,故而在自动挂载时,不会调用到NewFileAddedDelegate代理。

而对于热更之后的文件挂载,因为引擎内游戏已经实际在运行了,代理已绑定,此时再去调用FPakPlatformFile::Mount挂载PAK,自然就会执行到FCoreDelegates::NewFileAddedDelegate代理了。

挂载时机的不同,只有这个区别。他也是我们接下来进行BUG分析的关键点,只需要记得自动挂载和游戏内手动挂载,会有FCoreDelegates::NewFileAddedDelegate执行的区别就可以了。

故障分析

接下来回到我文章开头介绍的BUG,起初我很疑惑,为什么自动挂载和手动挂载会有区别,所以我仔细分析了FCoreDelegates::NewFileAddedDelegate代理绑定到的FLinkerLoad::OnNewFileAdded中的逻辑,其实也没什么特殊的:

FPakPlatformFile::Mount中,在Mount成功之后,如果NewFileAddedDelegate具有绑定,则会为当前Pak中的所有文件调用FCoreDelegates::NewFileAddedDelegate

1
2
3
4
5
6
7
8
9
if (FCoreDelegates::NewFileAddedDelegate.IsBound())
{
TArray<FString> Filenames;
Pak->GetFilenames(Filenames);
for (const FString& Filename : Filenames)
{
FCoreDelegates::NewFileAddedDelegate.Broadcast(Filename);
}
}

所以,手动挂载PAK时,这个逻辑会走到,并且手动挂载的PAK中的所有文件都会走到这个流程。

LinkerLoad.cpp中定义的FLinkerLoad::OnNewFileAdded的逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
void FLinkerLoad::OnNewFileAdded(const FString& Filename)
{
FString PackageName;
if (FPackageName::TryConvertFilenameToLongPackageName(Filename, PackageName))
{
FName PackageFName(*PackageName);
if (FLinkerLoad::IsKnownMissingPackage(PackageFName))
{
FLinkerLoad::RemoveKnownMissingPackage(PackageFName);
}
}
}

逻辑上把传进来的Filename从UFS的路径转换为PackageName,也就是(../../../PROJECTNAME/Content/XXXX转换为/Game/XXXX)的形式,并构造出一个FName,然后用于检测是否为MissingPackage,如果是的话就移除。

整个函数的逻辑就是为了把标记为MissingPackage的给移除,那么什么时候资源会被添加到MissingPackage呢?在资源加载失败的时候!

原因很简单,资源加载失败了就记录起来,如果后面还有尝试加载资源,就可以检查是否时被标记过的。

OnNewFileAdded内的逻辑就是反过来,如果有新的PAK被Mount进来,就代表可能存在有新的资源。就把PAK中的文件列表转成资源路径,检查是否是标记为MissingPackage,是的话就移除。因为这个资源在新的PAK中,也已经被挂载进来了,实际上是可以找到它的。

经过调试发现,其实逻辑压根就没有进入到RemoveKnownMissingPackage函数中,表明对MissingPackage的检测没有成功过。

整个函数的逻辑只是如此,还是没找到为什么自动挂载与手动挂载会导致地图无法进入的问题。
但是只要把这段逻辑屏蔽掉,原本进不去的A地图可以了,进得去的B地图又不行了,看来确实是它引起的问题,只是还没找到关联性。

最后,通过对实际加载时的资产与OnNewFileAdded内的PackageName对比,我发现真正有问题的问题在于构造了FName!

1
2
3
4
5
6
7
8
9
void FLinkerLoad::OnNewFileAdded(const FString& Filename)
{
FString PackageName;
if (FPackageName::TryConvertFilenameToLongPackageName(Filename, PackageName))
{
FName PackageFName(*PackageName);
// ...
}
}

这就是导致问题的代码,具体原因先按下不表,后面会详细分析。

目前你需要知道:FName是引擎中的用于静态字符串的机制,它有一些特点:相同的字符串只记录一次,通过索引访问。而且它默认是不区分大小写的,这是问题的关键。

异常的资产操作

为什么FName的构造会是罪魁祸首呢?现在,让我们放下调试器和打包后的游戏。

回到编辑器下,考虑下面这种情况:

  1. 新建一个地图,位于路径PrimaryTest下:

  2. 直接在操作系统中修改目录的文件夹,修改目录大小写为PrimaryTEST

可以看到,工程中的目录产生了变化,但是资产中的PrimaryAssetName并没有变化。

  1. 打包PAK时,UFS中uasset的路径就是磁盘的相对路径,大小写也一致

但,这就造成了一个问题:Cook后的uasset路径与实际的资产路径,出现了大小写不匹配。

从资源的序列化中读取时,它依然是小写的:


构造出了一个/Game/xxxx/level/yyyyy的FName,但是它的uasset在UFS的路径是Content/xxxx/Level/yyyy

真相不远了!

让我们回到FlinkerLoad::NewFileAdded,再看一眼它的代码:

1
2
3
4
5
6
7
8
9
void FLinkerLoad::OnNewFileAdded(const FString& Filename)
{
FString PackageName;
if (FPackageName::TryConvertFilenameToLongPackageName(Filename, PackageName))
{
FName PackageFName(*PackageName);
// ...
}
}

它是把UFS的路径转换为了PackageName!转换逻辑也只是单纯地对字符串进行操作,相对于Content的路径会被原样保留!

举个例子,一个资源/Game/Test/PrimaryTEST/NewWorld,他被Cook后打进UFS的路径为:

1
../../../PROJECT_NAME/Content/Test//PrimaryTEST/NewWorld.umap

那么把它通过FPackageName::TryConvertFilenameToLongPackageName转换之后的PackageName为:

1
/Game/Test/PrimaryTEST/NewWorld

并且将其构造成了FName!

这就会造成UFS转换而来的PackageName与实际资产中记录的PackageName存在了大小写的差异。

调试验证

分析到问题之后,还需要进行实际的调试验证,确认问题,毕竟实践是检验真理的唯一标准嘛!

当我们执行了FlinkerLoader::NewFileAdded,从UFS的路径转换为PackageName时,依据的就是UFS中的文件路径,它确实是大写的:

这里构造出了一个值为/Game/xxxx/Level/yyyyy的FName,该字符串已记录至NamePool。

在实际加载该地图时,获取到的FName也是大写的了:

可以明显地看到Level目录变成了大写。

如果在游戏逻辑中有哪里通过获取资产路径进行匹配的逻辑,如果区分大小写,就会导致无法匹配的问题了,而本次故障的事实也确实如此。

故障总结

让我们再回到最初的问题:为什么资源放入自动挂载目录,与热更后手动挂载会出现逻辑上的区别?

问题的答案:就是因为自动挂载目录里的PAK,是在FCoreDelegates::NewFileAddedDelegate代理绑定之前,不会构造出任何PAK内文件的FName,而热更后手动挂载的,会走到FLinkerLoad::NewFileAdded,并为其中的文件,构造出FName。
而问题的原因,就是UFS的资产路径,与资产中实际存储的路径存在大小写的差异,从而导致游戏逻辑里的某些检测没有通过。

如何规避?

问题虽然已经排查到了,这个问题之所以复杂,主要是牵扯了各种方面:

  1. 美术的资源的操作不规范
  2. 游戏内逻辑检查考虑不充分
  3. 文件系统的设计也确实会导致这个问题,而且很具有迷惑性

其实究其根本,在于对资源操作绕过了Editor,直接在磁盘文件系统上的文件进行了修改,导致了资产存放路径与资产内记录的值不一致,而恰好修改也只是变动了大小写而已,所以这个故障就变得极为隐蔽。

说是“资源操作不规范导致大小写异常引发的血案”也不为过,为了避免这种情况的再次发生,我添加了一个新的资源检查规则,用于自动化地检测资源的磁盘路径与PKG内记录的不一致的情况:

这样就能够比较及时、且批量地检查工程中存在相同问题的资产,进行处理。正如我过往文章中不断提到的那样,资源规范极为重要,可以极大地降低排查问题的成本,把问题扼杀在摇篮里。

除此之外,最重要的还是要管住人。任何对于资产的操作,必须通过Editor来完成,包括但不限于新增、删除、重命名、移动目录等,并且操作之后必须修复重定向器,严禁通过文件系统目录进行修改。

总结

本篇文章分析了UnrealEngine中挂载时机不同的逻辑区别、资产操作不规范导致资源与序列化路径不匹配的异常,并得出了明确的故障结论与规避方案。

技术问题没有玄学,如果一件事情让你觉得玄学,只是你还没找到它们之间的联系而已。

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

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

本文标题:UE热更新:一次资源异常的故障分析
文章作者:查利鹏
发布时间:2024/10/17 17:30
本文字数:4.1k 字
原始链接:https://imzlp.com/posts/22890/
许可协议: CC BY-NC-SA 4.0
文章禁止全文转载,摘要转发请保留原文链接及作者信息,谢谢!
您的捐赠将鼓励我继续创作!