DYNAMIC EXPLAINER / ARTICLE FLOW

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

01 / 11
STEP 01 / CODE / LOGIC

先说结论

资源管理的核心不是“在哪里调用 Load”,而是谁拥有引用、何时释放以及异步结果回来时作用域是否还有效。统一句柄和作用域能让加载与释放形成一一对应的责任链。

页面 A 加载 Texture/Hero:引用数 1
页面 B 加载同一路径:缓存命中,引用数 2
关闭页面 A:引用数 1,资源仍保留
关闭页面 B:引用数 0,资源可以卸载

分类:工程设计

先说结论

资源管理的核心不是“在哪里调用 Load”,而是谁拥有引用、何时释放以及异步结果回来时作用域是否还有效。统一句柄和作用域能让加载与释放形成一一对应的责任链。

两个页面共享同一张纹理

页面 A 加载 Texture/Hero:引用数 1
页面 B 加载同一路径:缓存命中,引用数 2
关闭页面 A:引用数 1,资源仍保留
关闭页面 B:引用数 0,资源可以卸载

若 B 的异步加载尚未完成就关闭,结果回来时也不能再绑定到已销毁页面。作用域负责让这类晚到结果被释放或忽略,而不是静默泄漏。

核心知识点

资源管理里最重要的不是“引用计数这个数字本身”,而是资源的获取和释放必须有统一入口、统一规则、统一生命周期归属。要逐步观察并发加载、Owner 矩阵和最终释放,可进入资源作用域与引用释放

可以概括成一句话:

谁 Load,谁负责 Release;
或者谁创建生命周期作用域,谁负责统一释放这个作用域里的所有资源。

更进一步说:

不是简单把加载和释放代码放在一起,而是要把资源的所有权设计清楚。

跟着代码做:先复现一次资源泄漏

假设战斗页面需要加载头像和技能图标:

void OpenBattlePage()
{
    Sprite avatar = resourceManager.Load<Sprite>("Avatar/Hero");
    Sprite skill = resourceManager.Load<Sprite>("Icon/Fireball");

    view.SetAvatar(avatar);
    view.SetSkillIcon(skill);
}

页面能正常显示,但关闭时没有任何代码知道应该释放哪两个资源。连续打开三次后,资源管理器可能看到:

Avatar/Hero   引用次数 3
Icon/Fireball 引用次数 3

问题不是 Load 本身,而是它没有返回“这次获取由谁负责”的明确记录。

第二步:让每次加载返回可释放句柄

sealed class ResourceHandle<T> : IDisposable
{
    private readonly Action release;
    private bool disposed;

    public T Asset { get; }

    public ResourceHandle(T asset, Action release)
    {
        Asset = asset;
        this.release = release;
    }

    public void Dispose()
    {
        if (disposed)
            return;

        disposed = true;
        release();
    }
}

业务代码保存句柄,而不是只保存裸资源:

ResourceHandle<Sprite>? avatarHandle;
ResourceHandle<Sprite>? skillHandle;

void OpenBattlePage()
{
    avatarHandle = resourceManager.Load<Sprite>("Avatar/Hero");
    skillHandle = resourceManager.Load<Sprite>("Icon/Fireball");

    view.SetAvatar(avatarHandle.Asset);
    view.SetSkillIcon(skillHandle.Asset);
}

void CloseBattlePage()
{
    avatarHandle?.Dispose();
    skillHandle?.Dispose();
    avatarHandle = null;
    skillHandle = null;
}

现在一次运行是成对的:

Open  -> Load Avatar,引用 0 -> 1
      -> Load Skill,引用 0 -> 1
Close -> Dispose Avatar,引用 1 -> 0,可卸载
      -> Dispose Skill,引用 1 -> 0,可卸载

disposed 让重复关闭不会把引用次数减成负数,但业务仍应该记录重复释放,因为它通常说明生命周期调用有问题。

第三步:资源多了以后,用作用域统一释放

页面有十几个资源时,逐个字段保存和释放很容易漏。让一个作用域持有所有句柄:

sealed class ResourceScope : IDisposable
{
    private readonly List<IDisposable> handles = new();
    private bool disposed;

