UE文件读写与JSON解析实战:路径、编码与遍历全指南
如果你经常碰 UE 里的配置文件、导出表、关卡列表这类需求肯定知道“读文件”这活儿看着简单真上手全是细节。路径写不对、中文乱码、JSON 解析出来全是空、打包之后文件读不到……每一个都够你折腾半天。这篇文章把我实际项目里用引擎库读写文件、遍历文件夹、解析 JSON 的写法直接摆出来C 为主顺带说一下蓝图侧怎么配合适合客户端开发、工具开发、TA 或者刚转到 UE 的程序看。1. 动手前先搞懂引擎的路径与文件访问规则1.1 路径前缀与常用目录UE 里的路径系统和 Windows/Linux 原生路径不是一回事。你用FPaths拿到的路径前缀都不一样常见有这些FPaths::ProjectDir()项目根目录后面带Content/、Config/、Source/这些。FPaths::ProjectContentDir()项目的Content目录也就是资源目录。FPaths::ProjectSavedDir()存档目录一般放运行时生成的文件、日志、截图。FPaths::ProjectConfigDir()配置文件目录放Default*.ini之类。FPaths::Combine(PathA, PathB)拼接路径比手写/拼接要安全自动处理分隔符。这里先说一个最常见的误区很多人直接在代码里写C:/MyProject/Content/Data/config.json。如果你只在本地编辑器里跑可能没问题但项目一打包路径变成projectname/Content/...的相对结构沙箱路径也变了硬编码全废。所以正确的思路是用FPaths拿到根目录再往后拼相对路径。FString RootDir FPaths::ProjectContentDir(); FString FullPath FPaths::Combine(RootDir, TEXT(Data), TEXT(config.json));另外UE 内部处理路径时正斜杠/优先。Windows 风格的\也能用但在 C 字符串里容易踩转义坑。比如你写C:\Project\Content\P会被当成无效转义序列编译期就会报警告。所以我个人习惯是统一用/拼接交给FPaths::Combine不要自己搂\。1.2 路径规范化与合法性检查拿到路径后最好做一遍规范化。FPaths::ConvertRelativePathToFull可以把相对路径、带../的路径转成绝对路径也能把./去掉。这在调试时特别有用因为你可以直接在系统文件管理器里打开路径看看文件到底在不在。FString RelativePath TEXT(../../../MyGame/Content/Data/config.json); FString FullPath FPaths::ConvertRelativePathToFull(RelativePath);注意ConvertRelativePathToFull依赖当前工作目录运行环境不同编辑器、打包游戏、命令行工具结果可能不一样。想完全可控就基于FPaths的已知目录去组装。检查文件是否存在我一般用FPaths::FileExists和FPaths::DirectoryExistsif (FPaths::FileExists(FullPath)) { // 文件在 } if (FPaths::DirectoryExists(DirPath)) { // 目录在 }这两个接口内部会走平台文件系统基本不坑。但在打包环境下有些路径可能是只读的比如嵌入到 Pak 里的资源路径。文件在逻辑上存在但你不一定能用普通文件接口去改它。2. 用引擎库遍历和读取文件夹2.1 IFileManager 和 FPlatformFileManager 怎么选UE 的文件操作入口最常用的两个IFileManager::Get()高层封装最常用能处理包内的文件系统、虚拟路径。FPlatformFileManager::Get().GetPlatformFile()平台底层文件接口更直接适合做平台相关逻辑。绝大多数场景用IFileManager::Get()就够了。拿遍历文件夹来说官方接口是IFileManager::FindFiles和IFileManager::FindFilesRecursive。TArrayFString OutFiles; IFileManager::Get().FindFiles(OutFiles, *SearchPath, *TEXT(*.json));FindFiles的这个重载会把匹配的文件名放到OutFiles里。注意它返回的只是文件名不带完整路径也不带目录名。所以如果你要读文件内容还得自己拼FString FilePath FPaths::Combine(SearchPath, OutFiles[i]);2.2 递归遍历、扩展名过滤与排序如果子文件夹里也有配置文件FindFiles就不够用了。换FindFilesRecursiveTArrayFString FoundFiles; FString SearchPath FPaths::ProjectContentDir() / TEXT(Data); IFileManager::Get().FindFilesRecursive( FoundFiles, *SearchPath, *TEXT(*.json), true, // 是否搜子目录 false // 只要文件不要目录 );这样拿到的FoundFiles里是完整路径直接能读。注意最后一个参数Filestrue表示返回文件Filesfalse会返回目录。如果你要区分文件和文件夹就按需求传。还有个小细节FindFilesRecursive不会自动排序尤其在不同平台上文件系统返回顺序可能不一样。遍历后想做“按时间排”“按名字排”就自己 Sort 一下FoundFiles.Sort();或者按修改时间排序FoundFiles.Sort([this](const FString A, const FString B) { return IFileManager::Get().GetTimeStamp(*A) IFileManager::Get().GetTimeStamp(*B); });GetTimeStamp返回FDateTime直接比较就行。做本地化文件、存档列表这类功能时按时间排序很实用。只拿子目录不拿文件也很常见。比如做一个“地图列表”功能要枚举Content/Maps下面所有关卡文件夹TArrayFString OutDirectories; IFileManager::Get().FindFiles(OutDirectories, *SearchPath, *TEXT(*));第二个重载参数可以传*TEXT(*)文件目录都会返回。如果再配合IFileManager::Get().DirectoryExists判断一下就能精确区分。3. 文件内容读取文本、二进制与流式方案3.1 FFileHelper 快速读取UE 里最省事的文本读取函数是FFileHelper::LoadFileToStringFString Content; bool bSuccess FFileHelper::LoadFileToString(Content, *FullPath); if (bSuccess) { // Content 就是文件内容 }这个函数会自动检测 UTF-8 编码读进来变成FString。配合 JSON 解析时我基本都用它简单粗暴。读二进制用LoadFileToArrayTArrayuint8 RawData; bool bSuccess FFileHelper::LoadFileToArray(RawData, *FullPath); if (bSuccess) { // RawData 就是原始字节 }很多新手会问文件明明存在为什么LoadFileToString返回 false大概率是编码问题。比如文件是 UTF-16 编码引擎读的时候按 UTF-8 处理会失败或者文件带 BOM 但格式不标准。遇到这种情况可以先用LoadFileToArray拿到字节再看前两三个字节判断 BOM自己转码。3.2 二进制与大文件处理LoadFileToString会把整个文件读进内存。如果文件只有几 KB 到几 MB这没问题但你要读上百 MB 的日志、二进制数据一次性读进来很容易把内存吃满还会在加载时卡主线程。大文件就打开文件句柄分段读。UE 提供IFileHandle接口IFileHandle* Handle FPlatformFileManager::Get().GetPlatformFile().OpenRead(*FullPath); if (Handle) { int64 FileSize Handle-Size(); TArrayuint8 Buffer; Buffer.SetNum(64 * 1024); while (Handle-GetPos() FileSize) { int64 ReadSize FMath::Min((int64)Buffer.Num(), FileSize - Handle-GetPos()); if (!Handle-Read(Buffer.GetData(), ReadSize)) { break; } // 处理读取到的数据 } delete Handle; }OpenRead拿到的是IFileHandle*用完记得delete。也可以封装成TUniquePtrIFileHandle来管理生命周期避免忘记释放导致句柄泄漏。手动流式读取的场景在 UE 客户端里不多但写编辑器工具时经常用到比如批量处理大体积贴图或者 CSV 导出。3.3 编码与换行符的坑编码问题是最容易让人懵的。UE 的FString内部是 UTF-16LoadFileToString默认按 UTF-8 做转换。如果你的 JSON 或文本文件是 ANSI比如从旧工具导出读进来中文就全是乱码。一个稳妥的兜底方案读取字节数组后先按 UTF-8 解码失败再按默认编码解码。TArrayuint8 FileData; FFileHelper::LoadFileToArray(FileData, *FullPath); FString Content FString::FromUtf8((const char*)FileData.GetData(), FileData.Num());FString::FromUtf8在 UE4.19 可用UE5 当然也支持。转换失败时它会返回空串所以还要做好降级处理。还有一个容易被忽略的坑换行符。Windows 下 CRLF\r\n、Unix 下 LF\n解析文本时如果按单字符分割可能会带出\r。读取 CSV、INI 这类格式时最好先把\r去掉再按行处理Content.ReplaceInline(TEXT(\r), TEXT()); TArrayFString Lines; Content.ParseIntoArrayLines(Lines, true);ParseIntoArrayLines的第二个参数bCullEmpty传 true可以忽略空行省得解析时空行干扰。4. JSON 文件解析从磁盘到 FJsonObject4.1 读取并解析 JSON先说一下 JSON 解析工具链。引擎的 JSON 支持分两块底层FJsonSerializer/FJsonObjectJson 模块高层FJsonObjectConverterJsonUtilities 模块。只读解析、手动拿字段时底层就够用。一个最标准、最稳的读取流程#include Json.h #include Serialization/JsonReader.h #include Serialization/JsonSerializer.h #include Serialization/JsonWriter.h FString JsonString; if (!FFileHelper::LoadFileToString(JsonString, *FullPath)) { return; } TSharedRefTJsonReader Reader TJsonReaderFactory::Create(JsonString); TSharedPtrFJsonObject JsonObject; if (!FJsonSerializer::Deserialize(Reader, JsonObject)) { return; }解析成功之后JsonObject里就是整个 JSON 的根对象。之后通过GetStringField、GetNumberField、GetBoolField、GetArrayField、GetObjectField来取值。FString Name JsonObject-GetStringField(TEXT(name)); double Version JsonObject-GetNumberField(TEXT(version)); bool bEnable JsonObject-GetBoolField(TEXT(enable));这里强调一点get 系函数如果字段不存在会直接报错因为内部有 ensure。如果你不确定字段是否存在用TryGetField或者先HasField判断if (JsonObject-HasField(TEXT(name))) { FString Name JsonObject-GetStringField(TEXT(name)); } else { // 用默认值兜底 }或者用更省事的 TryGet 系列FString Name; if (JsonObject-TryGetStringField(TEXT(name), Name)) { // 读取成功 }TryGetStringField、TryGetNumberField、TryGetBoolField、TryGetArrayField、TryGetObjectField都存在。日常解析外部配置时我基本都用 Try 系列因为外部文件的格式你控制不了也不想让整个模块崩掉。4.2 嵌套结构与数组遍历JSON 里最常见的结构是数组套对象。比如{ weapons: [ { id: 1001, name: sword, damage: 10 }, { id: 1002, name: bow, damage: 8 } ] }解析const TArrayTSharedPtrFJsonValue Weapons JsonObject-GetArrayField(TEXT(weapons)); for (const TSharedPtrFJsonValue Weapon : Weapons) { TSharedPtrFJsonObject WeaponObj Weapon-AsObject(); if (!WeaponObj.IsValid()) { continue; } int32 Id (int32)WeaponObj-GetNumberField(TEXT(id)); FString Name WeaponObj-GetStringField(TEXT(name)); double Damage WeaponObj-GetNumberField(TEXT(damage)); }注意一个问题数组项是FJsonValue它是一个虚基类要转成对象得调AsObject()。当数组项本身是数字、字符串、布尔、对象、数组时对应的是不同的FJsonValue子类。AsObject()在类型不匹配时返回空指针所以一定要判空。嵌套对象类似{ player: { name: alice, pos: { x: 1.0, y: 2.0 } } }TSharedPtrFJsonObject PlayerObj JsonObject-GetObjectField(TEXT(player)); TSharedPtrFJsonObject PosObj PlayerObj-GetObjectField(TEXT(pos)); double X PosObj-GetNumberField(TEXT(x));逐层往下拿层级再多也扛得住。但手写这种嵌套遍历很容易在字段缺漏时崩因为你不知道上一步GetObjectField是否真的拿到了对象。稳妥做法还是逐级判空const TSharedPtrFJsonObject* PlayerPtr JsonObject-TryGetObjectField(TEXT(player)); if (PlayerPtr PlayerPtr-IsValid()) { TSharedPtrFJsonObject PlayerObj *PlayerPtr; // 继续往下 }4.3 数值、布尔、null 的坑JSON 解析有几个容易出错的小细节说细一点。第一个是数值类型。JSON 里的数字经过FJsonSerializer解析出来统一是 double。你看GetNumberField返回类型是 double拿整数时最好自己做一次转换int32 Id (int32)JsonObject-GetNumberField(TEXT(id));如果数字超过 32 位范围要小心溢出。比如 ID 用int64的话就得(int64)转。要是数字是浮点型3.7强转 int 会变 3如果业务上不允许先做精度判断。第二个是布尔值。JSON 布尔在底层是FJsonValueBooleanGetBoolField没问题。但如果字段类型是字符串trueGetBoolField是读不到的。要兜底的话可以先用FJsonValueConverter之类的转换或者自己写一个ParseFlexibleBool支持字符串和布尔两种格式。第三个是null。FJsonValueNull是 JSON null 的表现形式。HasField对 null 字段返回 true因为字段存在只是值是 null。但GetStringField遇到 null 会报错。所以解析时最好判断一下值类型const TSharedPtrFJsonValue Value JsonObject-GetField(TEXT(optional)); if (Value.IsValid() Value-Type EJson::Null) { // 是 null }如果配置项允许为空建议在 JSON 侧直接把这种字段省略而不是写成optional: null省得解析逻辑到处判断。5. 更省力的路FJsonObjectConverter 与其他读取方式5.1 结构体绑定解析手写GetStringField一层层取值数据量小还好字段一多很烦。这时候用FJsonObjectConverter::JsonObjectStringToUStruct直接绑定到 UStruct 上代码会清爽很多。先定义一个 USTRUCTUSTRUCT() struct FWeaponConfig { GENERATED_BODY() UPROPERTY() int32 Id 0; UPROPERTY() FString Name; UPROPERTY() float Damage 0.f; }; USTRUCT() struct FGameConfig { GENERATED_BODY() UPROPERTY() TArrayFWeaponConfig Weapons; UPROPERTY() FString Version; };然后一行转换FGameConfig Config; FString JsonString; FFileHelper::LoadFileToString(JsonString, *FullPath); FJsonObjectConverter::JsonObjectStringToUStruct( JsonString, Config, 0, 0);或者先FJsonSerializer::Deserialize拿到FJsonObject再用FJsonObjectConverter::JsonObjectToUStructTSharedPtrFJsonObject JsonObject; FJsonSerializer::Deserialize(Reader, JsonObject); FJsonObjectConverter::JsonObjectToUStruct(JsonObject.ToSharedRef(), Config);注意模块依赖FJsonObjectConverter在JsonUtilities模块里需要在.Build.cs里加上PublicDependencyModuleNames.AddRange(new string[] { Core, CoreUObject, Engine, Json, JsonUtilities });这个方案适合字段名与结构体属性名完全一致的 JSON。字段名不对应时可以给 UPROPERTY 加JsonSerializer相关元数据来自定义但那套机制又有点绕我一般情况下直接用结构体绑定加 JSON 端命名对齐。5.2 用 DataTable / UObject 资产读 JSON还有一条“伪读取”路线当你不太想做 C 解析只想把 JSON 当配置表用可以考虑把 JSON 转成 CSV 再配 DataTable。DataTable 本身在编辑器里就可以导入 CSV/JSON。运行时读取就是UDataTable* Table LoadObjectUDataTable(nullptr, TEXT(/Game/Data/MyTable)); FWeaponConfig* Row Table-FindRowFWeaponConfig(TEXT(1001), TEXT());这种方式只适合结构化行数据不适合多层嵌套的 JSON。而且 运行时直接LoadObject只能加载已经打包进进来的资产生成的 CDO 或者资源本身。和磁盘文件读取并不是同一回事别混着用。5.3 蓝图中读取 JSON说句实在话纯蓝图读 JSON 的感觉就是“能但很难受”。引擎蓝图侧没有直接展示FJsonObject的节点得把解析后的数据转成 Struct 或者 Map 才能看到。我的一个做法是写几个 C 函数暴露给蓝图封装“读文件解析 JSON转 Map”。比如UFUNCTION(BlueprintCallable, Category Json Util) static bool LoadJsonAsMap(const FString FullPath, TMapFString, FString OutData);这函数内部把 JSON 里的扁平字段全部转成字符串放进 TMap然后蓝图直接按 Key 取值。对配置类型的 JSON 够用。复杂嵌套 JSON 就不适合转 Map 了建议直接用结构体绑定或者写个专门的解析类。我个人的经验是JSON 解析逻辑放 C蓝图只做结果展示和简单逻辑分支。让蓝图去操作 JSON 对象树调试成本太高。6. 常见问题与排查技巧实录6.1 常见问题速查表症状原因对策LoadFileToString返回 false文件路径错误、编码不支持、打包后路径不对用FPaths::ConvertRelativePathToFull打印实际路径检查编码中文乱码文件是 ANSI/GBK 编码引擎按 UTF-8 读用字节数组读取后手动转换编码源文件转 UTF-8 无 BOM路径找不到用了硬编码路径或者打包后 Content 被 Pak 接管改用ProjectContentDir()/ProjectSavedDir()组合路径GetStringField崩掉或 ensure字段不存在 / 字段是 null先HasField判断或改用 Try 系列接口JSON 数组解析出来为空字段名写错、大小写不一致、数组项类型不对打印JsonObject到日志核对字段确认数组项是对象还是值编辑器里能读打包后读不到Pak 只读环境下文件访问受限若只是读配置考虑用ProjectSavedDir读取可写文件或把数据打进 Pak 用资产接口访问大量遍历文件夹很卡遍历在 GameThread 执行且文件数量大挪到异步线程用Async 回调回主线程读取大文件内存暴涨一次性 LoadFileToArray改IFileHandle分段读取文件写不进去目标目录是只读的 / 沙箱限制写入ProjectSavedDir这类可写路径并确认目录存在6.2 异步读取与线程问题文件读取尽量不要在 GameThread 上做。项目里有个功能是从磁盘读 100 多张图片合图一开始直接在主线程遍历读取编辑界面切换时掉帧明显。改异步后体验完全不一样。简单做法就是用AsyncAsync(EAsyncExecution::ThreadPool, [FullPath]() { FString Content; bool bOk FFileHelper::LoadFileToString(Content, *FullPath); Async(EAsyncExecution::TaskGraphMainThread, [bOk, Content]() { // 回到主线程更新 UI 或状态 }); });注意FFileHelper本身线程安全但读完后如果结果要传给 UObject 属性或者刷新 UI必须切回 GameThread。直接用Async(EAsyncExecution::TaskGraphMainThread, ...)有一个讲究它不一定等主线程 Tick某些时机下会卡不过大多数情况没问题。如果要求严格按帧执行建议用定时器或者 Latent action 来做回包。6.3 个人实操心得最后分享几个我踩过坑之后形成的习惯。第一先打印路径。凡是文件读取出了问题第一步用UE_LOG(LogTemp, Warning, TEXT(File: %s), *FullPath);把路径打出来再拿这个路径去系统资源管理器里查。很多时候你以为的路径和实际跑的路径根本不是一回事尤其是命令行启动、打包运行的时候。第二配置类 JSON 统一放一个目录。我在项目里习惯在 Content 下建一个Data文件夹专门放运行时配置。C 侧封装一个ConfigLoader类统一负责拼接路径、读取、解析和日志输出。所有模块要读 JSON 都走这个单例不要散落在各个模块里各写一套后期改路径策略时只要动一处。第三JSON 解析日志打在最关键的位置。外部配置格式不对时你不想让整个游戏崩掉。全局加一层ensure或UE_LOG把解析失败的原因输出实测下来定位配置问题快很多。第四小心 CRLF 和 BOM。如果 JSON 文件是从 Windows 记事本或旧编辑器导出的建议打开文件用十六进制看下前几字节。带 BOM 的 UTF-8 文件FJsonSerializer一般能扛住但有些老工具导出的 UTF-16 就麻烦了。统一约定用 UTF-8 无 BOM 保存所有工具链都按这个标准走能省掉一半的编码坑。第五写文件时先保证目录存在。SaveStringToFile不会自动创建目录。所以写存档之前最好先IFileManager::Get().MakeDirectory(*DirPath, true)第二个参数 true 表示递归创建多层目录。不然第一次写文件就是失败排查半天才发现目录根本不存在。UE 里的文件和 JSON 读写说到底就是怎么和引擎的文件抽象层、序列化层配合。路径规则和编码这两个地基打稳了后面不管用FFileHelper还是FJsonObjectConverter都很顺手。希望这篇东西能帮你少走点弯路至少遇到问题知道该往哪个方向查。