近期遇到了一个极为诡异的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 | if (FCoreDelegates::NewFileAddedDelegate.IsBound()) |
所以,手动挂载PAK时,这个逻辑会走到,并且手动挂载的PAK中的所有文件都会走到这个流程。
在LinkerLoad.cpp
中定义的FLinkerLoad::OnNewFileAdded
的逻辑:
1 | void FLinkerLoad::OnNewFileAdded(const FString& Filename) |
逻辑上把传进来的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 | void FLinkerLoad::OnNewFileAdded(const FString& Filename) |
这就是导致问题的代码,具体原因先按下不表,后面会详细分析。
目前你需要知道:FName是引擎中的用于静态字符串的机制,它有一些特点:相同的字符串只记录一次,通过索引访问。而且它默认是不区分大小写的,这是问题的关键。
异常的资产操作
为什么FName的构造会是罪魁祸首呢?现在,让我们放下调试器和打包后的游戏。
回到编辑器下,考虑下面这种情况:
新建一个地图,位于路径
PrimaryTest
下:直接在操作系统中修改目录的文件夹,修改目录大小写为
PrimaryTEST
:
可以看到,工程中的目录产生了变化,但是资产中的PrimaryAssetName
并没有变化。
- 打包PAK时,UFS中uasset的路径就是磁盘的相对路径,大小写也一致
但,这就造成了一个问题:Cook后的uasset路径与实际的资产路径,出现了大小写不匹配。
从资源的序列化中读取时,它依然是小写的:
构造出了一个/Game/xxxx/level/yyyyy
的FName,但是它的uasset在UFS的路径是Content/xxxx/Level/yyyy
。
真相不远了!
让我们回到FlinkerLoad::NewFileAdded
,再看一眼它的代码:
1 | void FLinkerLoad::OnNewFileAdded(const FString& Filename) |
它是把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的资产路径,与资产中实际存储的路径存在大小写的差异,从而导致游戏逻辑里的某些检测没有通过。
如何规避?
问题虽然已经排查到了,这个问题之所以复杂,主要是牵扯了各种方面:
- 美术的资源的操作不规范
- 游戏内逻辑检查考虑不充分
- 文件系统的设计也确实会导致这个问题,而且很具有迷惑性
其实究其根本,在于对资源操作绕过了Editor,直接在磁盘文件系统上的文件进行了修改,导致了资产存放路径与资产内记录的值不一致,而恰好修改也只是变动了大小写而已,所以这个故障就变得极为隐蔽。
说是“资源操作不规范导致大小写异常引发的血案”也不为过,为了避免这种情况的再次发生,我添加了一个新的资源检查规则,用于自动化地检测资源的磁盘路径与PKG内记录的不一致的情况:
这样就能够比较及时、且批量地检查工程中存在相同问题的资产,进行处理。正如我过往文章中不断提到的那样,资源规范极为重要,可以极大地降低排查问题的成本,把问题扼杀在摇篮里。
除此之外,最重要的还是要管住人。任何对于资产的操作,必须通过Editor来完成,包括但不限于新增、删除、重命名、移动目录等,并且操作之后必须修复重定向器,严禁通过文件系统目录进行修改。
总结
本篇文章分析了UnrealEngine中挂载时机不同的逻辑区别、资产操作不规范导致资源与序列化路径不匹配的异常,并得出了明确的故障结论与规避方案。
技术问题没有玄学,如果一件事情让你觉得玄学,只是你还没找到它们之间的联系而已。