Unity资源引用计数机制:解决异步场景卸载内存泄漏
1. 为什么“卸载场景”会变成Unity项目里的定时炸弹在Unity项目做到中后期尤其是接入了模块化加载、热更或AB包体系之后“卸载场景”这件事就从一个API调用悄然演变成一场资源泄漏排查的噩梦。我见过太多团队——包括我们自己最早做MMO客户端时——在切场景后内存不降反升Profiler里Resources.UnloadUnusedAssets()调用后仍残留大量Texture2D、Mesh、Shader对象甚至出现“场景已卸载但UI prefab还在引用旧场景Camera”的诡异现象。问题不在于SceneManager.UnloadSceneAsync()没执行而在于它只负责卸载场景图Scene Graph对场景内所有GameObject、Component、Asset的引用关系完全不感知。Unity不会、也不能替你判断“这个Texture是不是被其他地方悄悄持有了”“这个ScriptableObject是不是被全局EventSystem缓存了”——它只管“场景容器”本身。这正是标题里强调“基于引用计数”的根本原因Unity原生卸载机制是“粗放式”的而真实项目需要的是“精准式”的资源生命周期管理。引用计数不是Unity内置功能而是我们必须亲手构建的一套轻量级契约机制——它不依赖任何第三方插件不修改Unity底层仅靠几行可审计的C#代码就能让每个资源知道自己被多少个活跃对象持有当计数归零时才真正触发销毁与卸载。关键词“异步场景卸载”背后其实是两个强耦合但常被混淆的问题一是场景切换的流畅性避免卡顿二是资源释放的确定性避免泄漏。前者靠UnloadSceneAsync协程调度解决后者必须靠引用计数兜底。没有后者前者越快内存雪球滚得越猛。这篇文章面向的不是刚学Instantiate的新手而是已经踩过3次以上Object.Destroy失效、Resources.UnloadUnusedAssets无效、Addressables.Release报错的中高级Unity开发者。你不需要懂IL2CPP内存模型但得清楚AssetBundle.Unload(true)和false的区别你不需要手写GC Root分析器但得明白static字段、event委托、Coroutine隐式引用是如何让资源“死而不僵”的。接下来我会带你从零搭起这套机制不讲虚的每一步都对应一个真实崩溃现场。2. 引用计数不是新概念而是Unity资源管理的必然补丁很多人一听“引用计数”第一反应是“这不就是COM时代的老古董Unity有GC还要手动计数”——这种理解错失了核心矛盾点。Unity的GCMono GC只管理托管堆Managed Heap中的对象比如ListT、string、自定义class实例。但它完全不管理非托管资源Unmanaged ResourcesTexture2D背后的GPU显存、Mesh的顶点缓冲区、AudioClip的音频解码上下文、甚至WWW/UnityWebRequest的网络句柄全由Unity原生层C分配GC无法触达。这些资源的释放入口只有两个显式调用Object.Destroy()或DestroyImmediate或等待Resources.UnloadUnusedAssets()的被动扫描。而后者的问题在于它只检查“是否被任何托管对象引用”却无法识别“是否被非托管对象间接持有”。举个典型例子一个UI Panel预制体里有个Image组件其sprite.texture引用了一个Atlas Texture。当你卸载该Panel所在场景后Panel GameObject被DestroyImage Component被回收但那个Atlas Texture可能正被另一个未卸载场景里的HUD Canvas偷偷复用——此时Resources.UnloadUnusedAssets()绝不会动它因为CanvasRenderer内部C层仍持有该Texture的显存句柄。这就是为什么你总看到Profiler里Texture内存居高不下。引用计数要解决的恰恰是这个“跨场景、跨模块、跨生命周期”的资源共享信任问题。它的设计哲学不是替代GC而是在GC的盲区之上构建一层轻量级的、可预测的资源所有权契约。具体来说它要求每个需要被共享的资源Texture、Mesh、Material、ScriptableObject等必须包装在一个可计数的代理对象中所有对该资源的“获取”操作如GetTexture(ui/atlas)必须显式调用AddRef()所有“释放”操作如Panel销毁时必须显式调用Release()当计数归零时触发Object.Destroy()Resources.UnloadUnusedAssets()针对Resources加载或AssetBundle.Unload(true)针对AB包。这不是过度设计。我参与过的6个上线项目中4个在接入热更系统后因AB包卸载逻辑混乱导致闪退根源全是AssetBundle.Unload(false)后纹理被其他模块继续使用——而引用计数能让你在Unload(false)前通过计数确认“是否真没人用了”。它把“不确定的被动回收”变成了“确定的主动契约”。下面这张表对比了三种常见资源释放方式的本质差异方式触发时机资源可见性是否可预测典型失败场景Object.Destroy(obj)立即或下一帧立即从场景移除高但仅限当前引用多处持有同一obj一处Destroy导致其他处NullReferenceResources.UnloadUnusedAssets()主动调用耗时长延迟需GC标记扫描低受所有静态引用影响全局EventSystem订阅了某个脚本的事件导致脚本无法回收引用计数代理Release()调用时计数归零即触发销毁极高契约驱动无只要遵守AddRef/Release配对规则关键点在于引用计数不改变Unity的底层机制它只是给开发者提供了一套“行为规范”。就像交通规则不造车但能让所有车安全通行。接下来我们就用最简练的代码实现这个规范。3. 从0到1搭建引用计数资源代理系统核心类设计与线程安全考量真正的工程落地从来不是堆砌炫技代码而是用最少的抽象解决最痛的点。我们的引用计数系统只包含3个核心类RefCountedAssetT泛型代理基类、RefCountedTextureTexture特化版、ResourcePool全局资源池。它们加起来不到200行却覆盖95%的资源管理需求。先看RefCountedAssetT的设计意图它必须包裹任意UnityEngine.Object子类Texture、Mesh、Material等提供AddRef()、Release()、Get()三个接口并保证线程安全——因为资源加载/卸载常发生在协程或AssetBundle加载回调中而UI更新可能在主线程计数操作必须原子化。public abstract class RefCountedAssetT : ScriptableObject where T : UnityEngine.Object { [SerializeField] private T _asset; [SerializeField] private int _refCount 0; // 使用Interlocked确保多线程下计数增减原子性 public void AddRef() Interlocked.Increment(ref _refCount); public bool Release() { int newCount Interlocked.Decrement(ref _refCount); if (newCount 0) { OnDestroy(); return true; // 已销毁 } return false; // 仍有引用 } public T Get() _asset; // 子类必须实现定义资源销毁逻辑 protected abstract void OnDestroy(); }为什么用ScriptableObject而非普通class因为ScriptableObject是Unity原生对象可序列化、可挂载Inspector、生命周期与Unity Editor同步且Destroy(this)能正确触发OnDestroy。更重要的是它天然支持HideFlags.DontSave避免被意外保存进场景。Interlocked系列方法是.NET提供的无锁原子操作比lock块更轻量适合高频计数场景。这里有个易错点_refCount初始值必须为0而非1。因为资源创建时如CreateInstanceRefCountedTexture()它尚未被任何业务方持有计数应为0第一次AddRef()才变为1。若初始化为1会导致Release()一次就归零销毁违背契约。再看RefCountedTexture的具体实现它展示了如何将“销毁逻辑”与Unity资源特性绑定public class RefCountedTexture : RefCountedAssetTexture2D { protected override void OnDestroy() { if (_asset ! null) { // 关键区分资源来源执行不同卸载策略 if (IsFromResources()) { Destroy(_asset); // Resources.Load的资源用Destroy } else if (IsFromAssetBundle()) { // 此处需关联AssetBundle实例实际项目中通过AssetBundleManager维护 AssetBundleManager.Instance.UnloadBundleForTexture(_asset); } else { Destroy(_asset); // 默认按Resources处理 } } Destroy(this); // 销毁代理自身 } private bool IsFromResources() _asset ! null _asset.hideFlags.HasFlag(HideFlags.NotEditable); private bool IsFromAssetBundle() _asset ! null _asset.name.Contains(_ab_); // 实际项目用更可靠的标记如自定义AssetBundleName字段 }这里暴露了Unity资源管理的灰色地带同一个Texture2D对象其销毁方式取决于它从哪来。Resources.Load的资源必须用Destroy()AssetBundle.LoadAsset的则必须调用AssetBundle.Unload(true)否则显存泄漏。RefCountedTexture通过hideFlags和命名约定做轻量判断避免引入复杂元数据系统。ResourcePool则是全局单例负责资源的创建、复用与查找public class ResourcePool : MonoBehaviour { private static ResourcePool _instance; public static ResourcePool Instance _instance; private readonly Dictionarystring, RefCountedAssetUnityEngine.Object _pool new Dictionarystring, RefCountedAssetUnityEngine.Object(); private void Awake() { if (_instance ! null _instance ! this) Destroy(gameObject); _instance this; DontDestroyOnLoad(gameObject); } public T GetOrCreateT(string key, FuncT factory) where T : RefCountedAssetUnityEngine.Object { if (_pool.TryGetValue(key, out var existing)) { existing.AddRef(); return (T)existing; } var newInstance factory(); newInstance.AddRef(); // 新建即持有1引用 _pool[key] newInstance; return newInstance; } }DontDestroyOnLoad确保池子跨场景存活GetOrCreate方法是核心它用key如ui/atlas查表命中则AddRef并返回未命中则factory创建新实例AddRef后存入池。注意factory返回的是代理对象不是原始资源——业务代码永远只跟代理打交道。这样设计的好处是资源加载逻辑Resources.Load或Addressables.LoadAssetAsync被隔离在factory中上层业务无需关心来源。最后强调一个血泪教训绝对不要在OnDestroy里调用Resources.UnloadUnusedAssets()。它是个重操作会阻塞主线程且在Release()链式调用中极易引发递归或死锁。我们把它移到ResourcePool的CleanupStaleEntries()方法中由业务方在场景切换完成后的空闲帧手动触发完全可控。4. 异步场景卸载的完整工作流从加载到卸载的引用闭环现在引用计数系统已就位但如何让它与SceneManager.LoadSceneAsync/UnloadSceneAsync无缝协同这才是“终极指南”的落点。很多团队失败不是因为计数逻辑写错了而是因为没理清“谁在什么时候该AddRef/Release”。我们以一个标准的模块化UI系统为例主城场景加载时需要显示背包面板BagPanel.prefab该面板依赖ui/bag_atlas纹理。整个流程必须形成闭环任何一环断裂泄漏即发生。下面是经过7个项目验证的标准化工作流分5个阶段4.1 阶段一场景加载前的资源预热Preload在调用SceneManager.LoadSceneAsync(MainCity)之前先通过ResourcePool预热所有该场景必需的资源代理// 在加载按钮点击事件中 public async void OnLoadMainCityClicked() { // 1. 预热资源获取代理并AddRef var atlasProxy ResourcePool.Instance.GetOrCreate( ui/bag_atlas, () { var tex Resources.LoadTexture2D(ui/bag_atlas); var proxy ScriptableObject.CreateInstanceRefCountedTexture(); proxy._asset tex; return proxy; }); // 2. 启动异步加载 var op SceneManager.LoadSceneAsync(MainCity, LoadSceneMode.Additive); await op.ToUniTask(); // 使用UniTask简化await // 3. 场景加载完成后才真正使用资源 if (op.isDone) { Instantiate(BagPanelPrefab, canvas.transform); // BagPanel脚本内部会通过ResourcePool.Get(ui/bag_atlas)获取代理 // 并调用proxy.Get()拿到Texture2D赋值给Image } }关键点GetOrCreate必须在LoadSceneAsync之前调用否则场景加载过程中BagPanel的Awake可能早于资源预热完成导致空引用。预热本质是“提前建立引用契约”确保资源在场景需要时已就绪。4.2 阶段二场景内资源的按需获取OnDemandBagPanel的Awake中不直接Resources.Load而是向ResourcePool索要代理public class BagPanel : MonoBehaviour { private RefCountedTexture _atlasProxy; private void Awake() { // 从池中获取已预热的代理AddRef计数1 _atlasProxy ResourcePool.Instance.GetOrCreate( ui/bag_atlas, () null // 此处不创建因已预热 ); // 获取原始Texture var atlas _atlasProxy.Get(); if (atlas ! null) { image.sprite Sprite.Create(atlas, new Rect(0,0,atlas.width,atlas.height), Vector2.zero); } } }这里GetOrCreate的第二个参数传null表示“只取不创”避免重复创建。_atlasProxy作为成员变量持有确保BagPanel生命周期内资源不被误释放。4.3 阶段三场景卸载前的引用释放Pre-unload Cleanup当用户点击“返回主菜单”时不能直接UnloadSceneAsync。必须先通知所有活跃模块释放资源public async void OnBackToMenuClicked() { // 1. 通知BagPanel释放资源 var bagPanels FindObjectsOfTypeBagPanel(); foreach (var panel in bagPanels) { panel.OnSceneExit(); // 自定义方法触发Release } // 2. 等待所有Release完成通常瞬间 await UniTask.DelayFrame(1); // 3. 卸载场景 var op SceneManager.UnloadSceneAsync(MainCity); await op.ToUniTask(); // 4. 清理资源池中已无引用的条目 ResourcePool.Instance.CleanupStaleEntries(); }BagPanel.OnSceneExit()实现为public void OnSceneExit() { if (_atlasProxy ! null) { _atlasProxy.Release(); // 计数-1 _atlasProxy null; } }CleanupStaleEntries()遍历池字典对每个代理调用Release()若返回true计数归零则从字典中移除。这是防止池子无限膨胀的关键。4.4 阶段四跨场景资源的持久化策略Cross-scene Persistence有些资源必须跨场景存在如全局音效库、角色动画控制器。这时不能用GetOrCreate而要用GetOrPersistpublic T GetOrPersistT(string key, FuncT factory) where T : RefCountedAssetUnityEngine.Object { if (_pool.TryGetValue(key, out var existing)) { existing.AddRef(); return (T)existing; } var newInstance factory(); newInstance.AddRef(); // 标记为持久化永不自动Cleanup _persistentKeys.Add(key); _pool[key] newInstance; return newInstance; }_persistentKeys是HashSetstringCleanupStaleEntries()会跳过这些key。这样音效资源在切场景时计数始终≥1不会被误销毁。4.5 阶段五异常路径的兜底保障Fallback Safety最后必须处理Destroy(gameObject)被绕过的场景。例如用户强制关闭App或Editor中Stop Play。我们在ResourcePool的OnApplicationQuit和OnDisable中添加强制清理private void OnApplicationQuit() { ForceCleanupAll(); } private void OnDisable() { if (Application.isPlaying) ForceCleanupAll(); } private void ForceCleanupAll() { foreach (var kvp in _pool.ToList()) // ToList避免遍历时修改字典 { if (kvp.Value ! null kvp.Value is RefCountedAssetUnityEngine.Object asset) { while (asset.Release()) { } // 循环Release直到计数≤0 } } _pool.Clear(); }while (asset.Release())确保即使计数异常如多次AddRef未配对Release也能强制归零。这是最后一道保险虽不优雅但保命。5. 真实项目踩坑实录那些文档里绝不会写的细节理论框架搭好不等于实战畅通。我在3个重度依赖AB包的项目中总结出5个高频、隐蔽、且官方文档几乎不提的坑每个都附带定位方法和修复代码。这些不是“可能遇到”而是“必然遇到”。5.1 坑一Coroutine隐式引用导致计数永不归零现象BagPanel已Destroy()但RefCountedTexture的_refCount始终为1Release()返回false。根因BagPanel中启动了一个IEnumerator LoadData()协程该协程在yield return new WaitForSeconds(1)后尝试访问_atlasProxy.Get()。Unity的协程系统会隐式持有BagPanel的引用直到协程彻底结束。即使BagPanel被Destroy协程仍在运行_atlasProxy被间接持有。定位在RefCountedAsset.Release()中加日志Debug.Log($Release called, count{_refCount} from {Environment.StackTrace});观察调用栈是否包含StartCoroutine相关帧。修复在OnDestroy中显式停止协程private void OnDestroy() { StopAllCoroutines(); // 必须 _atlasProxy?.Release(); }5.2 坑二ScriptableObject的hideFlags导致资源无法销毁现象RefCountedTexture代理对象Destroy(this)后其包裹的Texture2D仍在内存中且_asset.hideFlags显示为HideFlags.HideAndDontSave。根因ScriptableObject.CreateInstance创建的对象默认hideFlagsHideFlags.None但若在Editor中手动拖拽赋值Unity会自动设为HideAndDontSave导致Destroy(_asset)无效Unity认为这是编辑器资源不可运行时销毁。定位在OnDestroy中打印_asset.hideFlags若非None或HideAndDontSave则有问题。修复创建代理时强制重置var proxy ScriptableObject.CreateInstanceRefCountedTexture(); proxy.hideFlags HideFlags.DontSave; // 关键 proxy._asset tex;5.3 坑三Addressables与引用计数的冲突现象使用Addressables加载的资源Release()后AssetBundle.Unload(true)报错“Bundle is still referenced”。根因Addressables内部维护了自己的引用计数Addressables.Release(handle)会减少其计数但我们的RefCountedAsset又额外增加了一层。两者未同步。定位查看Addressables的ResourceManager源码确认其Release逻辑。修复放弃RefCountedAsset包装Addressables资源改用Addressables原生API并在其Completed回调中调用ResourcePool.CleanupStaleEntries()var handle Addressables.LoadAssetAsyncTexture2D(ui/bag_atlas); await handle.Task; var tex handle.Result; // 直接使用tex不包装 // 在场景退出时Addressables.Release(handle);即对Addressables资源信任其原生计数对Resources/AB包资源用我们的计数。混合方案更稳健。5.4 坑四UI Particle System的材质引用泄漏现象场景卸载后ParticleRenderer使用的Material仍被持有RefCountedMaterial计数不归零。根因ParticleSystem组件会缓存Material的副本即使ParticleSystem被Destroy其内部C层仍持有材质句柄。定位在Profiler的Memory视图中筛选Material右键Take Heap Snapshot用Deep Profiling查看GC Roots。修复在ParticleSystem.Stop()后手动清除材质private void OnDestroy() { if (ps ! null) { ps.Stop(); ps.Clear(); // 关键清除粒子缓存 ps.GetComponentRenderer().material null; // 断开引用 } }5.5 坑五Editor模式下资源卸载的假象现象Editor中UnloadSceneAsync后内存下降但Build后Android包内存不降。根因Editor的Resources.UnloadUnusedAssets()在Play Mode Exit时会强制执行掩盖了运行时问题而真机上必须手动调用且UnloadUnusedAssets()在Android上耗时极长常100ms开发者常忽略。定位在真机上用ADB命令adb shell dumpsys meminfo package监控PSS内存对比UnloadSceneAsync前后。修复在ResourcePool.CleanupStaleEntries()末尾添加条件调用#if !UNITY_EDITOR Resources.UnloadUnusedAssets(); // 真机必须调用 #endif并确保此调用不在主线程密集帧中建议用InvokeRepeating(UnloadUnused, 0.5f, 1f)延迟执行。6. 性能与扩展性平衡当引用计数成为瓶颈时怎么办引用计数系统虽轻量但在超大型项目如开放世界单场景含5000动态物体中Interlocked操作和字典查找可能成为瓶颈。我们曾在一个AR项目中遇到每帧创建/销毁数百个RefCountedAssetGetOrCreate调用耗时峰值达8ms。优化不是删除计数而是重构其作用域。核心原则计数粒度必须与资源生命周期对齐而非与对象数量对齐。6.1 粒度优化从“每个资源一个代理”到“每组资源一个代理”问题一个场景加载100个独立小图标icon_001.png ~ icon_100.png为每个创建RefCountedTexture产生100个代理对象字典查找开销大。方案改为一个RefCountedAtlas代理管理整个图集public class RefCountedAtlas : RefCountedAssetTexture2D { public ListSprite sprites; // 图集中所有Sprite protected override void OnDestroy() { // 销毁整个图集Texture而非单个Sprite Destroy(_asset); Destroy(this); } }业务代码通过atlasProxy.sprites[0]获取图标AddRef/Release操作针对整个图集。计数对象从100个降到1个字典查找从100次降到1次。6.2 查找优化从Dictionary到ConcurrentDictionary LRU Cache当资源Key极多10000Dictionarystring, T的TryGetValue在哈希冲突时退化为O(n)。升级为ConcurrentDictionary并添加LRU缓存private readonly ConcurrentDictionarystring, WeakReferenceRefCountedAssetUnityEngine.Object _pool new ConcurrentDictionarystring, WeakReferenceRefCountedAssetUnityEngine.Object(); private readonly LinkedListstring _lruList new LinkedListstring(); private readonly object _lruLock new object(); public T GetOrCreateT(string key, FuncT factory) where T : RefCountedAssetUnityEngine.Object { if (_pool.TryGetValue(key, out var weakRef) weakRef.TryGetTarget(out var target)) { target.AddRef(); return (T)target; } // 缓存未命中创建新实例 var newInstance factory(); newInstance.AddRef(); _pool[key] new WeakReferenceRefCountedAssetUnityEngine.Object(newInstance); // LRU管理 lock (_lruLock) { var node _lruList.AddLast(key); if (_lruList.Count 1000) // 限制缓存大小 { var oldest _lruList.First.Value; _lruList.RemoveFirst(); _pool.TryRemove(oldest, out _); } } return newInstance; }WeakReference避免代理对象被字典强引用LRU确保高频Key常驻内存ConcurrentDictionary支持多线程安全。6.3 卸载优化从“逐个Release”到“批量Unload”CleanupStaleEntries()遍历字典逐个Release在代理数1000时耗时显著。改为批量public void BatchCleanup(Liststring keysToCleanup) { foreach (var key in keysToCleanup) { if (_pool.TryRemove(key, out var weakRef) weakRef.TryGetTarget(out var target)) { // 批量收集待销毁的原始资源 _pendingDestroys.Add(target._asset); } } // 一次性销毁所有原始资源 foreach (var asset in _pendingDestroys) Destroy(asset); _pendingDestroys.Clear(); }_pendingDestroys是ListUnityEngine.ObjectDestroy调用可批量提交减少Unity内部状态切换开销。6.4 最后一条经验计数不是银弹日志才是你的氧气面罩无论优化多完善线上环境总有意外。我们在每个RefCountedAsset的AddRef/Release中加入条件日志#if REF_COUNT_DEBUG Debug.Log($[{GetType().Name}] AddRef to {_asset.name}, count{_refCount} at {Time.frameCount}); #endif通过#define REF_COUNT_DEBUG控制开关打包时关闭。线上Crash时通过adb logcat | grep RefCount快速定位泄漏源头。记住在内存问题上可观察性比性能优化重要十倍。你永远无法优化一个你看不见的问题。我在实际项目中发现最有效的调试方式不是盯着Profiler而是打开Debug.Log让每一笔引用都说话。当Release调用次数远少于AddRef时问题立刻浮出水面。这套系统没有魔法它只是把模糊的“可能泄漏”变成了清晰的“计数不匹配”而后者是工程师可以解决的问题。