短名资产解析:第一个命中不是稳定身份
自动关联目录:短名资产解析:第一个命中不是稳定身份
核心判断:短名适合作者输入,不适合作为规范资产身份。解析器若遍历多份映射并返回首个命中,结果就依赖容器顺序;若再返回指向共享暂存对象的引用,查询结果还会被下一次查询改写。
一、具体业务:用短名降低内容配置成本
资源管理器在启动时加载多份“短名 -> 软对象路径”映射,调用方既可传完整 /Game/... 路径,也可传短名。解析后统一进入同步/异步 StreamableHandle,并集中记录加载失败和耗时。短名让脚本与配置不必到处保存长路径,这个入口有明确价值。
实现还兼容普通字符串表、紧凑映射和内存映射数据,并对常见短名做大小写归一。实际调用点大多在得到指针后立即复制 FSoftObjectPath,因此没有普遍依赖借用引用。这些都是迁移时应保留的基础。
二、两个确定性缺口
第一,映射内使用 Add,同一文件中的重复键会覆盖旧值;跨映射解析则遍历外层 TMap,找到第一个短名就返回。代码没有构建冲突集合,也没有声明命名空间或优先级。于是相同短名可能随映射装载、哈希布局或内容合并变化而指向不同资产。“通常先加载某张表”不是解析契约。
第二,解析结果写入资源管理器的一个成员 FSoftObjectPath,API 返回它的指针或 const&。连续执行 Resolve(A)、Resolve(B) 后,先前保留的 A 引用实际观察到 B;嵌套调用、蓝图桥接或未来并发读取都会放大这个隐患。const 只限制调用方修改,不能让被引用对象不可变。
这两个问题共同说明当前 Resolver 不是纯函数:同一输入的结果既依赖外部遍历顺序,又依赖调用时序。
三、目标方案:构建候选索引,解析结果按值返回
enum class EResolveStatus { Unique, Missing, Ambiguous, WrongType };
struct FResolveScope {
FName Namespace;
UClass* ExpectedClass;
FName Target;
uint32 MappingRevision;
};
struct FResolveResult {
EResolveStatus Status;
FLogicalAssetId CanonicalId;
FSoftObjectPath Path;
TArray<FAssetCandidate> Candidates;
FRuleTrace Trace;
};
FResolveResult Resolve(FName alias, const FResolveScope& scope) const
{
TArray<FAssetCandidate> c = Index.FindCandidates(alias, scope);
if (c.IsEmpty()) return Missing(alias, scope);
if (c.Num() != 1) return Ambiguous(alias, scope, MoveTemp(c));
return Unique(MoveTemp(c[0])); // 返回独立值
}索引在全部映射加载后一次构建为 Alias -> Candidates。重复项不能静默覆盖:完全相同的规范身份可去重;不同身份必须通过命名空间、期望类型或显式优先级消歧。若业务确实需要 Override,优先级要进入规则数据和诊断结果,不能借 TMap 顺序表达。
持久化内容应保存规范逻辑 ID、版本/目标约束或可验证的软引用;短名只在输入边界解析一次。映射的 Revision 与摘要进入构建制品,确保编辑器预览、游戏客户端和 DS 使用同一解析语义。
四、为什么不继续返回指针
FSoftObjectPath 按值返回的成本相对于一次资源加载可以忽略,且编译器可以消除多余拷贝。若确实需要零拷贝,应返回指向不可变索引节点的稳定句柄,并让索引 Revision 控制其生命周期;不能复用一个“当前结果”成员。
按值结果还能携带 Missing/Ambiguous、候选来源和期望类型。相比 nullptr 或空路径,它让上层决定失败关闭、占位或回退,并能与批量资源加载的逐项解析计划直接组合。
五、迁移与验证
先扫描全部映射并输出冲突报告,再给旧 API 增加“首命中发生歧义”的计数;调用点改为接收值语义 FResolveResult,最后再禁止无 Scope 的歧义短名。不要一开始强行让所有历史短名失败,否则只会把隐式规则迁移成大量临时特判。
测试要随机化映射加载顺序,并覆盖同表重复、跨表重复、大小写、同名不同类型、连续两次查询、映射 Revision 切换和多线程只读。核心不变量只有两条:顺序变化不能改变唯一解析结果;一次成功结果不能被后续查询改写。