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”,而是卸载资格必须由看见全部租约的唯一所有者给出。

另一个演进信号是循环依赖或运行时增量清单出现:此时不能继续按包递归释放,而应先把依赖图归一成资源节点,再对节点租约计数。升级的是清单解析层,不是调用方接口,因而场景退出仍只需释放一个幂等租约。