资讯动态

UE4 C++解析Json数组:从基础类型到嵌套结构实战指南

发布时间:2026/10/17 16:09:41 来源:尧图企业网站定制
做 UE4 开发的朋友早晚都会碰到这么一件事手里有一份 Json 数据可能是服务器下发的活动列表也可能是本地配表里的技能参数你想用 C 把它读进来把一个数组完整地解析出来——是对象数组还是字符串数组结构是平的还是嵌套的这些细节一不注意就翻车。我见过有人因为一个中文编码折腾一晚上也见过把对象数组当成普通数组强转然后直接崩溃的。这篇文章就从 UE4 里 C 读取 Json、解析 Json 数组这件事讲起把环境配置、读取姿势、三种不同难度的数组解析、可复用的封装置槽以及高频坑点全部过一遍适合刚接触 Json 模块的新手也适合想把手头解析代码写得更稳的朋友。1. 这块需求到底在解决什么问题1.1 哪些场景必须用 C 解析 Json很多人在蓝图里用 “Json Object” 相关节点也能解析但一旦数据量大、结构复杂、或者要嵌入到纯逻辑层比如数据表加载、网络协议解析蓝图那套就变得又慢又难维护。C 直接操作FJsonObject的好处是它能进模块化代码能放进异步线程能方便地跟项目里的结构体、容器、存档逻辑打通。具体来说这几类场景基本绕不开 C 解析 Json 数组网络接口回包里的列表数据比如排行榜、商品列表、任务列表本地配表/配置文件比如武器参数数组、关卡配置数组存档系统里用 Json 记录多个存档槽或者多条行为数据从外部工具导出的批量数据一次性导入工程。标题里特别提到“解析 Json 数组”是因为数组在实际数据里出现的频率比想象中高得多。一个 JSON 文件的根节点往往是对象但对象里面装的数据十有八九是数组。数组里面可能是字符串、数字、布尔值也可能是对象甚至对象里再嵌数组。不同形态解析方式完全不同这也是这篇文章的展开主线。1.2 解析 Json 数组的完整数据链路要理解 UE4 里 Json 解析先记住这一整条链路读取文件或拿到字符串 → 丢给TJsonReader→ 用FJsonSerializer反序列化成TSharedPtrFJsonObject→ 从对象里用GetArrayField或TryGetField拿出数组 → 遍历数组里的TSharedPtrFJsonValue→ 根据元素类型调用AsString/AsNumber/AsObject/AsArray取出真正数据。很多人只会中间两步拿到FJsonObject之后直接GetStringField一把梭结果遇到数组就懵了。其实核心思想非常简单FJsonObject是一个键值对容器数组在它内部是“一个键对应一个TArrayTSharedPtrFJsonValue”而FJsonValue是各种类型的包装器。理解了这两层剩下就是 API 熟练度的问题。1.3 为什么官方的 Json 模块够用不需要造轮子UE4 内置的Json模块虽然名字朴素但功能完全覆盖日常需求能解析、能生成、支持嵌套、支持数组、支持各种基础类型。很多新手觉得“内置的不专业”其实多虑了。游戏里常用的 Json 序列化场景引擎这套 API 都撑得住。你真正要做的是把Json和JsonUtilities两个模块加到工程依赖里然后熟悉那几个核心类。瞎折腾出第三方库纯属给自己找事引擎版本一升级还要跟着踩坑。2. 动手前先把环境配好模块与头文件2.1 Build.cs 里加依赖这一步虽然简单但错过的人不在少数。在项目的.Build.cs文件里把Json和JsonUtilities补进依赖列表PublicDependencyModuleNames.AddRange(new string[] { Core, CoreUObject, Engine, Json, JsonUtilities });Json模块提供FJsonObject、FJsonValue、FJsonSerializer这一整套解析核心JsonUtilities额外提供FJsonObjectConverter常用于 UStruct 和 Json 互转。如果你手写解析为主Json是必需的JsonUtilities属于“加上保险”的类型。不加模块直接 include 头文件编译期会报出一堆“找不到 XXX.h” 的错误这种问题排查起来很烦所以先把这个搞定。2.2 必须认识的三组核心类第一组是FJsonObject和FJsonValue。前者是键值对容器代表一个 JSON 对象后者是所有 JSON 值的包装基类。数组里的每个元素即使是个普通字符串也会被包装成FJsonValue存到数组里。第二组是FJsonSerializer和TJsonReaderTCHAR。FJsonSerializer是“翻译官”负责把文本字符串翻译成FJsonObject对象TJsonReader负责逐字逐句读入字符串过程中如果语法错误它会记录错误信息。第三组是TJsonWriterTCHAR。这是把FJsonObject写成字符串的工具虽然标题只说了“读取”但实际项目里读写往往是一对后面我会把小封装一并给出来。这几类都通过TSharedPtr管理生命周期不要想着手动 delete。理解这点很重要因为后面所有操作几乎都离不开TSharedPtr和.IsValid()判断。2.3 FString、TSharedPtr 和 UE_LOG 的配合先说FString。从文件读出来的 Json 文本是FString从网络回调拿到的响应体通常也是FString。FJsonSerializer接受的是FString所以路径天然通顺。但注意FString和标准库std::string不是一个东西别混用。跨平台代码里经常有人把std::string和FString乱赋值编译时各种崩溃还找不到原因。TSharedPtrFJsonObject在反序列化失败时可能是一个空指针所以每次解析完第一件事就是IsValid()判断。这个习惯会帮你躲开一半的崩溃问题。调试时的关键工具是UE_LOG。它可以直接打印FString配合*JsonString解引用输出内容。很多解析问题其实不是 API 写错而是数据格式和预期不一致打日志是最快的排查方式。3. 拿到 Json 字符串的四种常见姿势3.1 从项目文件读取最常用本地配置类 Json 一般放在Content目录下。用FPaths::ProjectContentDir()拼接文件路径再通过FFileHelper::LoadFileToString读出来FString FilePath FPaths::ProjectContentDir() TEXT(Data/config.json); FString JsonString; if (!FFileHelper::LoadFileToString(JsonString, *FilePath)) { UE_LOG(LogTemp, Error, TEXT(读取Json文件失败: %s), *FilePath); return; }这里有个容易看漏的点LoadFileToString的第一个参数是FString第二个参数是const TCHAR*所以文件路径要加*解引用。很多同学写的是FilePath而不是*FilePath编译报错后一脸茫然。如果文件不在Content下而是在项目根目录、Saved 目录或者绝对路径只要把FilePath换成你实际的位置即可读取代码不用改。唯一要注意的是打包后目录结构可能变化正式项目建议把路径抽象封装一下不要到处硬编码。3.2 从绝对路径或存档目录读取如果是存档类数据路径一般是FPaths::ProjectSavedDir()。这个目录在编辑器运行和打包后的位置不一样但引擎帮我们做了统一。比如FString SavePath FPaths::ProjectSavedDir() TEXT(Save/slot_data.json); FString JsonString; FFileHelper::LoadFileToString(JsonString, *SavePath);存档场景下还有一个常见需求读取失败时不直接报错而是走默认数据初始化。所以最好把“读取 解析 回退默认值”包在一起逻辑更干净。3.3 从网络回调或命令行参数读取如果你用的FHttpModule在响应回调里会拿到一个FHttpResponsePtr此时直接FString ResponseString Response-GetContentAsString();后面的解析逻辑和从文件读取完全一样。也就是说整条 Json 解析链路和“数据来源”解耦文件、内存、网络最终都汇合到同一个FJsonSerializer::Deserialize这一步。这也解释了为什么我们前面要花时间搞懂那三组核心类因为它们是所有场景的公因数。从命令行参数读取的场景比较少见但某些工具类项目会用比如测试程序里FParse::Value把-JsonDataxxx参数取出来再把字符串交给解析器。这类场景务必注意参数里的特殊字符转义问题命令行里的引号和反斜杠经常把 Json 搞成非法格式。3.4 编码问题别等到跑起来才后悔很多中文乱码问题都出在编码上。FFileHelper::LoadFileToString默认会尝试自动检测编码但无 BOM 的 UTF-8 文件有时候会被识别成别的编码导致中文全部变成乱码。我的建议是手动维护的 Json 文件统一保存为“UTF-8 with BOM”解析最稳如果拿到的是无 BOM 的 UTF-8 文件且出现中文乱码优先尝试用FUTF8ToTCHAR手动转码而不是在原字符串上硬碰服务器下发数据时确认服务端输出的是标准 UTF-8否则客户端解析必然踩坑。关于网络响应字符串GetContentAsString()一般按 UTF-8 处理但如果服务端响应头里的charset写错也可能出现乱码。这类问题一旦碰到直接把响应字节打到日志里看十六进制是定位是 UTF-8 还是 ANSI 的最快手段。4. 解析 Json 数组三个难度的递进实练4.1 平民版数组里全是基础类型假设数据长这样{ tags: [rpg, action, puzzle] }这是一个最普通的字符串数组。解析步骤分为两段先拿到整个对象再取tags字段#include Json.h #include Misc/FileHelper.h TSharedPtrFJsonObject JsonObject; TSharedRefTJsonReader Reader TJsonReaderFactory::Create(JsonString); if (FJsonSerializer::Deserialize(Reader, JsonObject) JsonObject.IsValid()) { const TArrayTSharedPtrFJsonValue Tags JsonObject-GetArrayField(TEXT(tags)); for (const TSharedPtrFJsonValue Tag : Tags) { FString TagName Tag-AsString(); UE_LOG(LogTemp, Log, TEXT(Tag: %s), *TagName); } }注意两点第一GetArrayField返回的是const TArrayTSharedPtrFJsonValue它内部持有的是引用或副本这个细节不纠结也行用const引用接收就好第二数组里每个元素调用AsString()之前要确认它确实是字符串。如果 Json 里写的是数字 123AsString()可能返回空字符串或者导致断言失败——这个坑在后面的排查表里会重点说。整数数组、布尔数组逻辑完全一样只是把AsString()换成AsNumber()或AsBool()。注意AsNumber()返回的是double转int32时自己做好取舍。4.2 日常版数组里全是对象这应该是实际项目里最常见的形态。比如服务器返回一批物品数据{ items: [ { name: 木头, count: 12, price: 3.5, enabled: true }, { name: 石头, count: 30, price: 1.2, enabled: false } ] }解析时遍历数组对每个元素先调用AsObject()得到TSharedPtrFJsonObject再从这个子对象里取字段const TArrayTSharedPtrFJsonValue Items JsonObject-GetArrayField(TEXT(items)); for (const TSharedPtrFJsonValue Item : Items) { const TSharedPtrFJsonObject ItemObj Item-AsObject(); if (!ItemObj.IsValid()) { UE_LOG(LogTemp, Warning, TEXT(数组元素不是有效对象已跳过)); continue; } FString Name ItemObj-GetStringField(TEXT(name)); int32 Count ItemObj-GetIntegerField(TEXT(count)); float Price ItemObj-GetNumberField(TEXT(price)); bool Enabled ItemObj-GetBoolField(TEXT(enabled)); UE_LOG(LogTemp, Log, TEXT(物品: %s, 数量: %d, 价格: %.2f, 上架: %d), *Name, Count, Price, Enabled); }这段代码里最重要的习惯是IsValid()判断。一个 Json 数组里混入了非对象元素比如数字或nullAsObject()会返回空指针不判断就直接解引用游戏瞬间崩溃。第二个细节是GetIntegerField和GetNumberField的区别。count在 Json 里写成12用GetNumberField得到的是12.0类型是 double隐式转 int32 没问题但如果price用GetIntegerField去取1.2 会被截成 1精度直接丢了。所以我的习惯是整数用GetIntegerField浮点一律用GetNumberField不要图省事混用。第三个细节是字段缺失问题。如果某条物品没有price字段GetNumberField会返回 0.0你可能没察觉就带上错误数据了。正确做法是先用HasField判断或者用TryGetField拿到FJsonValue*再判断类型if (ItemObj-HasField(TEXT(price))) { Price ItemObj-GetNumberField(TEXT(price)); }4.3 硬核版对象套对象、数组套数组项目稍微复杂一点就是章节、任务、配置这种多级结构。比如{ content: [ { id: 101, title: 第一章, sections: [ { type: text, body: 你好 }, { type: image, url: /tex/hello.png } ] } ] }这种结构用循环嵌套就能解但代码容易越写越长。我自己习惯单独写一个递归遍历函数或者至少把“取对象数组”这个动作抽成一个工具函数不然每层都要写一遍GetArrayField AsObject IsValid又啰嗦又容易漏判断。内层数组解析示例const TArrayTSharedPtrFJsonValue ContentList JsonObject-GetArrayField(TEXT(content)); for (const TSharedPtrFJsonValue ContentItem : ContentList) { const TSharedPtrFJsonObject ContentObj ContentItem-AsObject(); if (!ContentObj.IsValid()) { continue; } int32 Id ContentObj-GetIntegerField(TEXT(id)); FString Title ContentObj-GetStringField(TEXT(title)); const TArrayTSharedPtrFJsonValue Sections ContentObj-GetArrayField(TEXT(sections)); for (const TSharedPtrFJsonValue SectionValue : Sections) { const TSharedPtrFJsonObject SectionObj SectionValue-AsObject(); if (!SectionObj.IsValid()) { continue; } FString Type SectionObj-GetStringField(TEXT(type)); if (Type TEXT(text)) { FString Body SectionObj-GetStringField(TEXT(body)); // 处理文本段落 } else if (Type TEXT(image)) { FString Url SectionObj-GetStringField(TEXT(url)); // 处理图片段落 } } }嵌套解析核心是“先拆对象再拆数组再拆对象”这个循环。你只要记住数组元素 - AsObject() - 对象; 对象字段 - GetArrayField - 数组层层递推再复杂的数据也能解出来。写的时候给自己定一条规矩每个AsObject()后面必须跟着IsValid()判断哪怕你觉得当前数据绝对不会有错——因为数据源不受你控制服务器改个字段、策划配错一个值崩溃或异常就在一瞬间。4.4 一个小技巧先打印结构再写代码我在处理不熟悉的 Json 文件时从来不直接写正式解析代码。第一步永远是读取字符串然后一行UE_LOG把原始 Json 打出来配合格式化工具看清楚层级关系。这一步能避免很多“想当然写错字段名”的低级错误。如果 Json 很大打日志会很长可以只打印前几百个字符或者把 Json 片段复制到本地文件里查看。UE 的日志窗口支持多行折叠但太长的内容还是会影响阅读。个人习惯准备一个临时函数递归打印整个 JsonObject 的键名和值类型。这个工具我用了很久排查效率翻倍。5. 复用价值把解析逻辑封装成 JsonHelper5.1 为什么建议封装而不是到处拷贝项目初期大家拿到 Json 都是到处写Deserialize加GetArrayField三个月后项目里可能有二十处解析代码每一处处理边界条件的方式都不同有的判空了有的没判改起需求来简直是灾难。更现实的问题是如果引擎版本升级导致某个 API 弃用你需要在二十个地方改同样的调用。与其这样不如写一个轻量级JsonHelper把高频操作收敛成几个工具函数。团队其他人接到类似需求时直接调用哪怕不会深入引擎 Json API 也能快速落地。5.2 两个高频工具函数直接抄作业第一个是“带默认值的安全取字符串字段”FString GetFieldStringOrDefault(const TSharedPtrFJsonObject InObject, const FString FieldName, const FString DefaultValue) { if (!InObject.IsValid() || !InObject-HasField(FieldName)) { return DefaultValue; } return InObject-GetStringField(FieldName); }第二个是“取对象数组”TArrayTSharedPtrFJsonObject GetObjectArray(const TSharedPtrFJsonObject InObject, const FString FieldName) { TArrayTSharedPtrFJsonObject Result; if (!InObject.IsValid() || !InObject-HasField(FieldName)) { return Result; } const TArrayTSharedPtrFJsonValue ValueArray InObject-GetArrayField(FieldName); for (const TSharedPtrFJsonValue Value : ValueArray) { const TSharedPtrFJsonObject ObjectValue Value-AsObject(); if (ObjectValue.IsValid()) { Result.Add(ObjectValue); } else { UE_LOG(LogTemp, Warning, TEXT(Json数组元素 %s 中数组字段 %s 存在非对象元素已跳过), *GetFieldStringOrDefault(InObject, TEXT(name), TEXT(未知)), *FieldName); } } return Result; }这个封装的核心价值是“内部处理好空指针和类型错误”业务代码永远不需要再判断AsObject()返回是否有效。它让解析代码从十行变成一行TArrayTSharedPtrFJsonObject Items JsonHelper::GetObjectArray(JsonObject, TEXT(items)); for (const auto Item : Items) { FString Name JsonHelper::GetFieldStringOrDefault(Item, TEXT(name), TEXT()); }5.3 封装 Json 写回解析和生成配套读取之外实际项目里经常还需要把结构体写回 Json 存档。核心只有四步TSharedPtrFJsonObject RootObject MakeShareable(new FJsonObject()); TArrayTSharedPtrFJsonValue ItemArray; TSharedPtrFJsonObject ItemObject MakeShareable(new FJsonObject); ItemObject-SetStringField(TEXT(name), TEXT(木头)); ItemObject-SetIntegerField(TEXT(count), 12); ItemArray.Add(MakeShareable(new FJsonValueObject(ItemObject))); RootObject-SetArrayField(TEXT(items), ItemArray); FString OutputString; TSharedRefTJsonWriter Writer TJsonWriterFactory::Create(OutputString); FJsonSerializer::Serialize(RootObject.ToSharedRef(), Writer); FFileHelper::SaveStringToFile(OutputString, *FilePath);注意SetArrayField接受的参数是const TArrayTSharedPtrFJsonValue所以每个对象要先用MakeShareable(new FJsonValueObject(...))包装成FJsonValue再放进数组。这一套如果也封装进JsonHelper以后生成配置、写存档都能复用同一套模板。6. 实战问题快查表从解析失败到中文乱码6.1 五个高频问题与排查思路症状可能原因处理办法FJsonSerializer::Deserialize返回 falseJson 格式不标准比如有尾逗号、单引号、注释用编辑器格式化 Json去掉注释和尾逗号再试读取Reader-GetErrorMessage()定位位置读取文件失败路径不对或打包后目录结构变化检查FPaths拼接结果先打印完整路径确认文件真的存在于目标目录中文全部乱码文件编码不是 UTF-8 或没有 BOM保存为 UTF-8 带 BOM无 BOM 时尝试手动转码AsString()返回空数组元素类型不是字符串可能是数字或对象先用Value-Type判断类型再调用对应AsXxx崩溃在AsObject()之后数组里混入了非对象元素每次AsObject()后必须IsValid()判断或者用封装工具函数这里的核心排查思路是“先确认数据是什么再怀疑代码写错”。很多新手把代码改来改去最后才发现 Json 文件里字段名多了一个空格。建议所有拿到手的 Json 文本先原样打印一次用眼睛确认格式无误再进入解析。Reader-GetErrorMessage()是经常被忽略的好帮手。反序列化失败时它能给出具体是哪一行、哪个位置出了问题远比“Deserialize 返回 false”这种信息有用。我见过太多人卡在解析失败上却没人去看这个错误消息。6.2 UE_LOG 输出 Json 的几个注意点打印 Json 字符串时UE_LOG的格式化参数用%s变量要加*UE_LOG(LogTemp, Log, TEXT(Json内容: %s), *JsonString);如果 Json 里含有特殊字符比如换行和引号日志窗口会显示得比较乱。这时候不要慌这是正常现象。可以把 Json 写到临时文件再查看或者只打印关键字段而不是整段字符串。还有一点容易被忽略UE_LOG 对超长文本的显示可能被截断。调试大数据时与其打一长串 Json不如循环打印数组里的每个元素字段。这样日志既短又有针对性。6.3 用第三方工具生成测试数据的小建议平时开发建议手边常备一个 Json 格式化工具。我自己的习惯是先在编辑器里用插件格式化一遍确认结构无误后再复制到 UE 工程里测试。格式化工具还会高亮显示括号不匹配、尾逗号等问题基本上能排除掉一半的解析失败原因。如果你要构造测试数据也建议先从少量数据开始。三五个元素的数组能跑通后再压上几百条记录测性能。很多性能问题不是解析 API 本身造成的而是你没意识到“每次 GetArrayField 都会复制一份数组”—用引用接收能省下不少不必要开销。谈到性能还有个大原则大批量 Json 解析别放游戏主线程。比如存档读取、排行榜拉取这些动辄几十毫秒的操作放进AsyncTask或单独线程解析完成后再切回游戏线程使用数据体感会好很多。解析本身是纯内存操作线程安全上注意不要同时读写同一个FJsonObject就行。最后再分享一个我个人的习惯拿到一份要解析的 Json先不着急写大逻辑而是先写一个 20 行不到的“探查小工具”打印出整个结构树哪个字段是数组、数组里每个元素是什么类型、有没有空值。看清楚之后再动正式代码出错率至少降一半。这套方法我用在很多项目上效果一直很稳定也希望你能用得上。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