ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

UE4运行时对象解析:UWorld、GNames与GObjectArray全解析

UE4运行时对象解析:UWorld、GNames与GObjectArray全解析 先纠正一下标题里的小笔误UE4 引擎里真正要拿的是 UWorld写 UWord 在社区里也能看懂但实际符号是 UWorld。这篇文章就把这条技术链路完整捋一遍从 UWorld 的获取到 GNames 名称池的读取再到 GetName 的解析机制最后落到 GObjectArray 全局对象表的遍历上。如果你是做引擎工具开发、资源审计、运行时内存分析或者想搭一个自定义对象浏览器这几个关键量会反复出现在你的代码里。我不会把这里的内容包装成什么高深莫测的逆向魔法。它其实就是 UE4 反射系统里非常常规的调试数据访问方式拿到 World 上下文读全局对象表把 FName 索引解析成可读字符串。前提是你有权访问要分析的工程或二进制。下面所有代码都优先走引擎自带的反射接口只有确实需要深入全局表时我才会手写访问逻辑。1. 先把全局对象体系看成一张地图1.1 UWorld、GNames、GetName、GObjectArray 分别是什么第一次看到这四个词的人很容易被吓到其实它们各干各的角色非常清晰。UWorld是 UE4 场景的最高层容器。一个运行中的游戏通常会有至少一个 World里面装着 PersistentLevel、当前 Level、GameMode、Pawn、Controller 这一类东西。做运行时分析时World 是天然的起点你从 World 可以往下拿到 Level从 Level 可以拿到 Actors从 Actors 可以拿到 Component整棵对象树就展开了。GNames是全局名称池。UE4 为了节省字符串比较的开销没有直接保存字符串而是给每个名字分配一个整数索引。所有唯一字符串都放在一个全局池子里这个池子在老版本里叫GNames新版本里叫NamePoolData但社区叫顺口了一直沿用 GNames 这个名字。它解决的问题是程序运行时要想判断两个 FName 是否相等只比对索引就行完全不用碰字符串。GetName是一个方法严格说不是全局变量。它存在于UObject上返回当前对象的名字。但这个名字不是直接存在对象里的而是通过对象的FName去 GNames 里反查出来的。所以 get Name 本质上是个翻译动作把FName索引翻译成人能读懂的字符串。GObjectArray是全局对象数组也是全局对象表。UE4 里所有 UObject 实例创建之后都会往这张表里注册一个条目。反过来说只要遍历这张表你就能知道当前进程里到底有哪些对象活着。运行时的 GC、对象查找、类型过滤全都建立在 GObjectArray 之上。这四个东西合起来就是一套完整的运行时自省链先借助 GObjectArray 枚举或定位 UObject再用 GetName 得到可读名字再通过 UWorld 的层级关系把零散对象纳入场景结构里。理解了这条链路后面写工具时思路会很清楚。1.2 这四个量会在什么场景里集中出现最常见的场景是对象浏览器和资源审计。比如你在做项目优化怀疑某个关卡卸载之后还有资源没释放。你不可能靠肉眼盯着内存看最直接的办法就是把当前 UWorld 里所有对象导出来按 Class 分组统计有多少 Mesh、多少 Texture、多少 Actor。这时候就需要先拿到 UWorld再遍历 GObjectArray然后用 GetName 和 GetClass 去区分谁是谁。另一个典型场景是热更新或者资产打包工具的校验。工具需要扫描一个 World 里引用了哪些外部资源把引用关系导成报告。UE4 的FObjectDependency相关接口能帮上忙但你自己要写一个简易版也只能回到 UWorld 和 GObjectArray 这套体系上。还有一个场景是独立分析程序。你不想启动整个游戏编辑器只想加载一个很小的 UE4 程序然后通过命令行参数把某个 World 加载进来一边跑一边输出对象列表。这种工具本质上就是靠 GEngine 拿 World靠 GUObjectArray 拿对象靠 GetName 得到报表。2. 获取 UWorld 的常规路径与 WorldContext 机制2.1 最稳妥的做法从 GEngine 找 WorldContext在绝大多数情况下我不会直接读GWorld这个全局指针因为它并不是在所有模块都能随意使用。UE4 官方其实建议你通过GEngine来获取 WorldContext。FWorldContext是引擎管理多个世界实例的上下文结构编辑器里同时存在编辑器世界和 PIE 世界就是靠 WorldContext 区分的。简单起一个获取当前游戏世界的函数#include Engine/Engine.h #include Engine/WorldContext.h UWorld* GetCurrentGameWorld() { if (GEngine nullptr) { return nullptr; } const TIndirectArrayFWorldContext WorldContexts GEngine-GetWorldContexts(); for (const FWorldContext Context : WorldContexts) { if (Context.WorldType EWorldType::Game || Context.WorldType EWorldType::PIE) { if (Context.World() ! nullptr) { return Context.World(); } } } return nullptr; }这段代码优先返回 Game 或 PIE 类型的世界避开编辑器世界。运行时插件里用这个方法很稳因为 GEngine 一般都已经初始化完成。需要注意一点在客户端和服务器分离的项目里一个进程里可能出现多个 WorldContext拿到的 World 未必是你想要的那个。客户端模式下通常有一个 Game 类型的 World服务器也有一个 NetGame 或 Game 类型的 World。具体过滤条件得看项目架构不能只认 WorldType。2.2 WorldContext 版本差异UE4 早期版本里FWorldContext的取世界对象方式可能不太一样。老代码里我见过直接用Context.World这个字段的如果你手里是 4.2x 以后的源码看到的是Context.World()成员函数。如果你在编译时遇到World字段不存在的报错说明代码版本偏新把字段访问改成函数调用就行。还有一个实际经验GEngine-GetWorldContextFromGameViewport(GEngine-GameViewport)在编辑器某些状态下手抖会返回空指针比如刚好处于关卡加载中途或者 Viewport 还没创建完。这种情况下我一般会退回遍历GetWorldContexts()因为上下文列表至少是稳定的最多只是没有匹配项。2.3 从任意 UObject 反推 UWorld有时候你手上只有一个UObject*并且你确定它属于某个场景但你没有直接保存 World 指针。比如你遍历了 GObjectArray拿到一组 Actor 和 Component你想知道它们属于哪个 World就可以沿着 Outer 链一直往上找。UObject::GetWorld()的内部逻辑就是这个思路。它会一层一层向上走先看对象是不是 Level再看是不是 World再看是不是带有 World 的外层对象。一般情况下你直接调对象的GetWorld()就够了。但如果是类默认对象、CDO 或者编译期生成的临时对象GetWorld()可能返回 nullptr因为它们在概念上不属于某个具体世界。UWorld* GetWorldFromObject(UObject* Obj) { if (Obj nullptr) { return nullptr; } return Obj-GetWorld(); }如果你想要更可靠的版本也可以直接用模板函数方式向上遍历UWorld* GetWorldSafe(UObject* Obj) { if (Obj nullptr) { return nullptr; } UObject* Outer Obj; while (Outer ! nullptr) { if (UWorld* World CastUWorld(Outer)) { return World; } Outer Outer-GetOuter(); } return nullptr; }这段相当于把GetWorld()的内部逻辑用更粗暴的方式做了一遍。好处是不依赖引擎版本坏处是会多几次 Cast 开销。在分析工具里无所谓在热路径上就要小心。2.4 编辑器 PIE 模式的坑如果你在编辑器窗口里测试或者写的是 Editor Utility 插件容易遇到一个经典问题拿到的 World 是编辑器世界而不是 Play 世界。PIE 之后GEngine里面会同时存在两个 WorldContext一个是原来编辑器的世界一个是新的 PIE 世界。调试对象浏览器如果把两个世界都遍历一遍会看到大量重复对象而且编辑器世界里只有编辑用数据没有运行时状态。我的习惯是先用Context.WorldType EWorldType::PIE过滤同时检查Context.PIEInstance或者Context.PIEInstanceName只导出你正在运行的那一个实例。有时候项目同时开了多个 PIE 窗口每个窗口对应一个不同的 PIEInstance。这时就要在 UI 里提供一个下拉框让用户选择具体实例不能只拿第一个。3. GNames 名称池与 GetName 的底层读取3.1 FName 内部结构DisplayIndex 和 NumberUE4 的FName并不直接存储字符串它只保存两个关键整数一个是名称索引一个是 Number。索引用来定位名称池里的字符串Number 用来区分同名对象比如一个 Level 里有两个同名 Actor引擎会自动给其中一个加后缀这个后缀信息就存在 Number 里。在 4.x 后期版本FName内部有两个索引字段ComparisonIndex和DisplayIndex。简单理解ComparisonIndex用于相等性快速比较DisplayIndex用于真正显示的时候到名称池里取字符串。两者大多数时候指向同一个 FNameEntry但也可以不同。如果你手工去读名称池最好用DisplayIndex这样拿到的显示名才是最终 UI 上看到的那个。如果是更早的版本FName里只有一个 Index 和一个 Number结构更简单。社区里流传的很多老代码默认只读一个 Index拿到新版项目上就会出问题。所以我每次写分析工具都会先看FName的定义连这个细节都不愿意查的人后面排查乱码会非常痛苦。3.2 老版本 GNames 数组布局早期的名称池实现是TNameEntryArray本质是一个很大的索引数组或块状数组每个下标对应一个FNameEntry。FNameEntry结构大致长这样// 这是示意结构具体以特定版本源码为准 struct FNameEntry { int32 Index; // 自身索引 FNameEntry* HashNext; // 哈希链 TCHAR Name[NAME_SIZE]; // 早期版本是可变的这里只做概念展示 };要拿到名字核心操作是FString GetNameFromIndex(int32 NameIndex) { static TNameEntryArray GlobalNames GNames; if (!GlobalNames.IsValidIndex(NameIndex)) { return FString(TEXT(InvalidName)); } FNameEntry* Entry GlobalNames[NameIndex]; if (Entry nullptr) { return FString(TEXT(NullEntry)); } return FString(Entry-GetName()); // 老版本里可能是 FString(Entry-Name) }老版本名称池的管理方式类似哈希表加数组。每一个 FNameEntry 在数组里占用一块固定大小索引就是 FName 里的值。这套结构简单粗暴缺点是不够灵活字符串空间会浪费所以后来新版本换成了分块名称池。3.3 新版本 NamePoolData 分块名称池UE4 后期和 UE5 里GNames保留为一个宏实际指向的是FNamePool。它不再是一个简单的数组而是由多个FNamedBlock组成每个块管理一段字符数据按块分配内存。这种设计的核心好处是避免单个大数组的连续内存压力。字符串会像水流一样写进不同的块里块本身不需要删除整体只增不减。对引擎来说FName 本来就基本是常量级别的生命周期设计删除单个名称并不重要名称池整体撑着内存就行。我们做分析工具时通常不需要手动实现这种分配逻辑。你要读的只是最终字符串。UE4 提供了FName::ToString()和FName::GetPlainNameString()这类公开接口足够用。只有当你在做外部解析或者在没有链接引擎模块的独立程序里分析内存转储时才需要重新实现块遍历算法那已经不是普通调试工具层面的事而是专门的转储解析工具了。3.4 用 GetName 不要自己硬解析能直接用GetName()就尽量不要去手抄 FNameEntry 结构。原因有两个一是版本兼容不同引擎小版本之间 FNameEntry 的布局确实有可能变化你辛辛苦苦调好的偏移引擎升级一次就可能崩掉二是线程安全名称池内部有锁你手动读的时候如果不小心并发环境下会读到半截字符串。在合法调试插件里我通常这么用FString GetObjectName(UObject* Obj) { if (Obj nullptr) { return TEXT(None); } return Obj-GetName(); }GetName()本质上去调用了 FName 的相关接口把GetDisplayIndex()对应的字符串取出来。如果想要完整的路径信息用GetFullName()或者GetPathName()它们会把 Class 名、Outer 链、对象名都拼出来非常方便用来做输出报表。FString Full Obj-GetFullName(); FString Path Obj-GetPathName();GetFullName()的结果类似StaticMesh /Game/Mesh/SM_House.SM_House第一段是类型名后面是整个路径。GetPathName()就是纯路径没有类型。实际调试时这两个函数能解决 80% 的可读性问题。4. GObjectArray 对象遍历与过滤4.1 引擎自带接口GetObjectsOfClass 和 TObjectIterator如果只是想拿一批符合条件对象先别急着去读全局表。UE4 官方提供的GetObjectsOfClass和TObjectIterator就是干这个的代码简洁版本兼容性也更好。GetObjectsOfClass的常规用法#include UObject/UObjectGlobals.h #include UObject/UObjectArray.h TArrayUObject* FoundObjects; GetObjectsOfClass(UWorld::StaticClass(), FoundObjects, true); for (UObject* Obj : FoundObjects) { UWorld* World CastUWorld(Obj); if (World ! nullptr) { UE_LOG(LogTemp, Log, TEXT(Found World: %s), *World-GetName()); } }这个函数会把所有匹配 UWorld 类型及其子类的对象找出来。最后一个参数是bIncludeDerivedClasses表示是否包含派生类。一般我传 true因为有时候 World 会被子类化你只查基类会漏人。TObjectIterator适合写在 C 循环里边遍历边处理不用提前收集数组#include UObject/UObjectIterator.h for (TObjectIteratorAActor It; It; It) { AActor* Actor *It; FString ActorName Actor-GetName(); // 处理逻辑 }这个迭代器是线程敏感的建议只在 GameThread 上使用。它内部会跳过一些 pending kill 的对象所以做统计时不会污染结果。4.2 手动读 GUObjectArray 的场景什么时候必须手写遍历 GUObjectArray大概有两种情况一是引擎自带接口满足不了性能要求你希望自己在内存里做更细粒度的过滤和排序二是你在做外部独立解析不依赖引擎反射调用这种通常需要把 GUObjectArray 的结构对接到自己的解析器里。引擎内部全局对象是FUObjectArray类型的GUObjectArray。手动读取时第一不要缓存 Entry 指针因为 GC 移动或删除都可能让指针失效第二不要小看序列号FUObjectItem里带序列号主要用于区分对象是否被复用。下面是一段典型的枚举代码#include UObject/UObjectArray.h void IterateAllUObjects() { const int32 NumObjects GUObjectArray.GetObjectArrayNum(); for (int32 Index 0; Index NumObjects; Index) { FUObjectItem* Item GUObjectArray.IndexToObject(Index); if (Item nullptr || Item-Object nullptr) { continue; } UObject* Obj Item-Object; if (Obj-IsUnreachable()) { continue; } FString Name Obj-GetName(); UClass* Class Obj-GetClass(); UE_LOG(LogTemp, Log, TEXT([%d] %s : %s), Index, *Class-GetName(), *Name); } }这段代码就能实现“把内存里所有 UObject 都捞出来”的需求。它比GetObjectsOfClass少一层封装能拿到对象数组里更原始的信息但代价是你要自己处理过滤条件比如跳过IsUnreachable、跳过PendingKill、跳过RF_ClassDefaultObject等等。4.3 手动枚举时如何避免越界和空指针对象数组在遍历过程中最怕 GC。严格来说在 GameThread 上正常遍历时 GC 不会同时发生因为 GC 也要求暂停在安全点。但当你的工具不是纯同步执行比如你开了异步任务或者编辑器后台还在大量加载资源就要格外小心。我的处理思路是过滤要分层。第一层用IsValid判断对象是否还活着第二层用GetClass()判断是否可用第三层再取名字。不要直接拿 Object 指针丢进另一个线程后再判断对象随时可能被 GC 收走。要在当前安全区域内完成引用保护比如用TStrongObjectPtr包一层或者立刻把需要的信息复制出来。另一个容易踩坑的是GetObjectArrayNum()返回的数量并不是固定不变的。遍历过程中如果新加载了对象数组会变大所以循环最好先取一个快照或者在循环开头重新取长度。不过更稳妥的方式是直接使用TObjectIterator它对数组变动和 GC 状态做了保护大多数分析场景完全够用。4.4 GC 状态、PendingKill 与序列化标志不同版本的 UE4 里判断对象是否有效的宏一直在变。老项目里常见obj-IsPendingKill()后来变成IsValid(obj)再后来内部又加了FUObjectItem的PendingKill标志位。写工具时要根据引擎版本选择否则会编译不过。一个相对通用又安全的判断是bool IsUObjectUsable(UObject* Obj) { if (Obj nullptr) { return false; } if (Obj-IsUnreachable()) { return false; } if (!IsValid(Obj)) { return false; } return true; }如果你的目标是统计线上资源泄漏多一层过滤并不吃亏。宁可少统计几个临时对象也不要因为访问了已销毁对象而崩溃。5. 把链路串起来写一个运行时对象转储小工具5.1 这个工具要解决什么问题前面讲的都是一块块的知识点实际做工具时要把它们串成一条流水线。我这里的例子比较实用写一个 UE4 编辑器菜单命令点击之后把当前 PIE 世界的所有 UObject 按 Class 分组导出成 CSV同时输出每个对象的名字和路径方便资源审计。工具流程就四步找到当前运行的 World。初始化一个空字典Key 是 Class 名Value 是对象详情列表。遍历 GObjectArray 或使用 GetObjectsOfClass 拿对象。对每个对象调用 GetName、GetClass、GetPathName拼成一行 CSV 输出。5.2 核心代码实现在 Editor 模块里注册一个菜单项点击后调用下面的函数#include Engine/Engine.h #include UObject/UObjectGlobals.h #include UObject/UObjectArray.h #include Misc/FileHelper.h void DumpWorldObjectsToCsv(const FString OutputPath) { UWorld* World nullptr; const TIndirectArrayFWorldContext Contexts GEngine-GetWorldContexts(); for (const FWorldContext Context : Contexts) { if (Context.WorldType EWorldType::PIE || Context.WorldType EWorldType::Game) { World Context.World(); break; } } if (World nullptr) { UE_LOG(LogTemp, Error, TEXT(No world found. Abort dump.)); return; } TArrayUObject* Objects; GetObjectsOfClass(UObject::StaticClass(), Objects, true); FString Output; Output TEXT(Class,Name,Path,World\r\n); for (UObject* Obj : Objects) { if (!IsValid(Obj)) { continue; } FString ClassName Obj-GetClass()-GetName(); FString ObjName Obj-GetName(); FString PathName Obj-GetPathName(); FString WorldName World-GetName(); Output FString::Printf(TEXT(%s,%s,%s,%s\r\n), *ClassName, *ObjName, *PathName, *WorldName); } FFileHelper::SaveStringToFile(Output, *OutputPath, FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM); UE_LOG(LogTemp, Log, TEXT(Dumped %d objects to %s), Objects.Num(), *OutputPath); }这段代码没有特别复杂的地方但把前面所有知识点都收进来了用 WorldContext 拿 World用 GetObjectsOfClass 拿对象用 GetName 和 GetPathName 生成报表最后写入 CSV。5.3 输出结果怎么分析打开生成的 CSV 后我们可以在 Excel 或脚本里分组统计。一个明显有用的分析维度是 Class 名分布。我可以直接按 Class 列做数据透视表就能看到当前场景里有多少 Actor 子类、多少 StaticMesh、多少 Texture。有些对象从逻辑上看不应该在运行时出现却出现在列表里这就是潜在的内存泄漏信号。再一个分析维度是路径规律。如果对象的 PathName 里没有包含当前 World 的路径说明它可能不是当前场景的对象而是编辑器资源、临时生成对象或者某个外挂模块持有的对象。这种对象如果在运行时数量持续增长往往就是没被正确释放的那批。5.4 工具的性能和稳定性优化当对象数量到几十万的时候直接拼接 FString 会非常慢内存也涨得快。我建议先输出到TArrayFString最后一次性 Join或者直接按行写入文件句柄不要在一开始就构造超大单字符串。另外一个排查点GetObjectsOfClass(UObject::StaticClass(), ...)会把所有 UObject 子类都收进来包括大量的 CDO 和类对象。如果你只想看场景里的实例过滤条件要更严格比如排除RF_ClassDefaultObject和RF_ArchetypeObject。GetObjectsOfClass(UObject::StaticClass(), Objects, true, RF_NoFlags, EInternalObjectFlags::None);这里有两个标志位参数可以传RF_ClassDefaultObject作为ExcludeFlags上面示例没有体现。如果你需要排除 CDO可以写成这样GetObjectsOfClass(UObject::StaticClass(), Objects, true, RF_ClassDefaultObject, EInternalObjectFlags::None);但要注意RF_ClassDefaultObject是 EObjectFlags 枚举值作为ExcludeFlags传参完全合理。这样 CDO 就不会出现在结果里分析结果更贴近运行时实例。6. 常见问题排查与版本踩坑6.1 常见问题速查表问题现象可能原因解决思路获取 World 返回空指针GEngine 还没初始化或当前没有 Game/PIE 上下文等模块初始化完成后再调确认编辑器处于 PIE 状态遍历对象时崩溃对象被 GC 回收或指针本身无效增加 IsValid 判断不要跨线程访问对象对象名字是乱码FName 索引拿错或版本不匹配检查 DisplayIndex/Number 的读取方式不要直接抄老代码输出 CSV 出现重复对象编辑器世界和 PIE 世界都遍历了严格过滤 WorldType按 PIEInstance 区分编译报错找不到 GUObjectArray头文件或模块依赖不对确认包含UObjectArray.h并链接 CoreUObject 模块对象数量比预期少过滤条件太严格或 CDO 被排除调整 ExcludeFlags检查 GC 状态新版 NamePool 解析不出来名称池结构已经不是数组用引擎公开 API 获取字符串或自行实现分块解析这个表基本是我在调试工具开发过程中翻车经验的总和。每次遇到诡异问题我都会先回到版本兼容性上找原因而不是去怀疑逻辑写错了。大多时候问题的根源就是新旧引擎结构不同。6.2 模块加载时机的问题很多刚上手的人会在模块 Startup 阶段直接去拿GEngine-GetWorldContexts()这是比较容易踩空的。原因很简单模块 Startup 时引擎系统可能还没有完成真正的世界初始化GameWorld 根本不存在。如果做一个编辑器菜单工具建议把实际分析逻辑放到菜单点击命令里面而不是模块加载时自动执行。点击菜单时引擎已经完成加载World 基本就位这时候拿到的上下文才有意义。如果是纯命令行的独立工具等引擎初始化完成后再进入主循环分析。6.3 GetFullName 和 GetPathName 的区别要分清写报表的时候最容易用错的是这两个GetFullName()包含类型名比如StaticMesh /Game/Meshes/SM_Cube.SM_CubeGetPathName()不包含类型名比如/Game/Meshes/SM_Cube.SM_Cube大多数分析场景里我推荐两个都保留因为 CSV 里多一列总是好的。如果你需要按类型统计用 GetFullName如果你只需要看资源路径用 GetPathName。还有一个小技巧Obj-GetPathName(World)可以沿着 Outer 链输出相对 World 的路径这样输出结果里不会带很长的 World 前缀读起来清爽很多。比如直接传 World 进去输出就会变成PersistentLevel.ActorBP_00这样的相对路径。6.4 关于 GNames 手动解析的最后提醒如果你还是想自己写 GNames 的解析逻辑我的建议是加上版本判断宏。UE4 老版本用TNameEntryArray新版本用FNamePool两种结构完全不同用错就会有大量乱码和数组越界。在独立分析工具里我通常会先做一次启发式校验从 GNames 里的某几个已知索引取字符串判断读出来的字符串是否像是合法路径。比如包含/、.、_这些字符且长度在合理范围。如果读出来一堆方框和乱码立刻停止后续操作不要拿着错误数据继续分析。这种经验我踩过不少次。尤其是当你给不同版本的项目做通用工具时不可能只靠一套结构吃遍所有版本。把你的解析层抽象出来做好版本适配远比临时写死索引偏移靠谱得多。最后再分享一个个人习惯我写这类分析工具时一定会先做一个“只读不写”的最小版本确认枚举结果稳定后再增加导出、统计、增量对比这些功能。因为一旦涉及写文件或修改数据出错的排查成本就会立刻翻倍。像 GNames、GObjectArray 这类引擎全局量本来就是给引擎自己维护的我们在工具层面去访问它最大的安全边界就是保持只读。先保证不崩、不乱再谈效率。
返回列表