1. 从一个真实的内存泄漏案例说起
前阵子帮朋友排查一个 Unity 项目,场景是这样的:一个卡牌游戏,战斗界面反复打开关闭,每次关闭再打开,内存就往上蹿一截,打开个二三十次,低端机上直接闪退。朋友很困惑,他说代码里该置 null 的都置 null 了,该 Destroy 的也 Destroy 了,C# 不是有 GC 吗,垃圾回收器不是会自动清理吗,怎么还会泄漏?
这个问题其实特别典型。我敢说,只要用 Unity 做过超过半年的项目,几乎都会踩到这个坑。GC 和内存泄漏并不是互斥的概念,很多人对 GC 有一个误解,觉得只要托管堆上的对象没人引用了,GC 就会自动收走,所以不可能泄漏。但现实是,GC 只能回收"没有任何引用指向它"的对象。只要还有一条引用链能从 GC Root 走到这个对象,哪怕你觉得这个对象"逻辑上已经没用了",GC 也拿它没办法。
而事件订阅,恰恰是最容易制造这种"隐形引用链"的地方。C# 里的 event 本质是一个委托链,发布者持有订阅者的方法引用,订阅者就被发布者"拽住"了。如果发布者是长生命周期的对象(比如单例、静态类、常驻的管理器),订阅者是短生命周期的对象(比如每次打开都新建的 UI 面板),那么只要不取消订阅,这个短生命周期对象就永远死不掉,它引用的所有资源——纹理、Mesh、GameObject——全都跟着一起陪葬。
这篇文章我就把这件事从头到尾讲透:GC 到底怎么工作、事件订阅为什么会导致泄漏、怎么用工具定位、怎么从代码层面根治。内容偏实战,代码都是可以直接抄的,适合有一定 C# 和 Unity 基础、被内存问题折磨过的开发者。
2. 先搞清楚 GC 到底管什么、不管什么
2.1 托管堆和非托管资源是两码事
要理解"有 GC 为什么还会泄漏",第一步得把内存分成两块来看。
托管内存:你在 C# 里 new 出来的 class 实例、数组、字符串、委托,这些都分配在托管堆上,由 GC 负责回收。GC 判断一个对象能不能回收,靠的是"可达性分析"——从 GC Root(静态字段、线程栈上的局部变量、CPU 寄存器等)出发,沿着引用链一路找,能找到的对象就是"活的",找不到的就是"死的",死的就可以回收。
非托管内存:Texture2D 的像素数据、Mesh 的顶点缓冲、NativeArray、通过Marshal.AllocHGlobal分配的内存、第三方 SDK 里 C++ 层申请的内存,这些 GC 根本看不见。它们通常被托管对象通过一个 IntPtr 或句柄"间接持有",托管对象被回收时,如果实现了IDisposable或者有终结器(Finalizer),才会顺带把非托管部分释放掉。
所以内存泄漏其实分两种:一种是托管对象被意外持有,GC 收不掉,连带它引用的非托管资源也一起泄漏;另一种是托管对象被正常回收了,但非托管资源因为没调用 Dispose 而泄漏。事件订阅导致的泄漏,绝大多数属于第一种。
2.2 GC Root 到底包含哪些东西
很多人对 GC Root 的理解是模糊的,这里我列一下实际会被当作根的对象:
- 静态字段引用的对象(包括 static 属性、static 事件)
- 当前线程栈上活跃的局部变量和参数
- CPU 寄存器里正在使用的引用
- 被
GCHandle显式固定的对象 - 终结器队列里等待执行的对象
关键点在于静态字段。Unity 里大量的 Manager、单例、静态事件,生命周期和整个 AppDomain 一样长,它们引用的任何东西都别想被回收。这就是事件订阅泄漏的温床。
2.3 一个最小可复现的泄漏例子
光说理论没感觉,直接上代码。假设我们有一个全局的事件中心:
public static class EventCenter { public static event Action<int> OnScoreChanged; public static void RaiseScoreChanged(int score) { OnScoreChanged?.Invoke(score); } }然后有一个战斗结算面板,每次打开都 new 一个:
public class BattleResultPanel : MonoBehaviour { private byte[] _bigBuffer = new byte[1024 * 1024 * 10]; // 10MB 占位 private void OnEnable() { EventCenter.OnScoreChanged += HandleScoreChanged; } private void HandleScoreChanged(int score) { Debug.Log($"Score: {score}"); } // 注意:这里故意没写 OnDisable 里取消订阅 }每次打开面板,EventCenter.OnScoreChanged这个静态委托链上就多挂一个BattleResultPanel.HandleScoreChanged。这个委托的 Target 指向那个面板实例,面板实例又持有 10MB 的 buffer。面板 GameObject 被 Destroy 了,但静态事件还拽着它,GC 永远收不掉。打开 30 次,就是 300MB 的泄漏,闪退是必然的。
你可以自己写个测试脚本,循环 Instantiate 和 Destroy 这个面板,然后调GC.Collect(),再用Profiler.GetTotalAllocatedMemoryLong()看数字,会发现内存纹丝不动。这就是最直观的证据。
3. 事件订阅泄漏的几种典型形态
3.1 静态事件 + 实例方法:最经典的组合
上面那个例子就是这种。判断标准很简单:发布者的生命周期 > 订阅者的生命周期,且订阅者没有在销毁时取消订阅,就会泄漏。
静态事件是最危险的,因为它的生命周期等于整个进程。除此之外,还有几类"长命"的发布者:
- 单例 Manager(比如
GameManager.Instance.OnStateChanged) - 场景常驻对象(
DontDestroyOnLoad的 GameObject 上的事件) - 静态委托字段(不是 event,是
public static Action xxx) - 第三方 SDK 的回调注册(很多 SDK 内部就是静态事件)
3.2 匿名方法、Lambda 和闭包:更隐蔽的坑
比实例方法更阴险的是 Lambda。看这段:
public class HpBar : MonoBehaviour { private void Start() { // 这个 lambda 捕获了 this Player.Instance.OnHpChanged += (hp) => UpdateBar(hp); } private void UpdateBar(int hp) { /* ... */ } }这个 lambda 编译后会生成一个编译器生成的闭包类实例,这个闭包实例持有this(因为要调用UpdateBar)。你没法用-=取消订阅,因为你根本没有那个委托的引用。每次 Start 都注册一个新的,旧的永远留在委托链上。
我见过最夸张的一个项目,一个血条脚本在 Update 里注册事件,一秒钟注册 60 次,跑十分钟委托链上挂了 36000 个回调,每次触发事件要遍历三万多遍,帧率直接崩了。这既是内存泄漏,也是性能灾难。
3.3 UnityEvent 和 Inspector 拖拽绑定
UnityEvent 相对安全一点,因为它在 Inspector 里可视化,取消订阅也直观。但有个坑:如果你在代码里动态AddListener,同样要记得RemoveListener。而且 UnityEvent 的RemoveListener对匿名方法无效,原因和上面一样。
另外,Inspector 里拖拽绑定的 UnityEvent,如果目标对象被 Destroy 了,Unity 会自动清理这个绑定(Unity 内部做了处理),所以相对不容易泄漏。但代码动态添加的就没这个待遇了。
3.4 委托链的"雪崩效应"
一个订阅者泄漏,往往不是泄漏它自己那么简单。它引用的所有字段——子对象、纹理、List、Dictionary——全都跟着泄漏。如果这个订阅者还订阅了别的事件,或者被别的对象引用,泄漏范围会像滚雪球一样扩大。我排查过一个案例,一个泄漏的 UI 面板间接拽住了整个战斗场景的 200 多个对象,Profiler 里看引用链足足有十几层。
4. 用工具把泄漏"抓现行"
4.1 Unity Profiler 的基本用法
光看代码很难确定到底哪里漏了,必须上工具。Unity Profiler 的 Memory 模块是最基础的入口。
操作步骤:
- 打开
Window > Analysis > Profiler,切到Memory面板 - 在场景里反复打开关闭可疑的界面,比如 10 次
- 手动调一次
GC.Collect()(可以在代码里加个按钮触发) - 观察
Total Used Memory和Managed Heap是否回落
如果反复操作后内存阶梯式上升、GC 后也不回落,基本可以确定有泄漏。但 Profiler 只能告诉你"漏了",不能告诉你"谁漏的",这时候需要更细的工具。
4.2 Memory Profiler 包:定位引用链的利器
com.unity.memoryprofiler这个包是排查托管泄漏的核心工具。安装后在Window > Analysis > Memory Profiler打开。
工作流是这样的:
- 打开界面,点
Capture抓一个快照,标记为 Snapshot A - 打开关闭目标界面若干次
- 再抓一个快照,标记为 Snapshot B
- 在 Snapshot B 里选
Diff对比 Snapshot A,看哪些对象数量增加了
重点看那些"应该被销毁但数量还在涨"的类型。找到可疑类型后,点进去看References,它会显示"谁引用了这个对象"。如果引用链的顶端是某个静态事件或者单例,基本就实锤了。
我个人的习惯是:先按Managed Objects排序,找数量异常增长的类;再看它的Referenced By,一层层往上追,直到追到 GC Root。这个过程有点像破案,但一旦追到根,问题就迎刃而解。
4.3 一个实用的排查脚本
在等 Memory Profiler 抓快照的间隙,可以先用代码快速验证。写一个简单的计数脚本:
public class LeakDetector : MonoBehaviour { private void Update() { if (Input.GetKeyDown(KeyCode.L)) { GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); long managed = GC.GetTotalMemory(false); long unity = Profiler.GetTotalAllocatedMemoryLong(); Debug.Log($"Managed: {managed / 1024 / 1024} MB, Unity: {unity / 1024 / 1024} MB"); } } }按 L 键强制 GC 并打印内存。反复开关界面,如果数字一直涨,就是泄漏。这个脚本虽然简陋,但在快速验证阶段非常好用。
注意:
GC.GetTotalMemory(false)返回的是托管堆大小,Profiler.GetTotalAllocatedMemoryLong()返回的是 Unity 分配的总内存(含非托管)。两个数字要结合看,托管涨说明是托管泄漏,托管不涨但 Unity 涨说明是非托管泄漏。
5. 从代码层面根治事件泄漏
5.1 成对出现的订阅和取消订阅
最朴素也最有效的原则:在哪里订阅,就在对应的生命周期回调里取消订阅。Unity 里通常是OnEnable配OnDisable,或者Start配OnDestroy。
public class BattleResultPanel : MonoBehaviour { private void OnEnable() { EventCenter.OnScoreChanged += HandleScoreChanged; } private void OnDisable() { EventCenter.OnScoreChanged -= HandleScoreChanged; } private void HandleScoreChanged(int score) { /* ... */ } }为什么用OnEnable/OnDisable而不是Start/OnDestroy?因为OnEnable/OnDisable在对象被禁用时也会触发,能保证对象不可见时就不接收事件,既省性能又防泄漏。而Start只执行一次,如果对象被禁用再启用,Start不会重跑,但OnEnable会,所以配对更自然。
5.2 用 IDisposable 封装订阅
当订阅关系比较复杂时,可以封装一个"订阅令牌":
public struct EventSubscription<T> : IDisposable { private Action<T> _handler; private Action<Action<T>> _unsubscribe; public EventSubscription(Action<T> handler, Action<Action<T>> unsubscribe) { _handler = handler; _unsubscribe = unsubscribe; } public void Dispose() { _unsubscribe?.Invoke(_handler); _handler = null; _unsubscribe = null; } }用的时候配合using或者手动 Dispose,能有效避免忘记取消。这个模式在响应式编程(UniRx)里很常见,UniRx 的IDisposable就是干这个的。
5.3 弱事件模式:让订阅者"自生自灭"
如果实在不想手动管理订阅,可以用弱引用(WeakReference)实现弱事件。核心思路是:发布者持有的是订阅者的弱引用,GC 回收订阅者时不会因为发布者的引用而受阻。
public class WeakEvent<T> { private readonly List<WeakReference<Action<T>>> _handlers = new(); public void Subscribe(Action<T> handler) { _handlers.Add(new WeakReference<Action<T>>(handler)); } public void Raise(T arg) { for (int i = _handlers.Count - 1; i >= 0; i--) { if (_handlers[i].TryGetTarget(out var handler)) { handler.Invoke(arg); } else { _handlers.RemoveAt(i); // 清理已回收的 } } } }弱事件不是银弹,它有自己的问题:委托本身可能被 GC 提前回收(如果订阅者没有其他强引用),导致事件"莫名其妙"不触发。所以它适合订阅者生命周期明确、且不希望被发布者影响的场景。我个人在项目里用得不多,更倾向于显式取消订阅,因为行为可预测。
5.4 用 UniRx 或 R3 统一管理生命周期
如果你的项目已经在用 UniRx,那订阅管理会轻松很多:
private void Start() { EventCenter.OnScoreChanged .Subscribe(score => UpdateBar(score)) .AddTo(this); // 对象销毁时自动取消订阅 }AddTo(this)会把订阅绑定到 GameObject 的生命周期,对象销毁时自动 Dispose。这是目前最省心的方案。R3(UniRx 的继任者)也延续了这个设计。不过引入第三方库有学习成本,小项目不一定值得。
6. 那些年我踩过的坑和排查心得
6.1 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 界面反复开关内存阶梯上升 | 静态事件未取消订阅 | 检查 OnEnable/OnDisable 是否配对 |
| GC 后内存不回落 | 对象被 GC Root 持有 | Memory Profiler 追引用链 |
| 事件触发次数越来越多 | Lambda 重复订阅 | 检查是否有匿名方法订阅 |
| 帧率随运行时间下降 | 委托链过长 | 统计事件订阅者数量 |
| 非托管内存持续增长 | 未 Dispose 的 Native 资源 | 检查 IDisposable 实现 |
6.2 几个容易忽略的细节
第一,-=对匿名方法无效。这是新手最容易犯的错。obj.Event += () => {}之后再obj.Event -= () => {},这两个 lambda 是两个不同的委托实例,取消不掉。解决办法是把 lambda 存成字段,或者改用命名方法。
第二,委托的 Target 是罪魁祸首。一个委托包含 Method 和 Target 两部分。Target 就是订阅者实例。只要委托还在,Target 就活着。理解这一点,很多问题就通了。
第三,静态字段比静态事件更隐蔽。有些人不用 event,直接public static Action OnXxx,效果一样,但更容易被忽略,因为它看起来就是个普通字段。
第四,DontDestroyOnLoad 的对象要格外小心。它们跨场景存活,如果订阅了场景内对象的事件,场景切换后订阅者可能已经销毁,但发布者还活着,反过来也一样。
第五,第三方 SDK 的回调注册往往没有取消接口。这种情况只能通过弱引用或者手动管理,实在不行就在 SDK 层面做一层封装。
6.3 一个真实的排查记录
前面提到的那个卡牌项目,我最后是怎么定位的?过程大致是这样:
先在 Profiler 里确认内存确实在涨,然后抓了两个 Memory Profiler 快照做 Diff,发现BattleResultPanel的实例数量从 1 涨到了 28。点进去看引用链,发现它被一个Action<int>委托持有,这个委托又挂在EventCenter.OnScoreChanged这个静态事件上。代码里翻了一下,果然OnEnable里订阅了,OnDisable里忘了取消。
修复很简单,加一行-=就完事了。但排查过程花了大半天,主要时间都花在理解 Memory Profiler 的引用链视图上。所以我的建议是:平时就养成成对写订阅的习惯,别等出问题再排查,排查成本远高于预防成本。
7. 一些延伸思考
事件订阅泄漏只是 Unity 内存泄漏的一个缩影。同样的逻辑还适用于:协程(Coroutine 持有闭包)、Invoke延迟调用、Update里的委托、UnityWebRequest的回调、AssetBundle的引用计数等等。它们的共同点都是"某处持有了一个不该持有的引用"。
我个人的经验是,做 Unity 项目时,脑子里要始终有一根弦:任何跨生命周期的引用都要问一句"谁持有谁,谁先死"。想清楚这个问题,大部分泄漏都能在设计阶段避免。
另外,GC 本身也不是免费的。频繁的 GC 会造成卡顿,所以除了防泄漏,还要注意减少不必要的堆分配,比如避免在 Update 里 new 对象、用对象池复用、用 struct 替代 class 等。这些是另一个话题了,有机会再展开聊。
最后分享一个小技巧:在项目里加一个全局的"事件订阅审计"工具,运行时统计每个事件的订阅者数量,超过阈值就报警。这个工具在开发期能帮你提前发现很多问题,比等到线上闪退再排查划算得多。