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的GC(Mono GC)只管理托管堆(Managed Heap)中的对象,比如List<T>、string、自定义class实例。但它完全不管理非托管资源(Unmanaged Resources):Texture2D背后的GPU显存、Mesh的顶点缓冲区、AudioClip的音频解码上下文、甚至WWW/UnityWebRequest的网络句柄,全由Unity原生层(C++)分配,GC无法触达。这些资源的释放入口,只有两个:显式调用Object.Destroy()(或DestroyImmediate),或等待Resources.UnloadUnusedAssets()的被动扫描。而后者的问题在于:它只检查“是否被任何托管对象引用”,却无法识别“是否被非托管对象间接持有”。举个典型例子:一个UI Panel预制体里有个Image组件,其sprite.texture引用了一个Atlas Texture。当你卸载该Panel所在场景后,Panel GameObject被Destroy,Image 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导致其他处NullReference |
Resources.UnloadUnusedAssets() | 主动调用,耗时长 | 延迟(需GC标记+扫描) | 低(受所有静态引用影响) | 全局EventSystem订阅了某个脚本的事件,导致脚本无法回收 |
| 引用计数代理 | Release()调用时 | 计数归零即触发销毁 | 极高(契约驱动) | 无(只要遵守AddRef/Release配对规则) |
关键点在于:引用计数不改变Unity的底层机制,它只是给开发者提供了一套“行为规范”。就像交通规则不造车,但能让所有车安全通行。接下来,我们就用最简练的代码,实现这个规范。
3. 从0到1搭建引用计数资源代理系统:核心类设计与线程安全考量
真正的工程落地,从来不是堆砌炫技代码,而是用最少的抽象,解决最痛的点。我们的引用计数系统只包含3个核心类:RefCountedAsset<T>(泛型代理基类)、RefCountedTexture(Texture特化版)、ResourcePool(全局资源池)。它们加起来不到200行,却覆盖95%的资源管理需求。先看RefCountedAsset<T>的设计意图:它必须包裹任意UnityEngine.Object子类(Texture、Mesh、Material等),提供AddRef()、Release()、Get()三个接口,并保证线程安全——因为资源加载/卸载常发生在协程或AssetBundle加载回调中,而UI更新可能在主线程,计数操作必须原子化。
public abstract class RefCountedAsset<T> : 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。因为资源创建时(如CreateInstance<RefCountedTexture>()),它尚未被任何业务方持有,计数应为0;第一次AddRef()才变为1。若初始化为1,会导致Release()一次就归零销毁,违背契约。
再看RefCountedTexture的具体实现,它展示了如何将“销毁逻辑”与Unity资源特性绑定:
public class RefCountedTexture : RefCountedAsset<Texture2D> { 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 Dictionary<string, RefCountedAsset<UnityEngine.Object>> _pool = new Dictionary<string, RefCountedAsset<UnityEngine.Object>>(); private void Awake() { if (_instance != null && _instance != this) Destroy(gameObject); _instance = this; DontDestroyOnLoad(gameObject); } public T GetOrCreate<T>(string key, Func<T> factory) where T : RefCountedAsset<UnityEngine.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.Load<Texture2D>("ui/bag_atlas"); var proxy = ScriptableObject.CreateInstance<RefCountedTexture>(); 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 阶段二:场景内资源的按需获取(OnDemand)
BagPanel的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 = FindObjectsOfType<BagPanel>(); 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,而要用GetOrPersist:
public T GetOrPersist<T>(string key, Func<T> factory) where T : RefCountedAsset<UnityEngine.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是HashSet<string>,CleanupStaleEntries()会跳过这些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 RefCountedAsset<UnityEngine.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始终为1,Release()返回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创建的对象默认hideFlags=HideFlags.None,但若在Editor中手动拖拽赋值,Unity会自动设为HideAndDontSave,导致Destroy(_asset)无效(Unity认为这是编辑器资源,不可运行时销毁)。
定位:在OnDestroy中打印_asset.hideFlags,若非None或HideAndDontSave,则有问题。
修复:创建代理时强制重置:
var proxy = ScriptableObject.CreateInstance<RefCountedTexture>(); 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.LoadAssetAsync<Texture2D>("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.GetComponent<Renderer>().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项目中遇到:每帧创建/销毁数百个RefCountedAsset,GetOrCreate调用耗时峰值达8ms。优化不是删除计数,而是重构其作用域。核心原则:计数粒度必须与资源生命周期对齐,而非与对象数量对齐。
6.1 粒度优化:从“每个资源一个代理”到“每组资源一个代理”
问题:一个场景加载100个独立小图标(icon_001.png ~ icon_100.png),为每个创建RefCountedTexture,产生100个代理对象,字典查找开销大。
方案:改为一个RefCountedAtlas代理,管理整个图集:
public class RefCountedAtlas : RefCountedAsset<Texture2D> { public List<Sprite> 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极多(>10000),Dictionary<string, T>的TryGetValue在哈希冲突时退化为O(n)。升级为ConcurrentDictionary并添加LRU缓存:
private readonly ConcurrentDictionary<string, WeakReference<RefCountedAsset<UnityEngine.Object>>> _pool = new ConcurrentDictionary<string, WeakReference<RefCountedAsset<UnityEngine.Object>>>(); private readonly LinkedList<string> _lruList = new LinkedList<string>(); private readonly object _lruLock = new object(); public T GetOrCreate<T>(string key, Func<T> factory) where T : RefCountedAsset<UnityEngine.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 WeakReference<RefCountedAsset<UnityEngine.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(List<string> 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是List<UnityEngine.Object>,Destroy调用可批量提交,减少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时,问题立刻浮出水面。这套系统没有魔法,它只是把模糊的“可能泄漏”变成了清晰的“计数不匹配”,而后者,是工程师可以解决的问题。
