UE4 文件系统

整理了一下实习时记录的有关于Unreal Engine 4的文档,共有两篇。

本文主要讲述UE4文件系统的抽象层次结构。

IPlatformFile

对平台相关的文件操作都抽象在IPlatformFile接口上进行实现。

文件系统类图

从上面的类图可以看出文件操作的实现类可以大致分为2类:

  • 具体平台的文件操作的实现均继承在IPhysicalPlatformFile接口下,包括:

    • FLinuxPlatformFile:Linux平台文件操作
    • FWindowsPlatformFile:Windows平台文件操作
    • FHTML5PlatformFile:HTML5文件操作
    • FAndroidPlatformFile:Android平台文件操作,继承自IAndroidPlatformFile接口
    • FApplePlatformFile:Mac OS X平台文件操作
    • FIOSPlatformFile:IOS平台文件操作,继承自FApplePlatformFile

    实际编译的时候以上的几个类只有唯一一个对应目标平台的会被编译进程序中。

  • 其余有一些对文件操作的包装类(Warpper)也继承自IPlatformFile接口,它们是虚拟在某一底层文件接口上的一层接口,主要是在某一个已有的文件操作接口上实现一些功能的增强、文件访问监控、性能监测等操作。这一种包装体现为对IPlatformFile链式引用(典型的责任链设计模式):

    • 继承自IPlatformFile的包装子类中均有一个指向更底层一层的文件操作接口的IPlatformFile*指针,这个指针在调用Initialize()对文件操作包装类初始化是就需要传入,之后可以用GetLowerLevel()SetLowerLevel()获取或设置该底层文件操作接口指针。
    • 实现具体平台文件操作的IPhysicalPlatformFile接口和其子类,由于不可能有比其更底层的文件操作接口(它们自身已经是最低一层),在IPhysicalPlatformFile接口中屏蔽了设置底层IPlatformFile*指针的函数。
    • 对已有文件操作的增强,主要就体现为:把文件操作请求转发到更低一层文件操作接口的前后插入其特定的一些操作,如对读写数据进行修改,或者将操作的详细情况记录在一个日志中。还可以选择不转发到更低一层,如实现缓存功能时命中缓存的数据可以直接在内存中读取,无需实际访问文件系统。

    这些逻辑接口共包括:

    • FCachedReadPlatformFile:实现文件的预读取到内存,每次预读64KB的数据为一个缓存块。
    • FLoggedPlatformFile:实现文件操作的日志记录,会记录下文件操作的类别、操作执行时间等。
    • FPlatformFileOpenLog:记录文件的打开操作到日志中(编辑器环境下EditorOpenOrder.log;其他环境下GameOpenOrder.log)。
    • FNetworkPlatformFile:实现对服务器上文件的访问,将服务器上的文件下载到本地进行访问。
    • FSandBoxPlatformFile:将文件访问转化为沙盒内的文件访问,对于不在沙盒内的文件以只读的形式访问。可以配置沙盒外文件访问禁止名单。
    • FPakPlatformFile:对Pak文件读取的封装,将Pak文件虚拟为一个文件系统的形式进行访问。
    • FPlatformFileReadStats:对文件读取速度进行统计,获取读取的每秒字节数。
    • FProfiledPlatformFile:记录更详细的文件操作时间,如每一次操作的开始时间、处理字节数、操作间隔等。

