如何在子场景卸载时自动释放其Addressable标签资源

解读

国内项目普遍采用“子场景(Sub-Scene)+ Addressable”做大世界分块副本动态加载,目的是降低首包大小、缩短进入游戏时间。
面试官真正想确认的是:

  1. 你是否理解 Addressable 的引用计数子场景生命周期是两条独立管线;
  2. 能否给出零泄漏、零卡顿、可维护的自动化方案,而不是让策划或美术手动填 Release 路径;
  3. 是否具备线上容错意识——一旦 Release 失败或异步操作被打断,如何兜底。

知识点

  • Addressable 标签(Label) 本身不是资源,只是查询 key;真正的引用计数挂在 LocationHandle 上。
  • SceneInstance 被 Unload 时,Unity 只卸载 Scene 对象,不会自动 Release Addressable 依赖;必须手动调用 Addressables.Release(handle)
  • 子场景在 Addressable 里通常打成一个 AssetBundle,其依赖项(贴图、Mesh、Shader 等)会被隐式引用;只要主 Bundle 的引用计数不归零,依赖 Bundle 也不会被卸载。
  • 国内主流热更框架(如 GameFramework、QFramework、Luban)都会封装一层 ResourceHolder,记录 handle→scene 的映射,防止重复加载或误释放。
  • 2021 LTS 之后,Addressable 提供 ResourceLocationMap.ChainOperation 接口,可以异步批量查询标签对应的所有 Location,避免同步遍历卡顿。
  • 释放时必须 先卸载子场景,再 Release handle,否则会因为 Unity 对象被销毁而触发 InvalidOperationException;若顺序反了,Profiler 里会看到 “Release while scene is still loaded” 警告。
  • 对于弱联网项目,还要考虑玩家断线重连:子场景卸载后若玩家重新进入,必须能无感知重新加载,因此释放前要把场景状态快照写到本地缓存,不能简单粗暴地清掉所有引用。

答案

  1. 统一封装 SubSceneManager,内部维护
    Dictionary<string, SceneInstance> loadedSubScenes;
    Dictionary<string, AsyncOperationHandle> sceneHandleMap;
    Key 统一用 子场景 Address 的 PrimaryKey(通常是“Assets/Scenes/Dungeon_01.unity”)。

  2. 加载时

    var handle = Addressables.LoadSceneAsync(sceneKey, LoadSceneMode.Additive, false);
    await handle.Task;
    loadedSubScenes[sceneKey] = handle.Result;
    sceneHandleMap[sceneKey] = handle;   // 记录 handle,用于后续释放
    
  3. 卸载时

    public async UniTask UnloadSubScene(string sceneKey)
    {
        if (!loadedSubScenes.TryGetValue(sceneKey, out var sceneInstance)) return;
    
        // 1. 先卸载场景
        await Addressables.UnloadSceneAsync(sceneInstance, true).ToUniTask();
    
        // 2. 再释放 Addressable 引用
        if (sceneHandleMap.TryGetValue(sceneKey, out var handle))
        {
            Addressables.Release(handle);
            sceneHandleMap.Remove(sceneKey);
        }
    
        loadedSubScenes.Remove(sceneKey);
    }
    

    注意:Release 必须在 UnloadSceneAsync 完成之后,否则会出现“Trying to release a scene that is still loaded”警告。

  4. 若需按标签批量释放(例如“Dungeon”标签下所有子场景),则

    var locations = await Addressables.LoadResourceLocationsAsync("Dungeon").Task;
    foreach (var loc in locations)
    {
        if (sceneHandleMap.TryGetValue(loc.PrimaryKey, out var h))
        {
            await UnloadSubScene(loc.PrimaryKey);
        }
    }
    
  5. 兜底策略

    • Application.lowMemory 回调里,遍历 sceneHandleMap,按**最近最少使用(LRU)**顺序释放,防止低端机 OOM;
    • 释放失败时捕获 InvalidKeyException,写本地日志并上报 Sentry,方便线上复盘;
    • 使用 Addressables.ReleaseInstance 而非 Release,如果将来把子场景改成 Prefab 动态实例化 也能复用同一套代码。

拓展思考

  1. 分帧释放:当子场景资源量巨大(>200 MB)时,一次性 Release 会造成 GPU 卡顿,可把 handle 拆成 N 批,每帧释放 20%,用 TimeSlicer 调度。
  2. 引用计数增强:在 handle 外层再包一层 WeakReference,配合 Custom Allocation Profiler 模块,可在 Editor 下可视化查看哪个子场景泄漏,解决国内项目“策划反复进退副本导致内存爆炸” 的老大难问题。
  3. 与 ILRuntime 热更兼容:若脚本端用 ILRuntime,跨域继承的 MonoBehaviour 会被 Hook 到 Addressable 的 ResourceLocator 中,卸载子场景前必须先 调用 ILRuntime 的 CrossDomain Clean,否则会出现“Can't destroy ScriptableObject”的 Fatal 错误。
  4. WebGL 特例:WebGL 平台没有文件系统缓存,Addressable 的 Bundle 卸载后再次加载要走 远程 CDN,必须提前把高频子场景 Bundle 打进 首包 StreamingAssets,避免玩家“进退副本”时反复下载;此时释放策略改为只减引用计数不真正卸载,等玩家退回登录界面再统一 Caching.ClearCache()