异步资源槽:Tag能去重,不能拒绝旧回调

自动关联目录:异步资源槽:Tag能去重,不能拒绝旧回调

核心判断:Tag 只能说明“这是网格槽还是动画槽”,不能说明“这是该槽的第几次请求”。旧请求完成后再按 Tag 查询当前路径,会把旧回调和新意图拼成一次不存在的操作。

一、业务冲突:同一表现槽会被连续换装

角色、投射物和召唤物都可能异步加载网格、动画类、影子代理和粒子。对象池复用、皮肤变化或配置刷新会在前一次加载完成前再次写入同一槽;结束使用时又必须整体取消,避免隐藏对象继续提交资源。

二、实际实现:句柄已集中管理,但身份只有 Tag

实际通用接口用 Map 保存 Tag -> SoftPath + Delegate + StreamableHandle。单资源请求遇到同 Tag 时会取消、释放旧句柄;结束接口既能按 Tag 取消,也能取消全部。具体完成回调不直接使用本次请求结果,而是再次按 Tag 从 Map 取当前 SoftPath,再 ResolveObject 并设置到组件。

集中保存句柄、支持分槽取消和统一 End 都是正确设计,比每个业务类散落一个匿名异步请求更容易关闭。

但还有三个具体缺口:

  1. 回调没有请求身份:A 完成时按 Tag 查到的可能已经是 B 的路径;
  2. 批量请求覆盖不对称:批量接口直接向 Map Add,同 Tag 旧句柄会失去管理入口,却没有先取消和释放;
  3. 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,就无法证明两者仍是同一件事。