DYNAMIC EXPLAINER / ARTICLE FLOW

把章节变成可单步观察的过程

01 / 10
STEP 01 / CODE / LOGIC

先说结论

资源系统至少要能观察资源记录、请求记录和作用域记录。相同资源的并发请求应合并底层加载,但每个调用者仍获得独立引用责任;Dispose 作用域只释放属于该作用域的引用。

  • 谁正在使用这份资源?
  • 两个并发请求会不会启动两次底层加载?
  • 一个使用者释放后,另一个还能否安全访问?
  • 场景卸载时,哪些资源应该一起释放?
  • 加载失败、请求取消或类型冲突时,是否留下悬空状态?
Scope A 请求 Hero.prefab:开始底层加载 L1
Scope B 同时请求 Hero.prefab:加入 L1 等待者,不再启动 L2
L1 完成:A、B 各得到一个句柄,引用数 2
A Dispose:引用数 1
B Release:引用数 0,资源可卸载

先说结论

资源系统至少要能观察资源记录、请求记录和作用域记录。相同资源的并发请求应合并底层加载,但每个调用者仍获得独立引用责任;Dispose 作用域只释放属于该作用域的引用。

两个并发请求合并一次加载

Scope A 请求 Hero.prefab:开始底层加载 L1
Scope B 同时请求 Hero.prefab:加入 L1 等待者,不再启动 L2
L1 完成:A、B 各得到一个句柄,引用数 2
A Dispose:引用数 1
B Release:引用数 0,资源可卸载

加载失败时两个等待者都应收到同一失败事实,但各自作用域仍要清理请求记录。资源名相同也不必然代表版本、变体和加载参数相同,合并键必须包含真实身份。

资源缓存最危险的问题不是“没加载到”,而是加载成功后没有人能回答:

  • 谁正在使用这份资源?
  • 两个并发请求会不会启动两次底层加载?
  • 一个使用者释放后,另一个还能否安全访问?
  • 场景卸载时,哪些资源应该一起释放?
  • 加载失败、请求取消或类型冲突时,是否留下悬空状态?

一个可靠的资源作用域系统,把缓存条目、Owner 集合、Scope 持有集合和 Pending 请求放在同一套状态机中。更宽的生命周期职责与统一入口,可先查看资源生命周期统一管理

1. 三张表

Entries[location] = {
    asset,
    owners: Set<scopeId>
}

Scopes[scopeId] = {
    label,
    locations: Set<location>
}

PendingLoads[location] = {
    assetType,
    requests: [{ scopeId, callback, canceled }]
}

Entries 回答“资源被谁持有”,Scopes 回答“某个 Owner 持有哪些资源”,PendingLoads 回答“哪些请求正在等待同一次加载”。

Owner 用集合而不是引用计数:同一个 Scope 重复请求同一资源,Owner 仍只出现一次。这里统计的是“作用域所有权”,不是 Load 调用次数。

2. 正常加载与缓存命中

每次请求先把外部键解析成稳定的 location,然后查询缓存:

location = Resolve(request.Key);

if (entries.TryGet(location, out entry)) {
    if (entry.Asset matches request.Type) {
        entry.Owners.Add(scope.Id);
        scope.Locations.Add(location);
        return entry.Asset;
    }

    return TypeMismatch;
}

缓存命中仍要登记 Owner。否则第二个 Scope 虽然拿到了对象,释放第一个 Scope 时系统却会误以为已经无人使用。

3. 并发请求合并

异步请求未命中缓存后,先检查相同 location 是否已有 PendingLoad。

  • 类型一致:把请求追加到等待列表,不启动第二次底层加载;
  • 类型冲突:立即失败,不加入错误类型的等待队列;
  • 没有 PendingLoad:创建一条 PendingLoad,并启动一次底层加载。

加载完成时先移除 PendingLoad,再只为仍然有效、未取消且 Scope 仍存在的请求登记 Owner。若没有任何有效请求保留资源,应立即调用底层释放。

pending = PendingLoads.Remove(location);

foreach (request in pending.Requests) {
    if (!request.Canceled && Scopes.Contains(request.ScopeId)) {
        TrackOwnership(request.ScopeId, location);
        request.Callback(asset);
    }
}

if (entry.Owners.Count == 0)
    ReleaseUnderlying(asset);

回调异常应被隔离:一个使用者的回调失败,不能阻止其他等待者收到结果。

4. 单独 Release

释放一个 Owner 对某资源的所有权,需要同时更新两边:

scope.Locations.Remove(location);
entry.Owners.Remove(scope.Id);

if (entry.Owners.Count == 0) {
    Entries.Remove(location);
    ReleaseUnderlying(entry.Asset);
}

只要 Owners 仍非空,就保留缓存条目和底层对象。重复 Release 返回失败,但不能影响其他 Owner。

5. Dispose 与场景卸载

Scope.Dispose 的含义是:

  1. 对 Scope 当前持有的 location 快照逐个 Release;
  2. 从场景绑定索引中移除 Scope;
  3. 删除 Scope 状态;
  4. 令 Scope 对象进入 disposed 状态,后续请求不再产生所有权。

场景 Scope 额外登记 scene → scopeIds。场景卸载时对这组 ID 做快照,再逐个 Dispose。普通 Scope 不会因为任意场景卸载而自动释放,它必须由自己的生命周期负责人 Dispose。

缓存包装器可以在“绑定到新场景”时先 Dispose 旧 Scope,再创建新的场景 Scope,避免一份管理器状态跨场景错误复用。

6. 失败与冲突

加载返回空

不创建缓存条目,所有未取消请求收到失败结果,PendingLoad 被移除。

启动加载抛异常

移除 PendingLoad,通知所有未取消请求失败。失败不能伪装成缓存命中。

同 location、不同类型

缓存已存在或加载正在进行时,类型不匹配都应直接失败。不能把错误类型强制转换为空后继续登记 Owner。

完成时已无有效 Owner

Scope 可能在加载完成前 Dispose,请求也可能取消。若完成时没有任何有效请求,应释放刚加载的底层对象,不把“无人认领”的资源放进缓存。

完成时出现另一个对象

同步路径可能已经建立缓存,而异步加载随后返回另一对象。系统应保留已有对象,并释放重复返回的对象。

7. 状态复杂度与代价

  • 缓存命中、Owner 添加与 Pending 查找平均为 O(1)
  • 单资源 Release 平均为 O(1)
  • Scope.Dispose 为 O(R),R 是该 Scope 持有的资源数。
  • 场景卸载为 O(S + ΣR),S 是场景 Scope 数。
  • Pending 完成为 O(P),P 是合并的等待请求数。

代价是需要维护双向索引,并认真处理取消、回调异常、类型冲突和完成顺序。收益是底层释放只发生在 Owners 真正归零时,而且每次状态变化都能被诊断工具展示。

8. 不应从名字推断的能力

  • 同一 Scope 的重复 Load 不等于引用计数增加;Owner 是集合。
  • 请求取消只取消该请求的交付,不等于取消底层共享加载。
  • 普通 Scope 不会自动跟随场景卸载。
  • 系统是否支持全局 Shutdown、超时、失败重试或容量淘汰,需要独立的入口和消费证据;没有链路就不要宣称已支持。

好的资源系统不是“有一个字典”,而是让请求、等待、所有权、释放原因和底层对象的最终去向都可以被逐步解释。

最后记住

相同身份的并发请求可以共享一次底层加载。
共享加载不等于共享释放责任,每个调用者仍有独立句柄。
引用数归零后资源才具备卸载条件。
作用域 Dispose 必须关闭请求、引用和晚到回调。