IPlatformFile接口的函数大致有一下几类:

  1. 返回自身的查询信息:如接口名字GetName()、是否应该使用ShouldBeUsed()、是否启用沙盒IsSandboxEnabled()、获取底层文件接口GetLowerLevel()
  2. 初始化和参数设置:如Initialize(),会传入底层文件系统和命令行、SetSandboxEnabled()AddLocalDirectories()
  3. 文件相关
    1. 查询:如是否存在FileExists()/DirectoryExists()、文件大小FileSize()、文件属性(只读?IsReadOnly(),修改时间GetTimeStamp())等
    2. 修改:删除DeleteFile()/DeleteDirectory()、移动文件MoveFile()、复制CopyFile()/CopyDirectoryTree()、创建目录CreateDirectory()
    3. 打开文件并读写:OpenRead()OpenReadNoBuffering()OpenWrite(),返回一个表示文件句柄的IFileHandle*指针。
    4. 遍历文件/目录:采用了访问者模式,有2种方式实现访问者。调用IterateDirectory等一系列函数便可以遍历文件或目录。
      • 通过继承FDirectoryStatVisitor并覆盖visit()函数实现遍历逻辑。
      • 直接将一个函数传入以TFunctionRef<...>的形式传入,适合用于在匿名函数中写遍历逻辑。
    5. 其他一些杂项:如异步文件读取接口OpenAsyncRead(),与文件服务器通信SendMessageToServer()、转换路径为绝对路径ConvertToAbsolutePathForExternalAppForRead()
  4. 返回当前实际的物理文件接口的函数GetPlatformPhysical()GetPhysicalTypeName(),这个函数的根据编译时选择的目标平台的内容选用不同的版本,在编译时确定下来应该返回IPhysicalPlatformFile中的哪个。

IFileHandle

IPlatformFile中,只有对文件的宏观操作而不涉及文件内容的具体读写操作,而对于文件内容的读取与写入的具体接口则定义在了IFileHandle这个接口中。

每次读取文件前先在IPlatformFile中以文件名打开文件,之后会返回IFileHandle的指针,返回的就是打开文件的句柄,这个句柄记录了读写文件需要的状态(如当前读写位置),通过这个句柄可以对文件内容进行操作。这一层类似C语言中fopen()后返回的FILE*文件指针。

IFileHandle定义了几个低层次的读写操作,如Read()Write()Flush()Seek()Tell()等,这些函数读写的均是字节缓冲区。

上面提到的文件操作的包装类,如果要在文件读写内容上做修改,或者有其他的文件读写源,就需要相应地继承IFileHandle,覆盖对应的函数,并在打开文件时返回实现了特定功能的IFileHandle子类。如FCachedReadPlatformFile对应地有FCachedFileHandleFPakPlatformFile对应地有FPakFileHandle等。

FPlatformFileManager

上面提到了很多实现了IPlatformFile的子类,每个涉及到文件读取的地方都可能使用到这些子类中的某一个,因此需要一个类管理这些子类,这个类就是FPlatformFileManager

其中最常用的函数是GetPlatformFile(),这个函数返回当前平台的文件接口实例IPlatformFile&,这样只要使用这个函数,就可以获取一个平台无关的文件访问接口。

此外,如果用到了实现增强功能的文件接口包装类,这个时候文件接口被更上一层的类包装,实际上形成了一个接口调用链。FPlatformFileManager也负责对这个链进行管理,如:

  • 设置当前最顶层的文件接口SetPlatformFile()
  • 以名字查找链中的某一文件接口FindPlatformFile()
  • Tick整条链TickActivePlatformFile()
  • 以名字创建新的接口GetPlatformFile()(这个函数也控制了某编译配置下能用哪些接口,如在Shipping模式下不能使用Log文件接口)
接口的创建

具体使用到哪些文件接口包装类,由程序启动的命令行参数进行控制。

对文件接口包装类的实际创建在LaunchEngineLoop.cpp中的LaunchCheckForFileOverride()函数与ConditionallyCreateFileWrapper()函数进行。前者按照预先定义好的顺序,调用后者尝试进行创建包装类。后者会从FPlatformFileManager中记载的所有文件接口类中查找到某一个文件包装类,调用其ShouldBeUsed()函数从命令行中获取是否应该启用该包装类的信息,如果启用,则初始化这个文件包装类,让其包装当前的顶层文件接口,并在FPlatformFileManager中更新该信息。