    public T Own<T>(ResourceHandle<T> handle)
    {
        if (disposed)
            throw new ObjectDisposedException(nameof(ResourceScope));

        handles.Add(handle);
        return handle.Asset;
    }

    public void Dispose()
    {
        if (disposed)
            return;

        disposed = true;

        for (int i = handles.Count - 1; i >= 0; i--)
            handles[i].Dispose();

        handles.Clear();
    }
}

页面代码变成:

ResourceScope? scope;

void OpenBattlePage()
{
    scope = new ResourceScope();

    Sprite avatar = scope.Own(
        resourceManager.Load<Sprite>("Avatar/Hero"));
    Sprite skill = scope.Own(
        resourceManager.Load<Sprite>("Icon/Fireball"));

    view.SetAvatar(avatar);
    view.SetSkillIcon(skill);
}

void CloseBattlePage()
{
    scope?.Dispose();
    scope = null;
}

运行思路变成:

页面创建作用域
-> 每次 Load 的句柄立即登记到作用域
-> 页面只使用 Asset
-> 页面关闭时 Dispose 一次作用域
-> 作用域逆序释放它拥有的全部句柄

第四步:异步加载必须检查作用域是否还活着

页面可能在加载结束前已经关闭:

async Task LoadAvatarAsync(ResourceScope pageScope)
{
    ResourceHandle<Sprite> handle =
        await resourceManager.LoadAsync<Sprite>("Avatar/Hero");

    try
    {
        // Own 会在已销毁作用域上明确失败。
        Sprite avatar = pageScope.Own(handle);
        view.SetAvatar(avatar);
    }
    catch (ObjectDisposedException)
    {
        // 加载完成时页面已经关闭,本次句柄必须立即归还。
        handle.Dispose();
    }
}

工程版本通常会把取消令牌传入加载请求,或在完成回调中检测生命周期版本;如果页面已关闭,要立即释放刚完成的句柄,不能继续更新旧 UI。后文的统一入口、Owner 和作用域设计,都是从这条具体获取/释放链扩展出来的。

为什么要统一

如果资源的生命周期分散在多个地方,就会出现账本混乱:

A 地方 Load
B 地方缓存
C 地方传给 UI
D 地方偷偷 Unload
E 地方忘了 Release

这样引用计数很容易失真:

引用计数过小:资源还在使用中就被释放
引用计数过大:资源没人使用了却长期不释放

前者会导致丢图、空引用、材质失效、句柄失效;后者会导致内存泄露、资源常驻、长时间运行后性能变差。

更好的做法

资源加载入口统一:

所有业务都通过统一的 ResourceManager 加载资源。

资源释放入口统一:

不要让业务随意直接释放底层资源句柄,而是通过统一 Release / Unload / Dispose 入口。

资源归属清晰:

资源属于哪个 UI 面板、哪个战斗对象、哪个场景、哪个作用域,要能说清楚。

生命周期边界明确:

UI 打开时加载,UI 关闭时释放。
场景进入时加载,场景退出时释放。
特效生成时持有,特效结束时释放。

作用域思维

可以把一组资源绑定到一个作用域:

using var scope = resourceManager.CreateScope("BagPanel");

scope.Load<Sprite>("icon_a");
scope.Load<Sprite>("icon_b");
scope.Load<GameObject>("item_prefab");

// 面板关闭时
scope.Dispose(); // 统一释放这个 scope 里持有过的资源

这样就不是靠人脑记:

我刚刚 Load 了哪些?
哪些要 Release?
有没有重复 Release?
有没有漏 Release?

而是让结构帮忙记账。

设计目标

一个稳健的资源系统应该尽量做到:

资源加载入口统一
资源释放入口统一
资源归属关系清晰
生命周期边界明确
异常、关闭、取消时也能走统一释放

下面这些常见工程概念,本质上都在解决同一个问题:

ResourceManager
AssetHandle
ResourceScope
UIWindowResourceTracker
AutoReleaseHandle

它们都在避免让每个业务代码自己手写资源账本。

最重要的收获

引用计数只是实现手段,所有权才是设计核心。

如果没有所有权,引用计数就只是一个容易被写错的数字。