Addressables 共享依赖的引用计数与卸载屏障
资源边界
战斗场景中的英雄包与敌人包可能共同依赖同一份 Mesh 和图集。若各包在退出时直接调用释放,先退出的一方会破坏仍存活对象。本文以时段 2026-07-27-2000 的独立 .NET 探针收窄问题:RefStore 拥有依赖计数,玩法对象只持有 Lease,底层资源仅在计数归零后具备卸载资格。
// .tmp/daily-labs/2026-07-27-2000/Program.cs · RefStore
sealed class RefStore(Dictionary<string, string[]> manifests)
{
private readonly Dictionary<string, int> refs = [];
public int LoadedCount => refs.Count(x => x.Value > 0);
public int RefCount(string dependency) => refs.GetValueOrDefault(dependency);
public Lease Acquire(string bundle)
{
foreach (var dependency in manifests[bundle])
refs[dependency] = RefCount(dependency) + 1;
return new Lease(() => Release(bundle));
}
}

所有权
系统边界有三层:清单定义包到依赖的映射;RefStore 维护计数并决定卸载资格;Lease 只表达一次使用权。两个不变量必须同时成立:计数永不为负;任意依赖计数大于零时,卸载请求只能失败且不能改变状态。把计数放到每个角色组件中看似直接,却无法对共享资源形成全局一致判断。
租约还切断了玩法对象与资源系统的双向知识:角色无需知道图集是否被另一个角色复用,资源层也无需追踪角色生命周期。清单是静态依赖事实,计数是运行时使用事实,两者不能混为“包已加载”布尔值;单个布尔值无法表示两个消费者先后离开的状态。
// .tmp/daily-labs/2026-07-27-2000/Program.cs · Release / TryUnload
public bool TryUnload(string dependency)
{
if (RefCount(dependency) > 0) return false;
refs.Remove(dependency);
return true;
}
private void Release(string bundle)
{
foreach (var dependency in manifests[bundle])
{
var next = RefCount(dependency) - 1;
if (next < 0) throw new InvalidOperationException("negative refcount");
if (next == 0) refs.Remove(dependency);
else refs[dependency] = next;
}
}
正常时序
固定输入只描述共享依赖场景:清单含英雄、敌人两个包,二者共同引用 Mesh 与图集,因此包数与共享依赖数都由该清单计算为二。英雄、敌人先后获取租约时,两项资源计数都变为二;英雄退出后计数降为一,资源继续存活;敌人退出后计数归零,记录被删除。这个时序把对象销毁与物理卸载分开:前者释放租约,后者由资源层根据零引用状态执行。
这里的“删除记录”只证明卸载资格已经出现,并不声称 GPU 内存同步释放。真实 Unity 集成仍需把零引用依赖送入 Addressables 的释放入口,并在主线程生命周期内完成句柄回收;探针验证的是调用之前必须成立的判定合同。
| 阶段 | Mesh 引用 | Atlas 引用 | 卸载资格 |
|---|---|---|---|
| 双租约存活 | 2 | 2 | 否 |
| 英雄释放 | 1 | 1 | 否 |
| 敌人释放 | 0 | 0 | 是 |

失败语义
失败路径不做补偿性减计数。对活跃图集调用 TryUnload 返回 false,引用仍为一;同一租约重复 Dispose 也只执行一次释放。后一条使场景取消、节点退出与异常清理可以安全汇合到同一终点,而不需要调用方维护额外的“是否已经释放”标志。
// .tmp/daily-labs/2026-07-27-2000/Program.cs · 回归断言
Run("UnloadBarrierRejectsLiveLease", () =>
{
var store = new RefStore(new() {
["boss"] = ["mesh", "atlas", "audio"]
});
using var lease = store.Acquire("boss");
Check(!store.TryUnload("atlas"), "活跃引用必须阻止卸载");
Check(store.RefCount("atlas") == 1, "失败路径不得改变计数");
});
Run("DoubleDisposeIsIdempotent", () =>
{
var store = new RefStore(new() { ["ui"] = ["atlas"] });
var lease = store.Acquire("ui");
lease.Dispose();
lease.Dispose();
Check(store.RefCount("atlas") == 0, "重复释放不得产生负计数");
});
运行证据
探针不模拟 Addressables API,而是验证其上层必须具备的所有权合同。Release 运行退出码为零,三项断言全部通过;固定输入与输出同时写入 evidence.json。技术图直接读取共享场景清单计算出的两个包、两项共享依赖与 3/3 统计,其他测试使用的 boss、ui 键不混入固定场景口径。
cd "$WORK_DIR/.tmp/daily-labs/2026-07-27-2000"
dotnet run -c Release
# exit_code=0
# passed=3 failed=0 skipped=0
# elapsed_ms=1.1977
# invariant=refcount_never_negative_and_live_dependency_never_unloads
{
"slot": "2026-07-27-2000",
"passed": 3,
"failed": 0,
"skipped": 0,
"fixed_input": {
"scenario_bundles": 2,
"shared_dependencies": 2
},
"invariant": "refcount_never_negative_and_live_dependency_never_unloads",
"tests": [
"SharedDependencyStaysLoaded",
"UnloadBarrierRejectsLiveLease",
"DoubleDisposeIsIdempotent"
]
}
演进条件
当前实现适合单线程资源协调器,计数操作复杂度与包的依赖数线性相关。若加载和释放跨线程并发,下一阶段应把清单展开、计数变更与卸载队列提交纳入同一临界区;若需要细分子资源,则将字符串键替换为真实资源句柄即可,不必改变租约合同。关键判断不是“何时调用 Release”,而是卸载资格必须由看见全部租约的唯一所有者给出。
另一个演进信号是循环依赖或运行时增量清单出现:此时不能继续按包递归释放,而应先把依赖图归一成资源节点,再对节点租约计数。升级的是清单解析层,不是调用方接口,因而场景退出仍只需释放一个幂等租约。