ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Unity内存泄漏实战:事件订阅为何导致GC失效及根治方案

Unity内存泄漏实战:事件订阅为何导致GC失效及根治方案 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 Actionint 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 EventSubscriptionT : IDisposable { private ActionT _handler; private ActionActionT _unsubscribe; public EventSubscription(ActionT handler, ActionActionT 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 WeakEventT { private readonly ListWeakReferenceActionT _handlers new(); public void Subscribe(ActionT handler) { _handlers.Add(new WeakReferenceActionT(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。这是目前最省心的方案。R3UniRx 的继任者也延续了这个设计。不过引入第三方库有学习成本小项目不一定值得。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。点进去看引用链发现它被一个Actionint委托持有这个委托又挂在EventCenter.OnScoreChanged这个静态事件上。代码里翻了一下果然OnEnable里订阅了OnDisable里忘了取消。修复很简单加一行-就完事了。但排查过程花了大半天主要时间都花在理解 Memory Profiler 的引用链视图上。所以我的建议是平时就养成成对写订阅的习惯别等出问题再排查排查成本远高于预防成本。7. 一些延伸思考事件订阅泄漏只是 Unity 内存泄漏的一个缩影。同样的逻辑还适用于协程Coroutine 持有闭包、Invoke延迟调用、Update里的委托、UnityWebRequest的回调、AssetBundle的引用计数等等。它们的共同点都是某处持有了一个不该持有的引用。我个人的经验是做 Unity 项目时脑子里要始终有一根弦任何跨生命周期的引用都要问一句谁持有谁谁先死。想清楚这个问题大部分泄漏都能在设计阶段避免。另外GC 本身也不是免费的。频繁的 GC 会造成卡顿所以除了防泄漏还要注意减少不必要的堆分配比如避免在 Update 里 new 对象、用对象池复用、用 struct 替代 class 等。这些是另一个话题了有机会再展开聊。最后分享一个小技巧在项目里加一个全局的事件订阅审计工具运行时统计每个事件的订阅者数量超过阈值就报警。这个工具在开发期能帮你提前发现很多问题比等到线上闪退再排查划算得多。
返回列表