异步资源槽:Tag能去重,不能拒绝旧回调
自动关联目录:异步资源槽:Tag能去重,不能拒绝旧回调
核心判断:Tag 只能说明“这是网格槽还是动画槽”,不能说明“这是该槽的第几次请求”。旧请求完成后再按 Tag 查询当前路径,会把旧回调和新意图拼成一次不存在的操作。
一、业务冲突:同一表现槽会被连续换装
角色、投射物和召唤物都可能异步加载网格、动画类、影子代理和粒子。对象池复用、皮肤变化或配置刷新会在前一次加载完成前再次写入同一槽;结束使用时又必须整体取消,避免隐藏对象继续提交资源。
二、实际实现:句柄已集中管理,但身份只有 Tag
实际通用接口用 Map 保存 Tag -> SoftPath + Delegate + StreamableHandle。单资源请求遇到同 Tag 时会取消、释放旧句柄;结束接口既能按 Tag 取消,也能取消全部。具体完成回调不直接使用本次请求结果,而是再次按 Tag 从 Map 取当前 SoftPath,再 ResolveObject 并设置到组件。
集中保存句柄、支持分槽取消和统一 End 都是正确设计,比每个业务类散落一个匿名异步请求更容易关闭。
但还有三个具体缺口:
- 回调没有请求身份:A 完成时按 Tag 查到的可能已经是 B 的路径;
- 批量请求覆盖不对称:批量接口直接向 Map
Add,同 Tag 旧句柄会失去管理入口,却没有先取消和释放; - Loading 语义错误:完成后条目仍留在 Map,
Contains(Tag)只能说明槽存在,不能说明 Handle 仍在 Loading。
单资源路径还是先发起新请求,再从 Map 删除旧请求并写入新值。只要加载回调保证以后排队,这通常能工作;一旦缓存命中允许重入或回调策略变化,完成回调可能在新槽状态发布前执行。接口正确性不应依赖这种隐含时序。
三、目标方案:SlotKey 表达用途,Generation 表达本次请求
struct FAssetSlot {
uint64 Generation = 0;
ELoadState State = ELoadState::Empty;
TArray<FSoftObjectPath> DesiredPaths;
TSharedPtr<FStreamableHandle> Handle;
ELoadError LastError;
};
void ReplaceSlot(FName Key, TArray<FSoftObjectPath> Paths) {
FAssetSlot& Slot = Slots.FindOrAdd(Key);
++Slot.Generation; // 先撤销旧回调的提交权
CancelAndRelease(Slot.Handle);
const uint64 Gen = Slot.Generation;
Slot.State = ELoadState::Loading; // 先发布状态,防同步完成重入
Slot.DesiredPaths = Paths;
Slot.Handle.Reset();
auto NewHandle = Request(Paths, [WeakOwner, Key, Gen, Paths] {
if (auto Owner = WeakOwner.Pin()) {
Owner->CommitSlot(Key, Gen, Paths);
}
});
// Request 可能同步回调并重入 ReplaceSlot,不能再使用之前的 Map 引用。
FAssetSlot* Current = Slots.Find(Key);
if (Current
&& Current->Generation == Gen
&& Current->State == ELoadState::Loading) {
Current->Handle = MoveTemp(NewHandle);
} else {
ReleaseUnclaimed(NewHandle);
}
}CommitSlot 同时校验 Key、Generation、DesiredPaths 和 OwnerReuseGeneration,直接消费本次结果,不再从当前 Map 猜路径。完成后状态变为 Ready 或 Failed,IsLoading 只检查显式状态。取消先增加 Generation,再取消 Handle;即使取消无法阻止已排队回调,它也无法提交。请求 API 若可能同步完成或在回调中重入替换,任何跨调用保存的 TMap 元素指针或引用都必须重新查找。
对于一组必须原子切换的网格、动画和材质,不能让三个槽各自 Ready 就立即上屏。应再建立 AppearanceRevision,等依赖集合全部 Ready 后一次提交;失败时保留上一套完整表现或使用明确降级资源。
四、取舍与验证
Generation 增加一次整数比较,Paths 快照也有少量内存成本,但能把“当前槽是什么”和“哪个请求回来了”彻底分开。若业务确认某槽永不替换,仍可用单次 Handle;通用换装接口不能建立在这个假设上。
故障注入应覆盖 A 请求后立刻 B、A 已缓存而 B 未缓存、同 Tag 连续批量请求、取消与完成同帧、对象回池后重借、加载失败后重试,以及完成回调内再次 Replace。指标至少记录 SlotGeneration、RequestedPathDigest、State、LateCompletionRejected、HandleLostWithoutRelease、ApplyLatency。
五、反思
现有接口已经把 Handle 收进 Map,说明工程意识到了请求需要所有者;问题是所有权被建模到“资源种类”就停止了。Tag 是一个长期存在的槽位,异步回调属于一次短期请求;不增加 Generation,就无法证明两者仍是同一件事。