以上这些文件接口包装类的链式定义顺序是(下一层包装上一层):

  1. PakFile;在非编辑器模式下或MONOLITIC模式下默认启用,除非指定NoPak参数
  2. CachedReadFile ;在PS4与Windows下默认关闭(因为Windows本身自己会预读取),可通过NoCachedReadFile参数和CachedReadFile参数强行关闭或启用
  3. SandBoxFile;通过-Sandbox=参数指定沙盒路径并启用
  4. 网络文件包装类三选一,优先级:StreamingFile > CookedIterativeFile > NetworkFile(只用于非Shipping构建下);通过-FileHostIP=参数指定文件Host并启用,Host必须可以访问,不然程序无法启动。
  5. ProfileFile(只用于非Shipping构建下);指定ProfileFile参数开启
  6. SimpleProfileFile(只用于非Shipping构建下);指定SimpleProfileFile参数开启
  7. FileReadStats(只用于非Shipping构建下);指定FileReadStats参数开启
  8. FileOpenLog(只用于非Shipping构建下);指定FileOpenLog参数开启
  9. LogFile;指定FileLog参数开启

如果考虑加入自定义的文件接口包装类,需要选择在合适的位置加入。上面的顺序可以看出UE4处理的大致顺序是:先做Pak,再做读取性能优化、再做沙盒权限,之后是网络文件,最后是一系列的性能统计与Log记录。

IFileManager

由于IPlatformFile接口比较偏底层(其打开文件后返回的文件句柄IFileHandle也是直接读写字节缓冲区的底层接口),不方便使用,IFileManager定义了更高一层的文件接口,实际的实现在FFileManagerGeneric中。

由于获取平台无关文件接口经常要调用FPlatformFileManager::Get().GetPlatformFile(),显得繁琐,这一层接口直接完成了这一步骤,并将操作转发到获取到的这个接口上,这样就保证了所有操作都是平台无关的。

在这一层接口上实现了一些更高层的文件操作,更方便调用:

  • 打开文件是通过CreateFileReader()CreateFileWriter()这两个函数,它们返回的是对读写操作包装好了的档案类FArchive*,其中实现了一系列的与UObject相关的序列化方法和许多其他类型的序列化方法,更便于保存对象到文件这个经常性操作。序列化操作最后被FArchiveFileReaderGeneric这个继承于FArchive的类转化为对字节缓冲区的操作,再转发到底层文件接口。
  • 定义了一些常用的,但是底层接口没有提供的文件操作。如递归查找文件FindFiles()、获取文件寿命GetFileAgeSeconds()等。
  • 增强了一些文件操作:如带进度回调的文件复制Copy();在操作失败时打印到Log等。

有了这些接口,操作文件就十分方便了,实际对文件操作时一般只用的到这个类,而不需要调用最底层的IPlatformFile接口。

平台相关文件接口

FWindowsPlatformFile

Windows平台的文件接口,与之配套的有文件句柄FFileHandleWindows、文件异步读取接口FAsyncBufferedFileReaderWindows等。

该接口直接采用Windows API实现Windows平台上的文件相关操作,涉及到CreateFileW()MoveFileW()DeleteFileW()GetFileAttributesExW()GetFileSizeEx()SetFileTime()FindFirstFileW()GetOverlappedResult()CloseHandle()等。文件句柄中对文件内容的读写涉及到ReadFile()WriteFile()FlushFileBuffers()等。文件异步读取接口中涉及到WaitForAsyncRead()等。

在实现的时候,也需要完成错误检测相关的功能,需要调用GetLastError();完成文件路径的本地化转换;将UE4中的内建的数据类型,如时间表示转化为Windows平台上的时间表示等。

逻辑包装文件接口

FNetworkPlatformFile

该接口实现网络文件的访问。每次需要访问网络文件时,将文件下载到本地,后续按照本地文件进行访问。

