把章节变成可单步观察的过程
先说结论
资源系统至少要能观察资源记录、请求记录和作用域记录。相同资源的并发请求应合并底层加载,但每个调用者仍获得独立引用责任;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 的含义是:
- 对 Scope 当前持有的 location 快照逐个 Release;
- 从场景绑定索引中移除 Scope;
- 删除 Scope 状态;
- 令 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 必须关闭请求、引用和晚到回调。