做UE5项目做到后期,最考验人的往往不是玩法系统,而是那些文档里不会写清楚的工程细节。文件(夹)的移动与查找就是其中一个绕不过去的点:玩家按个快捷键备份存档、日志按天归档、补丁下载完做落盘校验、开发期扫描Content目录里的资源清单……这些需求看着零碎,处理不好却能让你在测试机上折腾一整天。
标题里这组API——FFileManagerGeneric::Move和IFileManager::FindFilesRecursive,恰好覆盖了文件操作最常碰到的两个场景:把文件或文件夹从A挪到B,以及在某个目录树下递归筛出符合条件的文件。这篇文章我就围绕这两个函数展开,先说清楚它们的设计意图和底层逻辑,再给可直接抄作业的C++实现,最后把我踩过的坑和排查思路一并交代。蓝图党也可以看,理解了底层行为,后面用蓝图封装功能接口时会更有底。
1. 项目概述:为什么UE5项目需要自己处理文件(夹)移动与查找
1.1 这组API能解决什么问题
引擎自带的蓝图和C++模板项目,默认不会给你一个现成的"文件管理器"节点。你真正需要的存档备份、日志清理、资源扫描,最终都要落到IFileManager这一层。我总结下来,日常项目里至少有四类需求会用到标题里的两个接口:
一是存档管理。玩家存档要写保护、要自动备份,通常是旧存档移动到Archive子目录,留下干净的当前存档。二是日志与崩溃文件归档。项目跑久了Saved目录下Logs越来越多,需要按日期把*.log挪走。三是补丁或MOD资源扫描。启动时递归扫描某个可写目录下的.pak或自定义资源文件,把它们登记到资源列表里。四是开发辅助工具。比如编辑器插件里批量整理美术资源,把散落在各子目录的贴图归类到统一目录。
这些需求有一个共同点:单次操作逻辑不复杂,但失败后果往往很严重。移动一个存档失败,玩家可能直接丢进度;扫描漏了一个文件,MOD功能就静默失效。所以这组API值得认真对待,不能随手写写就完事。
1.2 前置知识:IFileManager 与 FFileManagerGeneric 的关系
在看函数签名之前,先明确一个概念:IFileManager是抽象接口,FFileManagerGeneric是它的默认实现。你在C++里写的IFileManager::Get().Move(...),在PC平台实际执行的就是FFileManagerGeneric::Move(...),这也是标题把类名写得这么具体的原因。
IFileManager::Get()返回的是一个全局单例,获得它不需要new也不需要释放:
#include "Misc/IFileManager.h" #include "Misc/Paths.h" IFileManager& FileManager = IFileManager::Get(); bool bFileExists = FileManager.FileExists(TEXT("D:/Temp/test.txt"));值得多说一句的是,FFileManagerGeneric本身不直接碰磁盘,它内部会走平台抽象层IPlatformFile。在Windows上底层是Win32的MoveFileEx,在Linux/Android上是rename(),在主机平台又有各自封装。这意味着同一个函数在不同平台的行为细节会有差异,比如跨卷移动、只读文件处理、路径大小写敏感度。后面讲实操时我会逐个点出来。理解这一点很重要:你写的是跨平台代码,但跑的永远是某个具体平台的行为。
2. 核心API拆解:Move 与 FindFilesRecursive 的原理与差异
2.1 FFileManagerGeneric::Move:移动文件的底层逻辑
先看完整签名:
bool Move(const TCHAR* NewDest, const TCHAR* Source, bool bReplaceExisting, bool bEvenIfReadOnly);四个参数分别表示:目标路径、源路径、目标已存在时是否替换、源文件即使是只读属性也照常处理。返回true表示移动成功,false表示失败。
我在项目里最常用的调用是这样的:
bool bMoved = IFileManager::Get().Move( *DestinationPath, // 新位置 *SourcePath, // 原位置 true, // 目标已存在时覆盖 true // 源文件只读也移动 );注意bEvenIfReadOnly这个参数,在Windows平台上特别重要。很多文件从版本库里拉下来或者从打包机拷贝过来,属性里带了"只读",不是只读文件与否,是修改权限的问题。MoveFileEx遇到只读源文件会拒绝操作,这个参数就是用来提前解除这种拒绝的。
底层逻辑上,FFileManagerGeneric::Move做了两件事:先是尝试同卷重命名,这是最便宜的路径,本质只是改目录项,瞬间完成;如果平台返回失败(比如跨磁盘、目标目录有问题),它会尝试降级为"复制+删除源文件"。也就是说,Move本身不是一个原子操作,跨卷时它可能先完整复制一份再删源。这一点直接决定了后面我要讲的目录移动风险点。
还有一点容易忽略:很多版本的实现里,Move不会替你自动创建目标目录。如果你把文件挪到一个不存在的目录,某些平台会直接失败。所以安全写法永远是先MakeDirectory再Move,这一点放到下一章实操里展开。
2.2 IFileManager::FindFilesRecursive:递归查找的筛选机制
这个函数的签名有点绕,先摆出来:
bool FindFilesRecursive(TArray<FString>& FileNames, const TCHAR* StartDirectory, const TCHAR* Filename, bool bFiles, bool bDirectories);参数依次是:用于接收结果的数组、起始目录、文件名通配符(注意只放文件名部分,不要带完整路径)、是否收集文件、是否收集目录。
举个例子,我要找出玩家存档目录下所有.sav文件:
TArray<FString> SaveFiles; IFileManager::Get().FindFilesRecursive( SaveFiles, *FPaths::ProjectSavedDir(), TEXT("*.sav"), true, // 收集文件 false // 不收集目录 );调用结束后,SaveFiles里是所有匹配的完整路径。注意通配符用的是UE自己的FString::MatchesWildcard规则,支持*匹配任意长度、?匹配单个字符,所以*.sav、save_?.dat、backup_*这类写法都成立。
bFiles和bDirectories可以同时为true,这样文件目录一起收集。如果两个都为false,那函数基本什么都不干。内部实现是先列出当前目录的所有条目,筛选匹配的加入结果,然后对每个子目录递归进入下一层。这意味着结果顺序不固定,依赖平台文件枚举顺序,需要保证顺序时就自己排序。
这个函数最大的优点是"一次调用拿全树",缺点是"一次调用可能很慢"。如果你对一个几十GB的目录树做全量递归,又是在游戏主线程调用,帧率会瞬间掉落,后面我会专门讲异步化处理。
2.3 为什么选UE封装而不是C++标准库
很多C++背景的同行第一反应是:我有std::filesystem,为什么还要用IFileManager?这个问题我一开始也纠结过,后来被两个真实场景说服了。
第一个场景是Pak文件虚拟化。打包后的游戏,Content目录里的资源并不以散文件形式存在,而是被打进.pak并通过IoStore/PakFile挂载成虚拟路径。std::filesystem::exists对这种虚拟路径一无所知,而IFileManager因为走引擎的IPlatformFile链路,会自动把请求路由到Pak层。你要用标准库检查一个Pak内的资源是否存在,结果永远是"不存在",排查半天才发现是查询层不对。
第二个场景是路径规范和FString生态。UE项目里路径到处是FString,手写的路径可能带相对前缀、可能混用正反斜杠、可能带尾部分隔符。标准库需要你手动转换、清理,而IFileManager配合FPaths全家桶,天然就处理好了这些琐碎问题。
那std::filesystem就完全没用了吗?也不是。如果你写的是独立工具、材质烘焙器、命令行辅助程序这类不依赖引擎运行时环境的程序,用标准库反而更清爽。判断标准很朴素:代码跑在UE进程里,就用IFileManager;跑在UE进程外,随便你用什么都行。
3. 实操:文件(夹)移动的完整实现
3.1 基础移动:单文件迁移
先把最简单的单文件移动写完整。注意我这里做了三件容易被忽略的事:路径转绝对、目标父目录预创建、失败日志打印。
#include "Misc/IFileManager.h" #include "Misc/Paths.h" bool MoveSingleFile(const FString& SourcePath, const FString& DestPath) { if (!IFileManager::Get().FileExists(*SourcePath)) { UE_LOG(LogTemp, Warning, TEXT("MoveSingleFile: source not found: %s"), *SourcePath); return false; } const FString FullSource = FPaths::ConvertRelativePathToFull(SourcePath); const FString FullDest = FPaths::ConvertRelativePathToFull(DestPath); // 目标目录若不存在,先建出来 IFileManager::Get().MakeDirectory(*FPaths::GetPath(FullDest), true); bool bMoved = IFileManager::Get().Move( *FullDest, *FullSource, true, // 覆盖已存在目标 true // 只读源文件也移动 ); if (!bMoved) { UE_LOG(LogTemp, Error, TEXT("MoveSingleFile failed! %s -> %s"), *FullSource, *FullDest); return false; } UE_LOG(LogTemp, Log, TEXT("MoveSingleFile OK: %s -> %s"), *FullSource, *FullDest); return true; }为什么要先ConvertRelativePathToFull?因为游戏运行时FPaths::ProjectSavedDir()返回的路径在某些平台可能是相对路径,而相对路径的解析基准在不同工作目录下不可控。先转绝对,让日志和调试信息有确定性。FPaths::GetPath(FullDest)取的是目标文件所在目录,MakeDirectory的第二个参数bTree设为true,会自动创建多级中间目录,这个细节省了我不少事。
这里有个真实教训:早期我不建目录直接Move,Windows上一切正常,因为同名文件的父目录恰好存在;后来换到某主机平台测试,第一次移动就失败,日志清一色"Move failed",原因就是目标父目录不存在。所以先建目录再移动,应该成为习惯而不是例外。
3.2 整目录移动与目标目录自动创建
目录移动和文件移动的调用形式差不多,但坑更多。先看代码:
bool MoveDirectory(const FString& SourceDir, const FString& DestDir) { if (!IFileManager::Get().DirectoryExists(*SourceDir)) { UE_LOG(LogTemp, Warning, TEXT("MoveDirectory: source dir not found: %s"), *SourceDir); return false; } // 确保目标目录的父级存在 IFileManager::Get().MakeDirectory(*FPaths::GetPath(DestDir), true); // 移动目录时一般不希望"合并",所以bReplaceExisting给false bool bMoved = IFileManager::Get().Move( *DestDir, *SourceDir, false, // 目标已存在时不替换 true ); if (!bMoved) { UE_LOG(LogTemp, Error, TEXT("MoveDirectory failed! %s -> %s"), *SourceDir, *DestDir); } return bMoved; }目录移动和文件移动最大的区别在于"替换"语义。文件移动的bReplaceExisting=true是指用新文件覆盖旧文件,这个操作通常没问题;但目录覆盖意味着把目标目录整个删掉,而目标目录可能还有其他内容,这个风险就大了。所以我建议做目录移动时,把bReplaceExisting固定设为false,宁可失败也不要静默吞掉目标目录。
另外提醒一下,跨卷移动目录时,底层降级为"复制+删除源"往往不可靠。FFileManagerGeneric对目录的递归复制回退不一定完整覆盖所有情况,尤其是包含隐藏文件、符号链接、长路径深层结构的时候。我的实测经验是:尽量保证目录移动发生在同一驱动的可写区域,比如从ProjectSavedDir/A挪到ProjectSavedDir/B,这种同卷重命名是最顺畅的。
3.3 移动到不同挂载卷时的降级处理
那如果确实要把文件从C盘挪到D盘、或者从SD卡挪到内部存储,怎么办?我的做法是手动实现"复制+校验+删除源"三步:
bool MoveFileCrossVolume(const FString& From, const FString& To) { IFileManager& FM = IFileManager::Get(); if (!FM.FileExists(*From)) { return false; } FM.MakeDirectory(*FPaths::GetPath(To), true); // 1. 先复制 if (!FM.Copy(*To, *From, true, true)) { UE_LOG(LogTemp, Error, TEXT("MoveFileCrossVolume: copy failed %s -> %s"), *From, *To); return false; } // 2. 校验:源和目标大小一致才删除源 const int64 SrcSize = FM.FileSize(*From); const int64 DstSize = FM.FileSize(*To); if (SrcSize == INDEX_NONE || DstSize == INDEX_NONE || SrcSize != DstSize) { UE_LOG(LogTemp, Error, TEXT("MoveFileCrossVolume: size mismatch, keep source")); return false; } // 3. 删除源 if (!FM.Delete(*From, true, true)) { UE_LOG(LogTemp, Warning, TEXT("MoveFileCrossVolume: copied but delete source failed: %s"), *From); return false; } return true; }这里加的校验逻辑很关键。直接调Move时,引擎内部替你做了copy+delete,但不会告诉你第二步是否成功;## 4. 实操:递归查找文件与目录
4.1 按扩展名筛选并收集文件
查找操作里最常用的就是"把某个目录树下所有指定类型的文件都找出来"。我举个例子,项目里扫描所有日志文件:
TArray<FString> FindAllLogFiles() { TArray<FString> Result; const FString Root = FPaths::ProjectSavedDir(); IFileManager::Get().FindFilesRecursive( Result, *Root, TEXT("*.log"), true, // 收集文件 false // 不收集目录 ); return Result; }如果你要收集多种扩展名,比如.uasset和.umap,那就把通配符写成*.uasset,或者干脆先全量收集再手动过滤:
TArray<FString> FindAssetFiles(const FString& RootDir) { TArray<FString> AllFiles; IFileManager::Get().FindFilesRecursive( AllFiles, *RootDir, TEXT("*"), true, false ); TArray<FString> Result; for (const FString& FilePath : AllFiles) { const FString Ext = FPaths::GetExtension(FilePath, true); // 带点的小写扩展名,如 ".uasset" if (Ext == TEXT(".uasset") || Ext == TEXT(".umap")) { Result.Add(FilePath); } } Result.Sort(); // 保证确定性顺序 return Result; }注意FPaths::GetExtension第二个参数传true,意思是把扩展名改成小写返回。在Windows上文件名不区分大小写,但Linux/主机平台是区分的,统一小写能避免过滤漏掉.UASSET这类写法。
这里插一句经验:FindFilesRecursive返回的结果顺序不保证稳定,我实测同一目录两次枚举顺序都可能不同。凡是后续要按顺序处理文件的场景,拿到结果后先Sort()排序,否则会出现这次遍历是A、B、C,下次变成C、A、B,日志对比时非常误导人。
4.2 同时查找文件夹与子目录结构
有些需求不关心文件,只关心目录结构,比如"找出所有含存档的子目录"。把bDirectories打开即可:
TArray<FString> FoundDirs; IFileManager::Get().FindFilesRecursive( FoundDirs, *FPaths::ProjectSavedDir(), TEXT("*"), // 匹配所有目录名 false, // 不收集文件 true // 收集目录 );这个调用会把ProjectSavedDir下所有子目录(含多级)都列出来。常见的坑是:新手以为Filename参数要传完整路径或者目录前缀,结果什么东西都匹配不上。记住,这个参数只管"当前条目的名字部分",通配符只对这一层名字生效。想匹配特定前缀目录,比如名字叫Save_开头的,用Save_*就行。
另外在某些平台实现里,隐藏目录(以.开头)可能不会被枚举到。如果你确实需要处理这类目录,建议先用FPlatformFileManager::Get().GetPlatformFile()拿到底层平台文件接口做更细的控制,IFileManager这一层就不强求了。
4.3 与Move组合:实现条件归档功能
单看查找和移动都简单,把它们组合起来才是真实业务的常见形态。我做一个存档归档器:把超过30天的旧存档文件移动到Archive子目录。
#include "Misc/DateTime.h" void ArchiveOldSaveGames(const FString& SaveRoot, int32 MaxAgeDays) { IFileManager& FM = IFileManager::Get(); // 1. 找出所有存档 TArray<FString> SaveFiles; FM.FindFilesRecursive(SaveFiles, *SaveRoot, TEXT("*.sav"), true, false); // 2. 准备归档目录 const FString ArchiveRoot = SaveRoot / TEXT("Archive"); FM.MakeDirectory(*ArchiveRoot, true); // 3. 逐个判断时间并移动 const FDateTime Now = FDateTime::UtcNow(); int32 MovedCount = 0; for (const FString& SaveFile : SaveFiles) { // 已经是归档目录里的文件,跳过 if (SaveFile.StartsWith(ArchiveRoot)) { continue; } const FDateTime FileTime = FM.GetTimeStamp(*SaveFile); if (FileTime == FDateTime::MinValue()) { UE_LOG(LogTemp, Warning, TEXT("GetTimeStamp failed: %s"), *SaveFile); continue; } if (FileTime < Now - FTimespan::FromDays(MaxAgeDays)) { const FString Dest = ArchiveRoot / FPaths::GetCleanFilename(SaveFile); if (FM.Move(*Dest, *SaveFile, true, true)) { ++MovedCount; } else { UE_LOG(LogTemp, Error, TEXT("Archive failed: %s -> %s"), *SaveFile, *Dest); } } } UE_LOG(LogTemp, Log, TEXT("ArchiveOldSaveGames done, moved %d files"), MovedCount); }这个例子里有几个细节值得讲一下。GetTimeStamp返回FDateTime,失败时返回FDateTime::MinValue(),所以要先做一次兜底判断。SaveFile.StartsWith(ArchiveRoot)这行是在避免一个经典错误:如果归档目录在存档根目录内部,那么已经归档的文件会被下一次扫描再找出来,然后移动到自己身上,造成无意义的失败或循环复制。跳过归档目录自身,整个函数才是幂等的。
归档目录和源目录的关系,我建议放在同一个可写根目录下,这样Move大概率走同卷重命名,快且安全。如果存档根目录结构特别深,先算一下相对路径再拼归档路径也是好做法,这里为了展示简洁就不展开了。
5. 常见问题与排查技巧实录
5.1 路径分隔符与平台差异
路径分隔符是我见过的最多低级Bug来源。手写路径时有人用"D:/Temp/test.txt",有人用"D:\\Temp\\test.txt",在Windows上两者都行,但一旦把这种字符串丢进配置表、打包到其他平台就出问题。统一的做法是永远用FPaths::Combine拼接路径:
const FString FullPath = FPaths::Combine(RootDir, SubDir, FileName);FPaths::Combine会自动处理分隔符和多余斜杠。如果确实要手写,也只用正斜杠,UE内部绝大多数函数都接受正斜杠,而反斜杠在非Windows平台完全是非法字符。
再看一个更隐蔽的差异:Windows不区分文件名大小写,Linux/Android区分。同一个LoadTexture在编辑器里好好的,打包到Linux服务器跑就加载失败,很可能只是资源名大小写不一致。FPaths::GetExtension(..., true)和路径比较都统一用小写,是降低这种平台差异的必要手段。
5.2 移动失败却返回false?返回值与日志排查
我见过不少人调试Move失败时,只盯着返回值,却看不到为什么失败。IFileManager本身日志有限,你需要自己补全信息。我的排查套路是三步:
第一,先打印源和目标路径,并且确保打印出来的是绝对路径。第二,手动检查源是否存在、目标父目录是否存在,用FileExists和DirectoryExists分别确认。第三,如果源和目标在同一个目录名上疑似冲突,把bReplaceExisting试一遍,看两种取值下行为差异。
给你一份我常用的日志模板:
UE_LOG(LogTemp, Error, TEXT("[Move] source=%s exists=%d size=%lld"), *FullSource, FM.FileExists(*FullSource) ? 1 : 0, FM.FileSize(*FullSource)); UE_LOG(LogTemp, Error, TEXT("[Move] dest=%s parentDirExists=%d destExists=%d"), *FullDest, FM.DirectoryExists(*FPaths::GetPath(FullDest)) ? 1 : 0, FM.FileExists(*FullDest) ? 1 : 0);打印出这些之后,绝大多数Move失败原因就已经清楚了:要么源路径根本不在预期位置,要么目标父目录没建好,要么目标文件已存在且bReplaceExisting为false。尤其是bReplaceExisting这个参数,它在文件移动里的语义很直白,但也很容易被误解成"要不要覆盖同名文件的内容"。实际上如果目标文件存在且这里传入false,Move会直接返回false,这一点和行为命名不一致,很容易踩。
还有一种让人抓狂的情况:Move返回true,但源文件还在原地。这多半是跨卷降级时"复制成功、删除源失败",引擎把这个当作整体失败处理了,但文档描述又模糊,导致你误判。我的建议是不要只信返回值,Move之后主动检查源文件是否真的消失,必要时做一次文件大小对账。
5.3 主线程卡顿与异步IO方案
这是性能向最容易翻车的地方。FindFilesRecursive在一个几万文件的项目目录上跑,可能耗时几百毫秒到几秒不等,直接在主线程调用的后果就是掉帧、卡顿、甚至被系统ANR(Android上直接弹"无响应")。
处理办法是丢到后台线程。UE里最简单的写法是用Async:
#include "Async/Async.h" void ScanSaveFilesAsync(const FString& RootDir, TFunction<void(const TArray<FString>&)> OnComplete) { Async(EAsyncExecution::ThreadPool, [RootDir, OnComplete]() { TArray<FString> FoundFiles; IFileManager::Get().FindFilesRecursive( FoundFiles, *RootDir, TEXT("*.sav"), true, false ); // 回到游戏线程执行回调 Async(EAsyncExecution::TaskGraphMainThread, [OnComplete, FoundFiles]() { OnComplete(FoundFiles); }); }); }这个写法的核心原则是:后台线程只处理纯数据,不碰任何UObject、Actor或GameInstance。RootDir和OnComplete都是以副本形式捕获进去的,回调里拿到的FoundFiles也是局部变量拷贝。如果你在后台线程里顺手访问了某个AActor的成员,轻则数据竞争,重则直接崩溃,这是异步文件操作最常见的翻车现场。
移动操作同理,可以异步执行,但要注意时序:如果玩家在移动中又触发了一次读取存档,极可能读到一半不存在的文件。我习惯在异步移动前后用一个FThreadSafeCounter或者原子标志标记状态,读取逻辑检查这个标志,避免并发读写冲突。
5.4 文件占用与只读属性
Windows下移动一个正在被其他进程打开的文件,会直接失败。典型场景是:杀毒软件扫描文件、资源管理器预览缩略图、或者你自己在上一个逻辑里忘记Close句柄就调Move。这种问题不是引擎能解决的,只能重试或者提示用户关闭占用的程序。
实操里我加了重试机制:
constexpr int32 MaxRetry = 5; constexpr float RetryInterval = 0.1f; bool MoveWithRetry(const FString& From, const FString& To, int32 MaxRetries) { for (int32 i = 0; i < MaxRetries; ++i) { if (IFileManager::Get().Move(*To, *From, true, true)) { return true; } FPlatformProcess::Sleep(RetryInterval); } return false; }注意这个重试逻辑必须放在非游戏线程,不然Sleep会把主线程卡死。每间隔0.1秒重试一次,最多5次,实测足够应付绝大多数临时占用。
还有一个和平台绑定的现象:Windows上从外部拷贝进来的文件经常带只读属性,此时Move如果忘了bEvenIfReadOnly=true,就会静默失败。我在打包服务器上遇到过多次,最后统一把所有文件操作都传true,问题绝迹。代价是如果你真的要保护某个只读资源不被移动,得额外写校验逻辑,但游戏运行时几乎没有这种需求,可以放心传true。
6. 进阶建议与个人实操心得
6.1 封装一个文件服务模块
把散落的文件操作直接写在各处代码里,初期很爽,后期很难受。一旦发现某个移动操作需要统一加上断点校验、事件回调、日志统计,你就要满项目去找调用点。
我的习惯是封装一个FileService模块,对外只暴露业务语义接口,比如"备份存档""归档日志""扫描补丁",对内统一调用IFileManager。大致结构:
class FFileService { public: bool BackupSaveGame(const FString& SaveName); bool ArchiveOldLogs(int32 KeepDays); TArray<FString> ScanPatchFiles(const FString& PatchRoot); private: bool MoveWithCheck(const FString& From, const FString& To); };好处是:路径规则、异常处理、日志格式都收敛在一处,出问题只需要改一个文件。业务代码完全不用关心Move的第四个参数传什么,也不用记得先建目录。如果你的项目是多人在维护,这种封装能显著减少"一个人一种写法"带来的内耗。
6.2 给新手的调试建议
结合我自己的经历,最后给三条操作性很强的建议。
第一条,写文件操作代码前先打印路径。不是打印一次,而是把所有涉及的根目录、源路径、目标路径在启动时打一遍。很多"移动失败"的根源是路径根本不是你想象的那个,尤其打包后运行时工作目录和编辑器不一致,Relative路径会给你一个措手不及。
第二条,尽早做打包验证。编辑器里一切正常,不代表打包后正常。打包后Content目录是Pak挂载的,只读;ProjectSavedDir才是可写区域。如果你在编辑器里把文件写到Content目录下,编辑器是允许的,但打包后同一代码必然失败。所以文件相关功能从设计上就只允许写ProjectSavedDir或ProjectUserDir,省去后面一堆排查。
第三条,善用FPaths全家桶。我列个常用速查表,新手可以收藏:
| 需求 | 函数 |
|---|---|
| 取文件名(不含目录) | FPaths::GetCleanFilename |
| 取底层文件名(不含扩展名) | FPaths::GetBaseFilename |
| 取扩展名(小写带点) | FPaths::GetExtension(Path, true) |
| 取父目录 | FPaths::GetPath |
| 拼接路径 | FPaths::Combine |
| 相对路径转绝对 | FPaths::ConvertRelativePathToFull |
| 标准格式路径 | FPaths::MakeStandardFilename |
这些函数配合IFileManager能覆盖90%的常规需求。说实话,我见过不少同行在这些小工具函数上重复造轮子,最后造的轮子还是方的——比如自己写字符串替换来处理分隔符,结果漏了FPaths::Combine这个现成方案。
文件(夹)移动与查找,本身不是什么高深技术,但它处在"业务需求"和"平台差异"的交界处,稍不留神就会给你来一下。我的体会是:这类基础操作宁可多写几行防御逻辑,也不要图省事直接裸调Move。多一层目录检查、多一次返回值校验、多一段日志输出,看起来啰嗦,但在玩家存档、补丁资源这种敏感场景里,这些防护往往就是避免事故的最后一根稻草。希望这篇文章能帮你把这些坑提前填平。