文件的Host地址由启动命令行参数-FileHostIP=指定,可以同时指定多个Host地址,用+分隔。与Host通信可以指定用TCP协议(以tcp://开头);也可以指定用HTTP协议(以http://),不指定默认使用TCP。

接口与服务器通信使用了专门定制的协议,这个协议的一部分定义在Runtime/Sockets/Public/NetworkMessage.h下,包括消息类型、通道类型、连接标志(None/Streaming/PreCookedIterative)、消息头格式、默认端口号等。Socket传输均采用的是阻塞操作,直到传输完成或出错才会返回。

并不是全部路径都使用网络访问,接口内有一个LocalDirectories数组记录哪些目录只以本地的形式访问。默认有一些目录以本地形式访问,如Binaries目录、Intermediate目录、Backup目录、Config目录、Logs目录、Sandboxes目录和所有的DDC目录。接口在每次做相关的文件操作前,先用函数IsInLocalDirectoryUnGuarded()判断一下路径是否应该以本地形式访问,如果是则不会在服务器上获取该路径的文件。

对于不在LocalDirectories数组中的路径,文件应该从服务器上获取。在涉及到需要访问这些文件的操作时,接口会先从服务器上将文件下载到本地,确保之后能以本地的形式访问,这一步由EnsureFileIsLocal()函数完成。为了知道哪些文件已经被下载过,每个被尝试下载过的路径会加入缓存路径列表CachedLocalFiles中,这样下次就可以直接查询出文件是否已在本地(即使下载失败,也会加入该表,下次不会再尝试下载)。

对于写入文件的操作,直接在本地文件系统上进行,不会写回到服务器上(服务器不需要对文件进行修改)。

网络文件路径与本地文件路径有一个对应关系,这个对应关系在初始化函数InitializeAfterSetActive()中,与服务器通信后建立。与服务器通信并初始化的过程大致如下:

  1. 接口将平台版本号、游戏名、本地的一系列常用目录路径发送给服务器,包括游戏目录路径(项目目录路径)、引擎路径、插件路径。扩展路径等。
  2. 服务器发送:
    • Cooked Package版本号,用于与本地版本对比。
    • 4个本地路径对应的服务器目录路径,包括服务器引擎路径、服务器项目路径、服务器引擎扩展路径、服务器项目扩展路径。
    • 一个服务器文件表ServerFiles,这个表中记录了服务器中的所有文件与文件的时间戳信息。有了这个表后,不需要访问服务器就可以知道服务器上有哪些文件,遍历服务器上的文件路径。
    • 一个服务器缓存表ServerCachedFiles,这个表中记录了服务器中的所有缓存文件与时间戳信息。(这个表具体含义不明)
  3. 将服务器的Cooked Package版本号与本地CookedVersion.txt中的版本号对比,如果服务器的版本未变,则不需要删除任何本地已经缓存的文件。
  4. 否则需要删除本地缓存的过期文件:通过对比服务器缓存表中的时间戳与本地文件的时间戳,将服务器上有更新版本的本地缓存文件删除。此外本地缓存了而服务器缓存表中没有的文件,也需要删除。

有了对应关系后,就可以把服务器上的文件路径对应到本地文件路径(进行字符串替换即可),ConvertServerFilenameToClientFilename()函数就实现了这个对应操作。

服务器的文件随时可能更新,接口会实时监测服务器上的更新并让本地的文件保持更新:Tick()中每5秒会调用PerformHeartbeat()与服务器进行心跳沟通,获得一个更新了的文件列表UpdatedFiles,并将本地过期的文件删除(同时删除缓存路径列表CachedLocalFiles中的对应项)。这样,下次访问到更新了的文件时,会重新从服务器下载最新版本。

FCookedIterativeNetworkFile

该接口在FNetworkPlatformFile的基础上,多了允许本地PAK文件绕过网络访问的功能。如果在一个迭代流程中,一些PAK文件不会被修改,则它们不需要从服务器上获取,直接用本地版本就可以了,这个接口便是提供了这个功能。

服务器在初始化时额外返回一个可以本地使用的PAK文件列表ServerValidPakFileFiles,之后访问文件时,如果文件在这些可以直接本地访问的PAK中,就不再需要网络访问,而是直接转发到底层的FPakPlatformFile进行访问。这个判断在ShouldPassToPak()函数中进行。

FStreamingNetworkPlatformFile

这个接口在FNetworkPlatformFile的基础上,实现了真正的流式文件传输。所有对文件的访问、读写操作都被转化为对服务器的访问,通过Socket与实时与服务器通信(除了遍历文件不需要访问服务器,因为初始化时就已经获得了服务器上所有文件的文件表)。这个接口也可以实际修改服务器上的文件。

每一种文件操作都有一个协议中定义的消息类型与之对应,操作时将消息类型、参数等以协议定义的格式序列化为一个Payload发送给服务器,阻塞等待服务器返回。

打开文件的OpenRead()OpenWrite()函数使用SendOpenMessage()通知服务器打开文件,服务器会返回一个文件的句柄ID,后续对该文件的操作以这个句柄ID作为标识。在接口配套定义的FStreamingNetworkFileHandle文件句柄中,Read()Write()等操作都被转换到对服务器发送消息,分别在SendReadMessage()SendWriteMessage()中。这样,对文件的读取实时从服务器获得(每次网络传输以64KB的块为单位进行,进行缓存),而对文件的写入则实时发送到服务器。

FSandboxPlatformFile

该接口实现了一个简单的沙盒机制:将访问的文件路径转换为沙盒内的文件路径进行访问。

接口的使用者有对沙盒内文件的读写权限,而对于沙盒外的文件默认只有读取权限而没有写入权限。此外,还可以用通配符的方式设置对哪些沙盒外的文件或目录禁止读取权限(也就是读取权限的黑名单)。判断沙盒外文件是否在这个名单内的函数是OkForInnerAccess();用AddExclusion()函数加入通配符到黑名单中。

其核心的功能是将文件路径与沙盒内路径进行相互转换,分别通过ConvertToSandboxPath()ConvertFromSandboxPath()这两个函数进行。其转换逻辑大致如下:

  1. 对于已在沙盒内的文件路径,保持原样
  2. 对于在游戏目录下(即项目目录FPaths::ProjectDir())的路径,转换为沙盒内的对应路径(沙盒根目录/项目名/...
  3. 对于根目录下(即FPaths::RootDir())的路径,转换为沙盒内的对应路径(沙盒根目录/...

对于只涉及读取的文件操作,首先会将路径转换为沙盒内路径,尝试以这个路径访问。如果访问失败(文件不在沙盒内),则用OkForInnerAccess()判断该沙盒外的路径是否允许读取,如果是则访问。对于涉及到写入的文件操作,如OpenWrite()DeleteFile()MoveFile()CreateDirectory()等,只以沙盒内的路径进行写入,以确保不会写入到沙盒外部。

有了路径转换和对写入操作的限制后,沙盒化功能就实现了。沙盒功能的具体开关由bSandboxEnabled变量控制,其可以通过SetSandboxEnabled()来开启或关闭。

此外该接口还有一些杂项功能,如清除沙盒内全部文件的WipeSandboxFolder()函数,这个函数通过初始化时给接口提供的命令行参数触发(在命令行参数中加入wipe:前缀)。命令行中通过-Sandbox=可以设置沙盒路径,可以是绝对路径/相对路径,也可以是一些常用路径,如User表示用户文件夹下的一个路径用户目录/My Games/UE4Unique表示以Guid生成一个独一无二的根目录下的随机路径。

FPakPlatformFile

该接口是UE4对PAK文件的读写封装。PAK文件的具体实现在PAKFile类中,FPakPlatformFile将各种对PAK的读取访问操作,最后变为调用PAKFile的实现函数。

PAK文件挂载后便可以像普通的文件一样访问PAK中的文件,挂载与卸载分别通过Mount()Unmount()函数进行。挂载除了要提供PAK文件名外,还可以提供挂载路径,可以指定将PAK文件挂载在某个虚拟文件系统的路径下(这样便可以将虚拟目录里的文件分散到多个PAK进行储存了)。支持同时挂载多个PAK,考虑到经常需要挂载一个目录下的所有PAK文件,接口还提供了一个MountAllPakFiles()函数通过通配符批量挂载PAK文件,初始化时便会用该函数从几个常用目录下挂载所有找到的PAK文件夹。

PAK文件都有一个挂载优先级,优先级决定了若多个PAK文件都含有同一个虚拟路径的文件,应该选用哪个PAK中的文件的问题。PAK文件的挂载优先级可以在Mount()时制定。当用MountAllPakFiles()批量挂载时,挂载优先级由一个硬编码的逻辑决定,为主游戏PAK > 游戏Content PAK > 引擎Content PAK > Saved Dir PAK > 其他PAK。

挂载优先级同时也是打补丁的重要机制。在UE4中,补丁包PAK文件名规定以_P结尾,并且可以额外附加补丁版本号,如XXX_001_P.pak。在Mount()函数中,如果发现PAK是一个补丁包,便会根据补丁的版本号给优先级加上一个bonus,比如XXX_001_P.pak文件的优先级就会增加(1+1)*100=200。这样就确保了补丁包PAK的优先级永远高于被打补丁的包。

由于PAK文件只读,不允许对PAK内的文件进行写入,因此使用OpenWrite()打开文件写入时实际上是在往本地文件系统写入。

OpenRead()打开文件读取时,首先用FindFileInPakFiles()函数在所有已挂载的PAK中查找是否有该文件,找到了则返回PAK内的文件句柄;若没找到,还会使用IsNonPakFilenameAllowed()函数查询是否允许通过本地文件系统访问,允许的话,则转发到底层的本地文件系统进行读取。

FPlatformFileReadStats

该接口实现了统计读取操作的速度的功能,共统计了以下7个指标:

  • 1 Tick下平均读取速度(MB/s)
  • 1 Tick下平均每次读取操作的读取大小(KB)
  • 1 Tick下发起的读取操作总数
  • 总平均读取速度(MB/s)
  • 总平均每次读取操作的读取大小(KB)
  • 总读取大小(MB)
  • 总读取操作次数

对读取速度的统计需要在调用底层文件接口的Read()函数前后获取时间,以求出前后的时间差,即操作花费的时间。该功能实现在文件句柄类的子类FPlatformFileReadStatsHandle::Read()函数中。由于需要计算每Tick下的读取速度均值,在Tick()函数中需要清空并更新统计量。

获取当前平台时间使用了FPlatformTime::Seconds()返回double类型的秒数。当秒数不会过小时(防止除零),计算BytesPerSec每秒读取KB数。更新统计量的时候采用了原子操作,主要是避免多文件句柄同时读取时,多线程更新统计量导致竞争。

这个包装接口在需要对文件读取速度做性能剖析时使用,不能用于Shipping发布版本。

Q&A

  1. NetWorkPlatformFile和本地同名文件的关系?

    本地的文件不被考虑,只以服务器文件为准。引擎内存中有一个数组记录了哪些文件在本次运行中被缓存,所有不在这个数组中的文件路径被访问时都会从服务器获取,并覆盖原有的本地文件。

    总的来说,所有本次运行所访问到的文件都一定是源自服务器的。

  2. SandBox的具体作用?什么情况下要用SandBox?

    防止引擎对本地文件系统写入。当运行未知来源的游戏文件、或者是不希望游戏对文件系统做任何修改时,用SandBox(我猜的)

  3. NetWorkPlatformFile和PakPlatformFile的关系?

    前者为网络文件层,该层从服务器获取文件。

    后者为PAK文件层,该层从打包好的PAK文件中获取文件。

  4. 文件读取速度的统计方式?

    FPlatformFileReadStats类的描述。

参考

  1. UE4 文件系统
  2. UE4 Pak 相关知识总结
文章作者: dhbloo
文章链接: https://dhbloo.github.io/2020/09/07/UE4-FileSystem/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 dhb's Blog