UE4 的 ReferenceViewer 用久了,很容易产生一个错觉,觉得它是引擎里自带的一个“引用数据库”,点一下就能把谁引用了谁列出来。实际上它自己一行引用数据都不存,它只是个查询前端,真正干活的是 AssetRegistry。而 AssetRegistry 手里那份数据,又是从磁盘上的 .uasset 文件里一段一段抠出来的。搞清楚这条链路之后,很多看起来诡异的现象就都有解释了:为什么第一次打开窗口那么卡、为什么明明删了资源引用还在、为什么打包之后有些引用凭空消失、为什么ReferenceViewer里搜不到你在 ini 里写的那条外接设备映射。这篇就顺着数据流从底往上捋一遍,把每一层的来源、口径和坑讲清楚,适合已经上手过 UE4、想拿引用数据做资源治理或者做工具的同学参考。
1. ReferenceViewer 背后那条数据链路到底长什么样
1.1 先记结论:引用数据只有一个源头,就是 AssetRegistry
在编辑器里打开ReferenceViewer,窗口会做三件事:拿到IAssetRegistry的引用、向它发查询请求、把返回的包名列表渲染成节点。它没有自己的数据库,也没有独立的缓存文件,所有节点连线的“事实依据”都来自 AssetRegistry 的内存态。
AssetRegistry 的内存态用一句话概括:一张TMap<FName, FAssetData>记录“有哪些资源”,再一张DependencyDataMap记录“谁引用谁”。后者是ReferenceViewer真正的命根子,它的 key 是包名,value 里分成两组集合——一组是“引用我的包”,一组是“我引用的包”,同时每一项还带一个类别标记(硬引用、软引用、可搜索名、管理引用等)。
这里有个容易踩的点:ReferenceViewer查询和展示的粒度在绝大多数情况下是 Package 而不是 UObject。也就是说你看到的一个节点代表/Game/Props/SM_Chair这个包,而不是包里的SM_Chair这个对象本身。UE4.18 之后引入了FAssetIdentifier,理论上可以把粒度下沉到对象级、PrimaryAsset 级甚至类级,但真正在磁盘上存的那份依赖数据,绝大部分仍然是包对包的关系。理解了这一点,你就不会去纠结“为什么同一个包里引用了两次这里只显示一条”这种问题。
1.2 磁盘上那份 AssetRegistry 缓存是从哪来的
AssetRegistry 有两份数据:一份是从磁盘缓存文件里反序列化回来的,一份是编辑器启动后扫描 Content 目录增量补上的。缓存文件通常在Intermediate/AssetRegistryCache/下面,早期的版本是一个整包AssetRegistry.bin,后面几个版本改成了分片存储加增量更新,目录没变,但里面会出现多个小文件。你在自己的工程里直接去Intermediate目录搜AssetRegistry就能定位到,不同小版本的命名和数量会有差异,这点不用强求一致。
这份缓存是谁写的?是上一次编辑器会话结束时(或者扫描完成后的某个时机)由 AssetRegistry 自己序列化下来的。它的作用是让下次启动不用把整个工程的包重新解析一遍,直接读缓存就能拿到引用关系。代价是它会过时——如果你在编辑器外改了文件、切了分支、或者缓存写入失败,读到的东西就跟磁盘实际情况对不上。
提示:删掉
Intermediate/AssetRegistryCache/下的内容是一个很有效的“重置引用数据”手段,但代价是下一次启动(尤其是大工程)会明显变慢,因为所有包都要重新解析一遍。
1.3 从 .uasset 抠出引用关系的那几个读取器
真正干“抠数据”这个活的是包读取器。编辑器启动或者触发扫描时,FAssetDataGatherer会在后台工作线程里逐个打开.uasset,用FPackageReader把文件头部读一遍,依次拿到这几样东西:
一是FPackageFileSummary,它告诉你这个包的基本信息和各个数据段的偏移量;二是导入表(Import Table)和导出表(Export Table),硬引用的核心证据就在这里;三是DependsMap,记录每个导出对象依赖了哪些包索引;四是包尾的 AssetRegistry 数据段,包含资源类名、对象路径、以及用于依赖分析的分组信息;五是SoftObjectPaths段和SearchableNames段,后面讲软引用和可搜索名的时候会用到。
整个过程不加载资源、不实例化 UObject,纯粹是二进制解析。这也是为什么它能扫得比较快——它的成本主要花在 IO 和字符串解析上,而不是构造对象上。扫描线程把结果打包成一批FAssetData加一批依赖项,通过队列丢回游戏线程,由 AssetRegistry 的实现类合并进内存态。
1.4 一个必须记住的口径差异
内存态里的依赖数据和磁盘上的包,永远是两套东西。你在文件管理器里看到一个资源删了,但 AssetRegistry 的内存态里可能还留着它的记录,直到下一次针对该路径的重新扫描发生。同样,你用版本管理工具切了分支,文件全变了,AssetRegistry 也不会自动知道,它只知道自己的内存态和缓存文件。
这就是所有“引用数据不准”问题的总根源。后面第五部分会专门列一张排查表,把常见现象和对应的修复动作对上号。
2. 引用关系被拆成五类,每一类的取数逻辑都不同
2.1 硬引用:导入表和导出表里真实存在的依赖
硬引用是最“实”的一类。它的判定依据很简单:一个包里出现了对另一个包的序列化引用。具体表现为该包的导入表里有一条记录,指向目标包的路径,而导出表里的某个对象属性通过包索引(Package Index)指向了这条导入记录。
举个例子,一个蓝图类里面有个 UPROPERTY 的 Actor 指针,或者一个静态网格体资产里引用了一个材质资产,或者一个关卡里摆了一个实例,这些在保存时都会变成导入表里的一条硬引用记录。材质、贴图、静态网格、骨架、蓝图之间的引用,绝大多数都属于这一类。
硬引用的一个重要特性是“跟着加载走”。打包器看到一个包对另一个包有硬引用,就会认为运行时加载前者必须把后者一起带上。这也是资源治理里最需要盯紧的一类关系,因为它直接决定打包体积和加载耗时。
注意:硬引用是传递的。A 硬引用 B,B 硬引用 C,那么加载 A 的时候 C 也会被带进来,即便 A 里根本没有直接用 C。
2.2 软引用:包尾那段 SoftObjectPaths 才是真正的来源
软引用的数据来源和硬引用完全不在一个地方。它不在导入表里,而是在包尾部一个专门的SoftObjectPaths段。原理是保存包的时候,序列化系统会把本次保存过程中出现过的所有FSoftObjectPath路径字符串收集起来,统一写进这个段里。
所以你会发现,软引用的“证据”本质上是字符串路径,只是这些字符串被引擎识别出来登记成了依赖关系。TSoftObjectPtr、TSoftClassPtr、FSoftObjectPath、FSoftClassPath这些类型的属性走的就是这条路。
这里有个很关键的区别:软引用在运行时不会自动加载目标,它只是一个“可以加载”的约定。因此它对打包体积的影响是可以被裁掉的,但对项目的正确性影响却可能是致命的——路径写错了、资产改名了、资产被移走了,软引用不会在编译期报错,只会在运行时静默变成空指针。这也是为什么引用数据治理里,软引用链的检查比硬引用更需要自动化。
2.3 可搜索名:字符串世界里唯一的补丁
可搜索名是一类很特殊的存在。有些引用在代码或者配置里是以 FName 字面量出现的,比如某个资产的名称被硬编码进了一个数据表,或者某个类名被写进了配置。这类引用在二进制层面不算包引用,导入表里看不见,软引用段里也没有。
引擎的处理方式是:允许在保存包的时候,把指定的 FName 登记进SearchableNames段,形成一个可以查询的“名字引用”。这样 AssetRegistry 就能在名字层面建立起关联。
问题在于,这个机制需要显式的登记动作。你写在 ini 里的字符串——比如外接设备的输入映射配置里那个动作名或者资产路径——默认情况下是不会被登记的。这就是为什么很多项目在打包之后发现“映射资产没被打进去”,因为整个链路里没有任何一处构成引擎能识别的引用。
2.4 管理引用:AssetManager 那一层额外注入的关系
管理引用(Management References)是 UE4.20 前后引入 Primary Asset 概念之后加进来的一类关系。它不由文件解析产生,而是由 AssetManager 的 Primary Asset 规则推导出来的:某个 Primary Asset 覆盖了哪些目录、哪些基类、哪些具体资产,AssetManager 据此在这些资产和 Primary Asset 之间建立引用。
这类关系在数据上同样被挂进 AssetRegistry 的依赖数据里,但它的来源是运行时的规则计算,不是磁盘上的序列化数据。所以它有一个很明显的特点:规则的变更会立刻反映到查询结果里,不需要重新扫描文件。
2.5 五类关系对照表
| 类别 | 数据来源位置 | 打包是否带资源 | 常见触发写法 |
|---|---|---|---|
| 硬引用 | 导入表 / 导出表 / DependsMap | 是 | UPROPERTY 指针、TArray 引用、关卡实例 |
| 软引用 | 包尾 SoftObjectPaths 段 | 可被裁掉 | TSoftObjectPtr、FSoftObjectPath |
| 可搜索名 | 包尾 SearchableNames 段 | 否 | 显式登记的名字引用 |
| 硬管理引用 | AssetManager 规则计算 | 是 | Primary Asset 覆盖规则 |
| 软管理引用 | AssetManager 规则计算 | 否 | 管理规则中的软关联 |
这张表建议背下来。拿到一个“引用看不见”的问题,先按这张表定位它属于哪一类,基本能省掉一半的排查时间。
3. 从点开菜单到画出连线,完整数据流拆一遍
3.1 第一次打开为什么慢,慢在哪
ReferenceViewer打开的瞬间会去调 AssetRegistry 的全量扫描接口(SearchAllAssets),而且是异步的。如果你这次编辑器会话里还没做过全量扫描,这一步就要真的去遍历 Content 目录、解析所有包的文件头,工程越大越慢。第二次打开之所以快,是因为内存态已经建好了。
这带来一个很现实的推论:刚启动编辑器、什么都还没干的时候去打开ReferenceViewer,看到的结果是最不可靠的,因为扫描可能还在跑。这时候查询一个包,可能返回空引用列表,但它并不是真的没有引用者,只是数据还没到位。
我自己的习惯是:打开编辑器之后先让它自己跑一会儿,看 Output Log 里 AssetRegistry 的扫描消息安静下来,再去做引用分析。如果是 CI 上的自动化审计,那就必须强制同步扫描,不能靠异步。
3.2 查询接口的参数组装,决定了你看到什么
ReferenceViewer发起查询时,会组装一个依赖选项结构体,把要查的类别逐项打开。这个结构体大致包含这么几个开关:是否包含硬包引用、是否包含软包引用、是否包含可搜索名引用、是否包含软管理引用、是否包含硬管理引用。
这就解释了一个经典困惑:为什么同一个资源,在ReferenceViewer里看到的引用者数量,跟你在资源管理器里手动找出来的不一样。因为默认开关组合和你的预期不同。比如你只打开硬引用,那所有TSoftObjectPtr的关系就全都不显示;反过来如果你把可搜索名打开,又可能冒出一堆看起来毫不相干的包。
C++ 侧直接用这套接口大概是这个形态:
IAssetRegistry& Registry = FAssetRegistryModule::GetRegistry(); FAssetRegistryDependencyOptions Options; Options.bIncludeHardPackageReferences = true; Options.bIncludeSoftPackageReferences = true; Options.bIncludeSearchableNamePackageReferences = false; Options.bIncludeHardManagementReferences = false; Options.bIncludeSoftManagementReferences = false; TArray<FName> Referencers; Registry.GetReferencers(FName(TEXT("/Game/Props/SM_Chair")), Referencers, Options); for (const FName& Name : Referencers) { UE_LOG(LogTemp, Log, TEXT("referencer: %s"), *Name.ToString()); }要注意,GetReferencers在旧版本里的签名不带选项参数,直接调用会拿到一个“默认口径”的结果,通常是硬引用为主。如果你在维护跨版本的工具代码,这里必须做版本判断,否则升级引擎之后结果会突然变多或者变少。
3.3 UI 层的二次裁剪,也会改变你看到的东西
接口返回的是一批包名,但窗口里看到的节点数量和这些包名不一定一一对应。UI 层还有一层过滤:按类别过滤、按资源类型过滤、按路径关键字过滤,还有“只显示引用我的 / 只显示我引用的”这类方向切换。另外历史记录和面包屑导航也会影响当前显示的节点集合。
所以当你觉得“这个资源明明有很多引用者,怎么只显示三个”的时候,排查顺序应该是:先看过滤器设置,再看查询选项开关,最后才去怀疑 AssetRegistry 数据有问题。这个顺序很重要,反过来做的话很容易白忙一场。
提示:把过滤器和查询选项截图存档,作为团队内部的“标准查询口径”。否则不同人做出来的资源审计报告根本没法互相对比。
4. 拿数据做真实的事:三个能直接抄的场景
4.1 找出谁在拖着一整套贴图,把打包体积压下来
大项目里最常见的体积问题不是单个大资产,而是某个不起眼的蓝图硬引用了一套分辨率很高的贴图,而这套贴图又各自硬引用了材质,材质又引用了别的贴图,形成一棵巨大的依赖树。
做法是先从包体清单里挑出体积最大的若干个资源,然后对每一个做依赖查询,把硬引用链展开,看这棵树总共覆盖了多少资产、累计多大。这一步用ReferenceViewer手动点也能做,但只适合抽样;真正要覆盖全量,得走脚本。
思路是遍历所有资源的 AssetData,对每个包调一次依赖查询,把结果落成一张映射表(源包 -> 目标包列表),再在这张表上做遍历统计。这张表建好之后,你想算“某个资产被引用几次”“某个资产的总依赖树有多大”“有没有循环引用”都只是在这张表上跑算法而已。
有一点要提醒:算依赖树的时候一定要做去重和剪枝。同一个包在树里出现多次只算一次体积,否则算出来的数字会很夸张。另外要设一个深度上限,否则遇到那种环形引用或者超长链,遍历会一直跑下去。
4.2 外接设备映射资产的引用链,为什么经常查不到
项目接了外接方向盘、脚踏板、摇杆或者自定义控制器之后,输入映射这块特别容易出问题。典型情况是:映射关系配在一个数据资产里,资产里写的是TSoftObjectPtr指向某个输入动作资产;但同时又有一份 ini 或者表格里写了纯字符串的动作名。这时候你去ReferenceViewer里查那个输入动作资产,会发现引用者列表少得可怜,甚至一个都没有。
原因就在第二部分讲的那五类关系上。软引用那一份是能查到的,但字符串那一份查不到,因为没有任何地方做过可搜索名登记。打包器看到的结果就是“这个输入动作资产没人引用”,于是把它裁掉,运行时映射直接失效。
解决方案有三条路,按推荐度排:第一条是把字符串引用全部改成软引用或者直接引用,让依赖关系变成引擎能识别的形式;第二条是给这些资产配上 AssetManager 的管理规则,用 Primary Asset 覆盖住它们,这样即便没有硬引用也会被打包;第三条是把它们挂进一个总被引用的“根资产”下面,通过硬引用链兜住。
注意:第三条是最脏的做法,短期能救急,长期会变成技术债,因为你会忘记这个根资产为什么存在,某天优化的时候把它删掉,问题就复现了。
4.3 用脚本批量导出引用表,接进流水线
手工点窗口只能解决个案,真正有价值的是把引用数据接进流水线。UE4 提供了 Python 侧的 AssetRegistry 封装,可以先取到注册表对象,再按包名查询引用者和被引用者。不同版本的函数命名有差异,写之前先在编辑器里用补全看一眼,别照着几年前的文章抄。
大概的调用形态是这样:
import unreal registry = unreal.AssetRegistryHelpers.get_asset_registry() options = unreal.AssetRegistryDependencyOptions( include_hard_package_references=True, include_soft_package_references=True, include_searchable_name_package_references=False, include_hard_management_references=False, include_soft_management_references=False ) referencers = registry.get_referencers("/Game/Props/SM_Chair", options) for name in referencers: unreal.log("referencer: {}".format(name))跑批量的时候有两点必须注意。一是先确保全量扫描完成,可以在脚本开头调用同步扫描接口,或者在命令行环境下用带同步参数的启动参数,否则脚本跑出来的结果是残缺的,而且这种残缺是静默的,不会报错。二是控制单次查询的数量,几万个包连着查会把编辑器卡死,做成分批加进度输出更稳。
导出成表之后,能做的事就多了:查循环引用、查孤儿资源、查跨模块的违规引用(比如玩法模块引用了编辑器专用资源)、监控每次提交的新增引用关系变化。这几项里,我认为最有价值的是最后一项,因为它能在问题进入主干之前就拦住。
5. 数据不准时的排查顺序与速查表
5.1 先判断是“数据错”还是“口径错”
遇到引用数据异常,第一步不是去查代码,而是先确认口径。同一组开关、同一个过滤器、同一个扫描状态下,结果是否可复现。很多时候问题出在两个人用了不同的查询选项,或者一个人在异步扫描没完成的时候查的。
如果口径确认一致结果还是不对,再往下走:看缓存文件的时间戳、看内存态里有没有这个包的记录、看这个包最近有没有被改动过。这三步基本能定位到是缓存问题、扫描问题还是文件本身的问题。
5.2 常见异常与对应处理
| 现象 | 通常成因 | 处理动作 |
|---|---|---|
| 引用者列表为空但明显有引用 | 全量扫描未完成 | 等待扫描结束或强制同步扫描 |
| 引用者里有已删除的资源 | 内存态未更新 | 对相关路径做强制重扫 |
| 软引用查不到 | 查询选项未开软引用 | 打开软引用选项后重查 |
| ini 里的映射资产查不到 | 字符串未登记为可搜索名 | 改为软引用或加管理规则 |
| 切分支后数据全乱 | 缓存文件与工作区不匹配 | 清理 Intermediate 下的注册表缓存 |
| 打包后资源丢失 | 引用链在打包口径下断裂 | 用管理规则或根资产兜住 |
5.3 几条用血换来的硬规矩
第一条,永远不要在编辑器刚启动、扫描还没停的时候做资源决策。这一条听起来像废话,但我见过太多团队在启动后一分钟内跑审计脚本,然后拿着残缺的报告去删资源。
第二条,做资源治理的工具必须显式声明查询口径。不要用默认参数,把五个开关一项一项写清楚,并且把这份配置和报告一起存档。半年后有人质疑数据的时候,你能拿出当时的口径。 第三条,删资源之前,硬引用和软引用两条链都要走一遍。只看硬引用链会漏掉软引用,只看软引用链会漏掉间接的硬引用传播,两个都看才安全。
第四条,管理规则是解决“看不见的引用”最干净的手段,但要注意它会把资源钉死在包里。用它兜住的资产,你基本就放弃了通过引用分析来裁剪它的可能,所以只用在真正需要常驻的资产上。
6. 想做得更深:把引用数据接进你自己的工具
6.1 用自定义 AssetRegistry 标签把隐式引用显式化
如果项目里有大量靠命名约定或者配置文件维持的关联,可以考虑在保存资源的时候往 AssetRegistry 的标签里塞自定义信息。这样查询的时候就能通过标签筛选出这些资源,再配合自己的规则推导出关联关系。
这个做法的好处是不改动引擎的引用模型,纯粹是加一层元数据;坏处是它不会自动出现在ReferenceViewer的依赖查询结果里,需要你自己的工具去读。所以在团队内部要明确:这是辅助数据,不是依赖数据的替代品,真正的引用关系还是得靠前面那五类。
6.2 把审计做成定期任务而不是临时动作
临时审计的问题是不可持续。做一次很累,做完就没人再看,几个月后问题又长回来。比较靠谱的方式是把依赖表的生成、孤儿资源检测、循环引用检测做成定期任务,输出结构化报告,和上一次的结果做 diff,只关注新增的变化。
这样做还有个隐藏收益:你会逐渐积累出一份“引用关系的演化史”。当某次打包体积突然变大,你能直接看到是哪次提交引入了新的依赖链,而不是从头去猜。
6.3 大工程上的性能取舍
十万级资源的工程里,全量依赖查询的成本不低。我自己的经验是:日常开发只查增量部分,全量只在发布前跑。做增量查询的关键是能拿到“最近改动过的包列表”,这个信息可以从版本管理工具或者编辑器自身的改动记录里拿。
另外查询结果的存储格式也值得挑一下。直接存文本表格在几万条的时候还行,到几十万条就会变得很难处理,换成更紧凑的二进制格式或者数据库会舒服很多。这一点在前期不显眼,到后期会成为瓶颈。
6.4 一个容易被忽略的扩展方向
引用数据不只能用来做资源治理,它还能用来回答一些设计层面的问题:某个模块是不是被别的模块过度依赖了、哪些资产的被引用次数异常高说明它承担了过多的职责、有没有本该被拆开的“上帝资产”。这类分析不需要很复杂的算法,把依赖表建好之后,做几个简单的统计就能看出苗头。
我自己在实际项目里最常用的一招,是把依赖表按“被引用次数”排个序,然后人工看前二十名。基本上每次都能发现一两个设计上不太对劲的地方,改完之后,后续的资源治理和编译速度都会跟着受益。