
1. 项目概述一次典型的内存泄漏排查之旅在Unity开发中尤其是项目迭代到中后期性能问题往往会从“小麻烦”升级为“大隐患”。内存泄漏就是其中最隐蔽、也最令人头疼的一种。它不像崩溃那样直接报错也不像掉帧那样显而易见而是像水管上的一个微小裂缝悄无声息地让内存水位不断上涨最终导致应用卡顿、闪退甚至被系统强制终止。我最近在维护一个Unity 2021.3 LTS版本的项目时就遭遇了这样一次典型的内存泄漏。游戏在长时间运行后尤其是在安卓设备上内存占用会持续攀升最终导致应用无响应。经过一番排查最终定位到的“元凶”竟是一个极其常见却又容易被忽视的细节一个“忘记置空”的事件委托。这次排查过程我主要依赖了Unity强大的Memory Profiler工具它像一台精密的“内存CT扫描仪”帮我层层剥茧找到了问题的根源。这篇文章我就来详细复盘这次排查的全过程分享从现象捕捉、工具使用到问题定位和修复的完整思路与实操细节希望能给遇到类似问题的开发者提供一个清晰的参考路径。2. 内存泄漏现象与初步分析2.1 症状描述与复现项目是一个中等规模的3D手游运行在Unity 2021.3.15f1版本上。最初的问题反馈来自测试团队在连续进行大约30分钟的关卡挑战后部分中低端安卓设备会出现明显的卡顿随后游戏画面冻结最终应用失去响应。在Unity Editor的Play模式下通过Stats面板观察也能发现Used Memory已使用内存会随着游戏时间的推移而缓慢但持续地增长即使在场景切换、返回主菜单等理论上应该释放大量资源的时候内存下降的幅度也远小于预期存在明显的“内存只增不减”的趋势。为了稳定复现问题我设计了一个简单的测试流程在编辑器中循环进入一个包含大量动态生成UI和特效的关卡场景然后退出到主菜单如此反复。同时我打开了Profiler窗口Window Analysis Profiler并切换到Memory模块。在进行了大约20次循环后可以清晰地看到Total Used Memory的曲线呈现出一个“阶梯式”上升的形态每次循环的峰值都比前一次略高而谷底退出场景后也在不断抬高。这强烈暗示了存在未被正确释放的对象即内存泄漏。注意在Unity中由于垃圾回收Garbage Collection, GC的存在内存的释放不是即时的。因此判断内存泄漏不能只看瞬间值而要看一个周期如一次完整的场景加载/卸载后的内存基线是否持续升高。如果基线不断上移就基本可以断定存在泄漏。2.2 初步排查方向面对内存泄漏首先要确定泄漏的是哪一类内存。Unity中的内存主要分为两大类托管堆内存Managed Heap由C#代码创建的对象如类的实例、数组、字符串等所占用的内存由Mono或IL2CPP的垃圾回收器管理。本地/原生内存Native Memory由Unity引擎底层C代码或第三方原生插件分配的内存如纹理、网格、音频片段、AssetBundle等资源以及一些引擎内部数据结构。我的排查思路是如果是托管堆泄漏通常是由于代码中保持了不必要的对象引用阻止了GC回收。常见原因有静态变量、单例、事件/委托未取消订阅、缓存设计不当等。如果是原生内存泄漏则更可能与大资源如纹理、网格未正确卸载、AssetBundle未释放、或原生插件管理不当有关。通过Profiler的Memory模块我可以看到Detailed视图下ManagedHeap.UsedSize和Total Used Memory都在增长。但为了获得更精确的洞察我决定使用更专业的工具——Memory Profiler。3. 深入排查Memory Profiler实战解析3.1 Memory Profiler工具配置与抓取Unity Memory Profiler是一个独立的包需要从Package Manager中安装。我安装的是com.unity.memoryprofiler的最新稳定版本。它的优势在于能提供两个时间点的内存快照对比直观地展示出哪些对象在期间被分配且未被释放。我的操作步骤如下建立基线在游戏启动后进入主菜单此时内存状态相对干净点击Memory Profiler窗口中的Capture Snapshot按钮抓取第一个快照Snapshot A。这作为我们的“干净”基准。执行泄漏操作进行之前设计的“加载关卡-退出”循环重复执行5次让泄漏效果更明显。抓取对比快照在完成5次循环后依然停留在主菜单点击Capture Snapshot抓取第二个快照Snapshot B。开始分析在Memory Profiler中选择Snapshot B然后点击右上角的Compare to下拉菜单选择Snapshot A进行对比。3.2 对比报告分析与线索发现对比视图是排查的核心。视图主要分为两部分All Objects和All of Memory。我首先关注All Objects标签页。在对比表格中我按Size Diff大小差异降序排列。排在最前面的几个条目通常是纹理、网格等大型资源。我需要仔细甄别这些增长是合理的比如缓存了新的资源还是泄漏的本该释放的没释放。很快我注意到了一个不寻常的现象除了几个预期内的大型资源有微小增长外有一个名为System.Action的条目其Count Diff数量差异和Size Diff都异常突出。它增长了数百个实例占据了可观的托管堆内存。点开System.Action的详情在Referenced By被谁引用面板中我看到大量的引用来源都指向我项目中的一个自定义管理器类——AchievementManager。这是一个关键线索。System.Action是C#中委托的一种常用形式而委托如果使用不当正是托管内存泄漏的“重灾区”。AchievementManager负责处理游戏内的成就解锁和通知内部使用了大量的事件来通知UI更新。3.3 定位泄漏源委托与事件的陷阱我打开了AchievementManager的代码。其中有一个关键的事件定义和订阅逻辑public class AchievementManager : MonoBehaviour { public static AchievementManager Instance; // 定义了一个成就更新事件 public event ActionAchievementData OnAchievementUpdated; private void Awake() { Instance this; } public void UnlockAchievement(string id) { // ... 解锁逻辑 AchievementData data GetAchievementData(id); OnAchievementUpdated?.Invoke(data); // 触发事件 } }而在多个UI界面的控制器中是这样订阅事件的public class AchievementPopupUI : MonoBehaviour { private void OnEnable() { // 订阅成就更新事件 if (AchievementManager.Instance ! null) { AchievementManager.Instance.OnAchievementUpdated HandleAchievementUpdated; } } private void HandleAchievementUpdated(AchievementData data) { // 更新UI显示 } // 问题所在没有在OnDisable或OnDestroy中取消订阅 // private void OnDisable() // { // if (AchievementManager.Instance ! null) // { // AchievementManager.Instance.OnAchievementUpdated - HandleAchievementUpdated; // } // } }问题根因分析AchievementPopupUI在OnEnable时向AchievementManager.Instance的静态事件OnAchievementUpdated注册了事件处理方法HandleAchievementUpdated。当这个UI界面被关闭GameObject失活或销毁时由于没有在OnDisable或OnDestroy中取消订阅AchievementManager.Instance这个静态对象仍然持有着对AchievementPopupUI实例中HandleAchievementUpdated方法的委托引用。在Unity中一个C#对象如AchievementPopupUI实例要被GC回收必须没有任何“根”Root引用它。静态变量是强根Strong Root。因此只要AchievementManager.Instance存在它是个单例通常全程存在它通过事件持有的委托链就会一直引用着那些已经关闭的UI对象阻止它们被垃圾回收。每次打开又关闭AchievementPopupUI就会多一个“僵尸”委托挂在静态事件上对应的UI对象实例也无法释放这就造成了托管堆内存的持续泄漏。这就是典型的“忘记置空”的委托泄漏。它之所以隐蔽是因为在代码逻辑上UI关闭后似乎不再工作开发者容易忽略其对象实例实际上还被“吊着”呢。4. 解决方案与修复实施4.1 修复代码确保订阅与取消订阅配对修复方案非常直接在注册事件的地方必须提供对称的注销路径。我修改了AchievementPopupUI以及其他有类似问题的UI控制器代码public class AchievementPopupUI : MonoBehaviour { private void OnEnable() { SubscribeToEvents(); } private void OnDisable() { UnsubscribeFromEvents(); } private void OnDestroy() { // OnDestroy是最终保障防止某些情况下OnDisable未被调用 UnsubscribeFromEvents(); } private void SubscribeToEvents() { if (AchievementManager.Instance ! null) { AchievementManager.Instance.OnAchievementUpdated HandleAchievementUpdated; } } private void UnsubscribeFromEvents() { // 即使Instance为null取消订阅也是安全的不会抛出异常 if (AchievementManager.Instance ! null) { AchievementManager.Instance.OnAchievementUpdated - HandleAchievementUpdated; } } private void HandleAchievementUpdated(AchievementData data) { // 更新UI显示 } }修复要点对称性OnEnable配OnDisableStart配OnDestroy确保生命周期对应。使用独立方法将订阅和取消订阅逻辑封装成独立方法SubscribeToEvents/UnsubscribeFromEvents提高代码可读性和可维护性避免遗漏。空值安全在取消订阅前检查事件源是否为空这是一个好习惯。4.2 使用弱事件模式进阶方案对于更复杂的系统或者担心开发者遗忘取消订阅可以考虑使用弱事件Weak Event模式。弱事件允许事件源持有对监听者的弱引用这样即使监听者没有被其他强引用持有也可以被GC回收从而自动从事件列表中移除避免了内存泄漏。在C#中可以使用WeakReference自己实现或者使用一些现有的库。不过弱事件会带来微小的性能开销并且监听者需要是类实例不能是静态方法。对于大多数Unity项目养成“配对取消订阅”的好习惯是更简单有效的方法。4.3 修复后验证代码修复后我重复了之前的测试流程清理内存通过Profiler强制GC抓取快照A。进行10次“加载关卡-退出”循环。抓取快照B并进行对比。这次在对比报告中System.Action的Count Diff和Size Diff都变成了很小的数字主要是一些临时委托分配会被GC正常回收而之前持续增长的AchievementPopupUI实例也不再累积。内存曲线恢复了健康的状态每次循环后的内存基线基本保持稳定。5. 扩展排查其他常见内存泄漏场景与预防这次排查虽然解决了主要问题但内存泄漏的形态多种多样。借助这次经验我们可以系统性地检查其他高危场景5.1 静态容器与缓存静态的ListT,DictionaryTKey, TValue是常见的泄漏源。如果你向一个静态列表中添加了场景中对象的引用并且忘记移除这些对象就永远无法释放。public static ListEnemy AllEnemies new ListEnemy(); // 危险 void Start() { AllEnemies.Add(this); } // 必须要在Enemy销毁时从列表中移除 void OnDestroy() { AllEnemies.Remove(this); }预防建议谨慎使用静态容器存储场景对象引用。如果必须使用请建立严格的“注册-注销”机制并考虑使用WeakReference来存储弱引用。5.2 协程Coroutine引用启动一个协程时如果引用了外部对象并且协程因为条件永远不满足如while(true)但退出条件错误而无法结束那么它所在的MonoBehaviour实例以及其引用的所有对象都无法被释放。IEnumerator LeakyCoroutine(SomeComponent target) { while(target ! null) // 如果target被销毁但本协程未停止MonoBehaviour实例仍被引用 { yield return new WaitForSeconds(1); // 使用target... } }预防建议在OnDisable或OnDestroy中使用StopAllCoroutines()或StopCoroutine()来停止所有协程。检查协程的循环退出条件是否严谨。5.3 未卸载的AssetBundle与Resources资源通过AssetBundle.LoadAsset加载的资源在使用完毕后必须通过Resources.UnloadAsset(对于非GameObject/Component) 或AssetBundle.Unload(true/false)来管理。通过Resources.Load加载的资源在不再需要时可以调用Resources.UnloadUnusedAssets来释放通常在与GC.Collect()一起调用时效果更佳。预防建议建立清晰的资源生命周期管理策略。对于AssetBundle使用引用计数或基于场景的资源管理方案。5.4 原生插件与第三方SDK如果使用了iOS/Android原生插件或第三方SDK内存泄漏可能发生在原生代码侧。Unity Profiler可能无法直接追踪。需要通过平台特有的工具如Xcode Instruments的Allocations/Leaks工具Android Profiler或Perfetto进行联合分析。预防建议仔细阅读第三方SDK的文档确保按照要求进行初始化和清理。在销毁对象或退出场景时调用SDK提供的清理或销毁接口。6. 建立长效预防机制与最佳实践一次排查解决了问题但更重要的是建立习惯防止类似问题再次发生。6.1 代码规范与审查事件订阅审查在代码审查中将“事件订阅是否有对应的取消订阅”作为必查项。可以编写简单的静态分析脚本在CI/CD流程中检测常见的模式。静态成员审查警惕所有静态字段和属性审查它们是否持有了本应释放的场景对象引用。生命周期方法配对建立团队规范要求Start/OnEnable中的初始化操作必须在OnDestroy/OnDisable中有对应的清理操作。6.2 开发期监控流程定期使用Profiler将性能测试纳入日常开发流程。在完成一个功能模块后运行一段时间观察内存曲线。善用Memory Profiler对于复杂系统或核心模块定期使用Memory Profiler抓取对比快照主动寻找异常增长点。在目标设备上测试Editor下的内存行为有时与真机尤其是移动设备有差异。定期在真机上进行长时间的压力测试至关重要。6.3 工具辅助Unity的Heap Explorer在Memory Profiler的Snapshot详情中可以使用Heap Explorer工具以树状图形式查看对象引用关系对于理解复杂的引用链非常有帮助。自定义内存诊断工具对于大型项目可以开发简单的运行时工具在开发版本中记录关键管理器如对象池、资源管理器的对象数量并在UI上显示便于实时监控。内存管理是Unity开发的一项基本功也是区分初级和资深工程师的关键能力之一。这次对“忘记置空委托”的排查是一次非常经典的案例。它告诉我们很多严重的性能问题根源往往是一些基础的编码疏忽。养成良好的编码习惯善用Unity提供的强大分析工具建立系统的性能监控流程就能在问题演变成危机之前将其扼杀在摇篮里。在项目后期去追查一个偶发的内存泄漏其成本和难度远高于在开发初期就遵循最佳实践。希望这次详细的实录能让你对Unity内存泄漏的排查有一个更直观、更深入的认识。