ARTICLE DETAIL

资讯详情

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

UE4引用查看器数据源:AssetRegistry依赖图与包体瘦身避坑

UE4引用查看器数据源:AssetRegistry依赖图与包体瘦身避坑 前阵子在做一次包体瘦身我盯着一个明明只在设置界面里露过一次脸的图标资产想搞清楚它到底被谁拖进了包里。做法很直接右键资产打开 UE4 的引用查看器ReferenceViewer顺着引用链一路往上爬结果爬到一半发现链路跟我预想的完全对不上——图里断掉的地方编辑器里明明有人拿着它。那一刻我才意识到我对这个工具的理解一直是错的我一直默认它是扫磁盘找引用实际上它只是一张画出来的图真正的数据来自资产注册表AssetRegistry。把这件事搞清楚之后很多以前觉得玄学的现象一下子都通了为什么有些引用明明存在却搜不到、为什么改完名字图里还挂着一条老边、为什么打包之后同一份资产在图里的关系变了。这篇就把我翻 ReferenceViewer 模块源码的过程完整写一遍从界面入口一路追到内存里的那张依赖图中间顺手带上几个可以自己跑的验证脚本。适合已经会用引用查看器、但被它的结果坑过的人看如果你还没用过这个工具看完至少能知道什么时候不该信它。1. 引用查看器其实是个只读前端数据源头在资产注册表1.1 这个工具到底在回答什么问题先把它的能力边界说清楚。引用查看器回答的是一个非常具体的问题在资产注册表的记录里这个资产或这个包和谁之间有一条边。注意主语是注册表的记录不是磁盘上的文件内容。这两者的差距就是后面所有坑的来源。界面上你看到的东西大致分几块中间的图节点是资产或包连线是依赖方向、左上角的 Filter 面板控制显示哪几类引用、要不要显示引擎内容/插件内容、搜索深度和广度、底部的面包屑当前选中的资产路径、以及一套前进后退的历史记录。双击节点可以在内容浏览器里定位右键可以重新以某个节点为根重新展开。这些功能里只有画图和过滤是引用查看器自己的活。排序、去重、判断某个节点是不是引擎内容、是不是 Level、是不是蓝图这些都可以在拿到数据之后靠字符串和元数据判断但谁引用了谁这条最核心的信息它一条都算不出来只能去问别人。我一开始的误解就在这儿我以为打开这个窗口会触发一次全盘扫描。实际不是。窗口打开的速度那么快是因为它要的数据早就已经躺在内存里了。1.2 从 Tab 到内存一条完整的取数链路把调用链拉直了看是下面这样从用户点击到最终拿到一堆 FName一共经过五层层级关键类型 / 函数职责入口FReferenceViewerModule::OpenReferenceViewerUI接收一组FAssetIdentifier拉起工具窗口界面SReferenceViewer基于SGraphEditor渲染图、响应交互、管理历史图模型UEdGraph_ReferenceViewer/UEdGraphNode_Reference把数据组织成节点和连线供 Slate 画出来取数FReferenceViewerGraphBuilder真正去问注册表的那个家伙数据源IAssetRegistry::GetReferencers/GetDependencies从FAssetRegistryState里捞出依赖边你从界面上看到的一切关系最终都能归到第四层的那两个函数调用上。图模型层几乎是傻瓜式的——RecursivelyConstructNodes拿到一批引用者给每个引用者建一个节点然后判断要不要继续往深里递归。它不做任何文件 IO不打开任何包也不解析蓝图字节码。所以如果你把编辑器关掉、只留一个打包后的工程跑游戏是打不开这个窗口的——它依赖的是编辑器环境里的注册表实例而不是运行时的包加载器。这也解释了另一个常见疑问为什么引用查看器里的引用关系和运行时LoadPackage实际拉起来的包不是一一对应的。1.3 把这个前提记住能省掉很多误判理解它是前端不是扫描器这件事价值不在于炫技而在于决策。做包体瘦身的时候最典型的错误决策就是在引用查看器里搜了一圈没搜到任何硬引用于是判定这个资产没被用可以删。但没搜到可能只是因为这条边是软引用你没打开软引用开关这条边根本没被注册表记录比如运行期用字符串拼路径加载这条边在注册表里但因为你开了只显示引用者或者深度设成了 1被过滤掉了这条边指向的是重定向器而重定向器的名字和真实资产不一样。这四种情况在界面上长得一模一样都是一片空白。下面的章节会逐个拆开讲先把数据从哪来这件事彻底讲透。2. 顺着源码往下走那批引用者数据是怎么被捞出来的2.1 OpenReferenceViewerUI入口比想象中简单FReferenceViewerModule::OpenReferenceViewerUI的参数就是一个TArrayFAssetIdentifier加上一堆可选的 flag比如要不要把当前打开的资产作为根、要不要强制刷新。它做的事情主要有三件创建或复用SReferenceViewer这个 DockTab、把传进来的资产列表塞给底层的图模型、然后调一次重建。这里有个细节值得留意FAssetIdentifier这个类型既能表示一个包只有 PackageName也能表示一个包里的某个资产PackageName AssetName甚至还能表示一个可搜索名字SearchableName比如 GameplayTag 之类的字符串引用。引用查看器接受的其实是这个泛化的标识而不是简单的路径字符串。这一点直接决定了后面能查到什么粒度的边。顺便说一句历史记录。FReferenceViewerHistoryManager负责前进后退它记录的是根节点集合的变化而不是图的变化。所以你按后退的时候整张图是从头重新构建的不是从缓存里翻出来的一张旧图。这一点在排查问题的时候有实际意义后退之后看到的图可能和你第一次看到的不完全一样因为注册表在这期间可能发生了增量变化。2.2 RecursivelyConstructNodes取数的核心就这一个函数真正干活的函数在UEdGraph_ReferenceViewer里叫RecursivelyConstructNodes。它的逻辑骨架大概是传入当前要处理的资产集合、一个是否是依赖方向的标志true 表示往右展开依赖false 表示往左展开引用者对集合里每个资产调注册表拿它的引用者或依赖者列表把返回的每个标识过滤一遍引擎内容插件内容软引用搜索深度够不够没见过的建FReferenceViewerNode见过的复用并加一条连线如果还没到深度上限把这一批新节点作为下一轮的输入继续递归。写得很直白就是一次广度优先的图遍历。整个函数里没有任何IFileManager、FPackageReader之类的调用——这是最有力的证据证明数据不是从这里扫出来的。有个实现细节挺有意思节点是先查重再创建的。它内部维护了一个从FAssetIdentifier到节点的映射所以同一个资产在图上只会有一个节点多个人引用它就会有多条线汇过去。这解释了为什么图上看不出A 引用 B 三次这种信息——它去重了。如果你需要知道引用次数这个工具帮不了你。2.3 两层节点结构为什么有时候图会卡FReferenceViewerNode和UEdGraphNode_Reference是两层不同的东西前者是取数层的数据载体后者是图编辑器的 UObject 节点。每建一个UEdGraphNode_Reference都会产生一个 UObject展开一个引用关系复杂的根节点几百上千个 UObject 是常规操作。这就带来了一个实际影响深度开大 根节点选得太靠上 编辑器卡住甚至 OOM。我踩过一次选了个引擎里被到处引用的基础材质深度设成 10编辑器直接卡了将近一分钟。后来我的习惯是先用浅深度确认方向再挑一个具体的、引用关系收敛的节点往下钻。另外要注意节点上的体积数字。部分版本里这个值是来自注册表里的包级元数据磁盘体积字段而编辑器环境下这个字段经常是 0 或者不准——它更多是在打包、有了 cook 数据之后才有意义。所以别拿引用查看器上的体积去做精细的包体分析那是另一个工具的活。2.4 那些过滤开关其实作用在取数环节很多人以为 Filter 面板是画完之后把某些节点藏起来实际不是。大部分过滤条件是在RecursivelyConstructNodes的第 3 步就生效的——被过滤掉的节点根本不会被创建它的子树自然也不会被展开。这个差别很关键如果你开着一个过滤条件图里断掉的地方可能不是真的断而是被剪掉了。几个常见的开关和它们实际影响的东西我整理成表方便对照开关影响的数据范围典型误判显示软引用取数时把 Soft 类型的边也算进来关着时软引用资产看起来没人用显示引擎内容结果里是否保留/Engine/下的资产引擎资产被剪掉链路看起来断了显示插件内容结果里是否保留插件目录下的资产插件里的引用看不到显示引用者 / 显示依赖决定往左还是往右展开只开一边会以为是单向引用搜索深度上限超过层数直接不再递归深层引用链被截断搜索广度上限同一层的节点数超过上限就不再展开大图边缘凭空消失我的建议是排查问题的第一遍把所有过滤都放开、深度设成 3 左右先看个大概确认方向之后再收紧条件。反过来做很容易在第一眼就得出错误结论。3. 数据真正的老家FAssetRegistryState 里的那张依赖图3.1 FDependsNode 里挂着什么注册表内部对每个节点一个包或者一个资产都维护一个FDependsNode里面主要是四组指针数组包级依赖、包级引用者、资产级依赖、资产级引用者。注意依赖和引用者是分开存两份的而不是存一份再反向查——这是为了两个方向都能 O(1) 查。这个设计带来两个直接后果。第一新增一条边的时候要同时更新两个节点的两组数组写入成本比想象中高所以当年做异步扫描的时候才会花那么大力气去做批处理。第二如果某次增量更新只更新了一边历史版本里确实出现过这类问题你会看到我引用了它但它不知道被我引用的不对称现象。遇到这种不对称别急着怀疑自己的理解先试试强制重扫一次注册表。另外这里的指针是指向同一个注册表状态内部的节点对象的不是磁盘路径。也就是说整个图是纯内存结构序列化的只是它的一个快照。3.2 包粒度与资产粒度是两套边这是我耗时最久才想明白的一点。注册表同时记录两种粒度的依赖包级/Game/BP/BP_Player这个包依赖/Game/Material/M_Hit这个包。粒度粗一个包里有十个资产也算一条边。资产级BP_Player这个具体资产依赖M_Hit这个具体资产。粒度细但覆盖度不如包级——很多引用在扫描期只能确定到这个包依赖那个包确定不了具体是哪个资产。引用查看器默认展示的其实是资产级的视图但底层数据里两类都存着。当某条引用只能确定到包的时候工具会退化成显示包节点。这解释了一个很常见的困惑为什么有时候引用查看器里显示的引用者是一个包点进去才看到具体资产而另一些时候直接就显示资产。不是工具不稳定是数据本身的解析精度不同。想确认的话可以在取数时分别指定依赖类型对比两次的结果差异。3.3 Packages、Hard、Soft 三种类型到底差在哪取数接口接受一个依赖类型的参数常见的取值有三类语义完全不同Packages最粗的一层就是这个包引用了那个包不管引用方式是什么。Hard硬引用指运行时必然会拉起对方的引用比如蓝图变量里的直接资产指针、TSubclassOf、ConstructorHelpers::FObjectFinder这类。Soft软引用指以路径形式保存、用到才加载的引用比如软对象指针、软类指针、软对象路径。调用方可以按位组合这些类型。默认情况下也就是引用查看器不打开软引用开关时你看到的更接近 Hard 的结果。注意很多人以为打开软引用开关只是多显示一些节点实际上它改变的是取数时查询的边集合。开着和关着拿到的列表长度可能差好几倍而且差的那部分往往正是这个资产为什么会进包的答案。3.4 磁盘缓存和内存态不是一回事注册表在磁盘上有一个序列化的快照文件通常在工程的 Intermediate 目录下具体路径各版本略有差异编辑器启动时会先把它读进来得到一个初始状态然后异步扫描器再对照磁盘上的实际文件做增量更新。所以内存态 磁盘快照 启动后的所有增量磁盘快照可能过期尤其在你从版本控制拉了一大堆改动、但还没重新完整扫描的时候。这就引出了我第一次踩坑的场景同事拉完代码直接打开编辑器右键一个资产看引用图里挂着一个已经被删掉的资产。原因很简单——快照是旧的扫描还没跑完。这种时候要么等一下让它扫完要么手动触发一次强制重扫。判断扫描是否跑完的办法最靠谱的是看那个是否已完成搜索的状态而不是看时间。4. 这些依赖边是谁写进去的三条写入路径4.1 开发期的异步扫描读包的收集器编辑器启动后后台会有一个资产数据收集器在跑它的工作方式是遍历内容目录下的包文件用包读取器打开每个包的头部信息只读头部和依赖表不加载对象从里面抽出两类东西——资产清单和依赖信息——然后批量写进注册表状态。只读头部这个设计很关键。它意味着扫描非常快但也意味着扫描期能看到的信息只有包里记录的那些。如果一条引用在包文件的依赖表里没写扫描器就永远发现不了无论它在代码里有多真实。批量写入是为了解决锁竞争。早期版本里逐条更新依赖图的开销非常大后来改成收集一批再统一合并扫描期间的卡顿感就是从这里来的。如果你在做编辑器工具需要频繁读注册表最好避开启动后那几十秒。4.2 编辑器里的增量回调编辑器运行期间资产的增删改不会等下一次全量扫描。资产被创建、保存、重命名、删除的时候会通过注册表的几个回调把增量同步进去比如新增资产、重命名资产、删除资产这几类。这个机制保证了你在编辑器里新建一个资产、立刻右键看引用能马上看到结果。但它也有代价增量回调依赖调用方正确触发。如果有插件或第三方工具绕过标准流程直接生成资产文件注册表可能不知道重命名走的是删旧加新中间会有一小段两个名字都存在的窗口期删除的时候如果目标资产还挂在别人的依赖表里注册表未必能立刻把反向边清干净。这就解释了最经典的现象重命名之后引用查看器里还挂着一条指向旧名字的边。通常刷新一下、或者强制重扫就能清掉但如果清不掉说明有东西真的还在引用旧名字——最常见的就是重定向器。4.3 打包和 cook 之后数据会被裁掉一部分打包过程中注册表会被重新烘焙成运行时版本这时候会有一次有意的裁剪编辑器专用的边、只能编辑器加载的资产的边以及一部分软引用的元数据都可能不被写进最终产物。所以如果你的排查目标是打包后的包里为什么有这个资产在编辑器里看到的关系和打包时使用的关系可能并不完全一致。我的习惯是编辑器里定位大致方向再用打包产物对应的工具链去验证最终结果。拿编辑器里的图直接下结论容易被这层裁剪带偏。4.4 按名字引用的配置为什么在图里查不到这里拿一个经常被问到的场景举例项目设置里那种外接设备映射一类的配置。这类配置的典型形态是——配置文件ini 或者一个数据资产里存的是资产名或者路径字符串运行期再解析成具体资产。在包依赖层面这种引用有时确实会被记录下来如果配置本身是资产且路径字符串在属性里以软引用的类型存但更多时候它存的是一个普通的FString或FName包读取器根本不知道那是个资产路径。这时候引用查看器里就是一片空白。判断方法很简单去那个配置资产里看字段类型。如果字段类型是软对象路径、软类指针这类注册表认识的类型图里就有边如果是普通字符串或名字那就得靠人工维护一张映射表去反查。这个坑在设备配置、输入映射、UI 表这类数据驱动的资产上特别常见。5. 自己动手把同一份数据取出来对照5.1 Python 版本十分钟写一个依赖导出脚本验证数据来自注册表这件事最省事的办法是绕过引用查看器直接用编辑器脚本拿到同一份数据看结果是否一致。import unreal ar unreal.AssetRegistryHelpers.get_asset_registry() opts unreal.AssetRegistryDependencyOptions() opts.set_editor_property(include_hard_package_references, True) opts.set_editor_property(include_soft_package_references, True) opts.set_editor_property(include_searchable_names, False) opts.set_editor_property(include_hard_management_references, True) opts.set_editor_property(include_soft_management_references, True) target /Game/BP/BP_Player.BP_Player asset_data unreal.AssetRegistryHelpers.create_asset_data(target) deps ar.get_dependencies(asset_data, opts) refs ar.get_referencers(asset_data, opts) unreal.log( dependencies of {} .format(target)) for d in deps: unreal.log( {}.format(d)) unreal.log( referencers of {} .format(target)) for r in refs: unreal.log( {}.format(r))几个实际使用上的提醒。第一脚本拿到的是资产标识数组打印出来可能是包名也可能是资产路径取决于那条边记录的粒度别惊讶。第二create_asset_data的参数形式在不同版本里略有差别如果报类型错换一种传参方式试试或者先用get_assets_by_path拿一个现成的资产数据对象。第三选项结构体里那几个开关的名字我建议你在自己版本里用自动补全确认一下——这个结构体是随版本演进的直接抄代码有概率对不上。跑完这个脚本把输出和引用查看器界面上的节点列表对着看一眼你会发现两边基本能一一对上界面那边可能有去重和过滤这就足够证明数据同源了。5.2 C 版本两个必踩的坑想在工具链或者命令行里做批量检查还是得走 C#include AssetRegistryModule.h #include AssetRegistry/IAssetRegistry.h void DumpDependencies(const FName PackageName) { IAssetRegistry Registry FModuleManager::LoadModuleCheckedFAssetRegistryModule(TEXT(AssetRegistry)).Get(); FAssetIdentifier Id(PackageName); TArrayFAssetIdentifier Dependencies; Registry.GetDependencies(Id, Dependencies, EAssetRegistryDependencyType::Packages); UE_LOG(LogTemp, Log, TEXT(Dependencies of %s: %d), *PackageName.ToString(), Dependencies.Num()); for (const FAssetIdentifier Dep : Dependencies) { UE_LOG(LogTemp, Log, TEXT( %s), *Dep.ToString()); } }第一个坑是扫描没完成。编辑器刚启动、或者工程刚被拉下来时调用上面这段拿到的往往是空数组。解决办法有两个主动调一次全量同步搜索或者监听注册表的文件加载完成事件再做后续操作。前者简单粗暴代价是阻塞后者优雅但要处理好事件已经触发过的情况。第二个坑是依赖类型的枚举差异。在 UE4 里EAssetRegistryDependencyType是一个命名空间里的普通枚举直接写EAssetRegistryDependencyType::Packages没问题到了更新的版本它变成了强类型枚举语义基本一致但写法有细微差别。如果你在写跨版本的公共代码最好用编译期判断做一层薄封装别硬写一个值。还有一个容易被忽略的点GetDependencies返回的是直接依赖还是传递闭包答案是直接依赖只走一跳。想要多层结果得自己写递归并且自己维护已访问集合去环。引用查看器里的深度展开就是它自己在做这件事。5.3 交叉验证的对照表为了把界面结果和接口结果的对应关系说清楚我按自己的实测经验整理了一张对照表。你可以照着这张表去核对两边界面上的现象接口层的对应表现结论方向只有左边有节点GetReferencers有值GetDependencies为空正常只是它没有下游依赖左右都空两个接口都返回空要么真没引用要么是字符串引用显示包名而不是资产名返回的标识只有包名部分该边只解析到包粒度打开软引用后多出节点include_soft_package_references打开后才出现这是软引用边界面上有、接口里没有接口查询时依赖类型选窄了检查枚举组合这张表里最有价值的是最后一行。很多人第一次用接口拿数据时会默认只查 Packages然后发现结果比界面上少就开始怀疑接口不对。其实只是查询条件比界面窄而已。6. 引用查看器会看走眼的几种典型情况6.1 同一个资产硬引用和软引用的命运完全不同蓝图里挂一个静态网格体变量、并设了默认值这是硬引用注册表里必然有一条硬边运行时也一定会被拉起来。换成软对象指针就变成软引用边运行时只有在你主动加载的时候才会被拉起来打包时的处理策略也不一样。这两个东西在引用查看器里长得几乎一样都是连了一条线但它们在打包和运行时行为上的差异是巨大的。所以我养成了一个习惯在图上看到一条边之后一定会去确认它是硬还是软。确认方式不是看线的颜色各版本的视觉提示不一样而是在接口层把两种类型分开查一遍对比数量差。节点上还有一个是否编辑器专用的标记这个标记也值得看。编辑器专用的资产在 cook 时不会被带上所以它引用了谁对最终包体往往没影响——但在图上看链路是连着的很容易误判。6.2 重定向器和重命名之后的残留资产重命名在 UE 里通常是新增 删除 留一个重定向器这三步。重定向器本身也是资产也会参与依赖关系所以你会看到图上多出一个名字很奇怪、指向旧路径的节点。麻烦的是链路会变成两跳真实引用者 → 重定向器 → 真实资产。你在界面上顺着引用者爬的时候看到的是一个中间的、你根本认不出来的东西很容易以为引用链断了或者莫名其妙。处理办法就一个用修复重定向器把重定向清理干净然后强制重扫注册表。别在带重定向的状态下做包体分析结论大概率是错的。我吃过一次亏某个材质被判定没人引用删完之后才发现有一条边是从重定向器那边过来的整个 UI 全变粉。6.3 深度和广度被截断大图边缘的凭空消失前面提过深度和广度上限是在取数阶段生效的。这意味着当你的根节点引用关系很宽的时候图的边缘会突然没有下游了——不是没有是没展开。识别方法看节点上有没有可以继续展开的视觉提示不同版本叫法不一样一般是节点右下角有个小标记或者干脆把深度临时开大一点再看一次。如果开大之后多出来一堆节点那之前就是被截断了。我的排查习惯是分两遍走第一遍浅深度定位方向第二遍把疑似有问题的那一条支路单独作为根重新展开。这比一次性开满深度要安全得多既不会被大图淹没也不会漏掉关键路径。6.4 我最终固化下来的几个使用习惯写到这里也差不多把这条链路讲完了最后分享几个我现在雷打不动的操作习惯都是踩坑换来的。第一动手之前先等注册表扫完。刚打开编辑器那几分钟做任何引用分析都是自欺欺人尤其是在切分支或者拉了大版本之后。宁可多等一会儿也不要拿着半截数据下结论。第二看到没人引用先做一次反向验证。用脚本查一遍引用者列表两边都为空的资产才值得进一步怀疑只有界面为空、接口不为空的一定是过滤或依赖类型的问题。第三字符串引用一定要单独建清单。那些靠名字和路径字符串加载的配置资产比如各种映射表、设备配置注册表永远帮不了你只能靠约定和代码检索。我现在会在项目里维护一份只允许字符串引用的资产类型清单改代码的时候重点看这些。第四包体分析别只信引用查看器。它告诉你的是边不是体积。要判断一个资产为什么进了包、占了多大得配合打包产物的体积数据一起看两个工具互相印证结论才靠谱。这一点我是在一次误删事件之后才真正当回事的——引用关系对了不代表你的瘦身决策就一定对。
返回列表