Unity内存管理深度解析:从GC机制到原生资源优化

发布时间:2026/9/3 5:35:08
Unity内存管理深度解析:从GC机制到原生资源优化 做Unity项目做到中后期很多人会有一种相似的感觉帧率看起来还行但玩着玩着就开始掉帧场景加载多了之后内存占用直线上升低端机上频繁闪退日志里不是OutOfMemory就是Allocation failed。排查到后面发现多数问题都指向同一个源头内存管理没做好。Unity开发者的特殊困境在于它不像写普通C#程序那样只有一个托管堆Unity项目同时存在托管堆内存和引擎原生内存两条线。前者由C#对象和GC垃圾回收主导后者由纹理、网格、音频、Shader、AssetBundle等引擎资源占用。两条线互相影响但诊断方式和优化手段完全不同。很多人只盯着Profiler里的GC Alloc看却忽略了原生内存才是低端机内存爆掉的元凶。这篇文章是Unity性能优化指南的第二期聚焦内存管理机制。我会从托管堆和原生内存两个维度拆解Unity的内存模型讲清楚GC什么时候触发、为什么频繁GC会卡顿、IL2CPP和Mono在内存行为上有什么差异然后给出代码层面和资源层面的可落地优化手段。读完你至少能回答以下问题Unity里的内存到底被谁占了Profiler里该看哪些指标一段会产生垃圾的代码长什么样AssetBundle和Addressables应该怎么管理资源才能避免重复加载和泄漏1. 先建立整体认知Unity内存是“双轨制”理解Unity内存管理最怕只从单一视角去看。很多刚接触Unity的开发者会下意识地以为Unity和普通C#程序一样只管好new出来的对象就行剩下的交给GC。但实际项目中你会发现哪怕脚本里完全没有代码逻辑只加载几个大场景内存也能飙到几百MB。问题就在于Unity引擎本身是C实现的引擎加载的纹理、网格、音频、动画、Shader等资源走的是原生内存Native Memory由引擎的C层管理。而你的游戏逻辑脚本跑在Mono或IL2CPP上C#对象分配在托管堆Managed Heap里由GC负责回收。这两条线并不是各自独立的。托管对象可能持有对原生资源的引用例如Texture2D对象本身在C#侧只是一个很小的封装但它的像素数据在GPU或CPU侧的原生内存里。当C#侧还持有Texture2D引用时原生资源就不会被释放而如果C#侧已经不再持有引用但你没有主动调用Destroy或Resources.UnloadUnusedAssets原生资源也不会立刻卸载。这就造成了很多项目中常见的资源泄漏从现象上看是Memory Profiler里原生内存居高不下但根源却可能是C#脚本里一个静态List一直保存着已加载的Asset引用。可以先用一张表格理解Unity内存的两条主线内存类型管理方主要占用对象释放方式托管堆.NET运行时Mono/IL2CPPC#对象、字符串、数组、委托、装箱对象GC自动回收原生内存Unity引擎C纹理、网格、音频、Shader、AssetBundle主动Destroy/Unload托管堆上的资源封装C#对象持有原生引用Texture2D、Mesh、AudioClip等引用释放 Destroy/Unload配合很多内存问题之所以难排查就是因为它跨了这两条线。所以后面无论讲原理还是讲工具我都会时刻提醒你当前这个优化动作动的是哪条线的内存。2. 托管堆与GC机制Unity的C#内存是如何运转的2.1 托管堆的基本结构C#层的对象分配在托管堆上。每当你写new Listint()、拼接字符串、创建Linq查询产生的中间对象都会在托管堆上分配内存。托管堆上已经分配但不再被引用的对象就是所谓的“垃圾”。GC的作用就是定期找到这些垃圾并回收。但GC不是实时的它的触发条件通常是内存分配达到一定阈值或者外部显式调用System.GC.Collect()。需要澄清一个误区GC Alloc并不等于内存泄漏。GC Alloc只是代表某段代码在堆上分配了对象。如果这些对象在之后被GC回收它只会造成CPU开销不会导致内存永久增长。只有当对象一直被引用GC永远无法回收时才会形成真正的泄漏。2.2 GC什么时候触发在Unity中GC主要在以下场景触发托管堆已分配内存达到一个阈值运行时发现需要向操作系统申请新的内存页。手动调用System.GC.Collect()。在部分平台系统内存压力较大时底层也可能触发GC行为。GC触发时整个游戏线程会暂停这就是“GC卡顿”的来历。一次完整的GC需要遍历托管堆上的所有对象检查引用关系。项目里的托管堆越大、活动对象越多单次GC的耗时就越长。所以优化的两个核心方向是减少GC Alloc的绝对值降低GC时的遍历开销也就是控制堆上存活对象的规模和复杂度。2.3 频繁GC为什么比单次长GC更可怕从体感上说单次GC如果偶尔发生玩家顶多觉得掉了一帧。真正致命的是持续、高频的小规模GC因为每一帧或每隔几帧就会触发一次暂停。这种情况在移动端尤其明显手机上CPU性能弱一次5ms的GC暂停都可能造成肉眼可见的卡顿。低端Android设备上GC的触发频率会直接影响游戏能否保持30帧。你会发现很多项目的卡顿曲线图表里每隔一段时间就有一个稳定的尖刺帧时间从16ms突然跳到50ms以上。如果发现这个尖刺的模式很有规律优先怀疑GC是大概率正确的方向。2.4 Mono与IL2CPP的GC差异Unity有两套主要的脚本后端Mono和IL2CPP。它们的托管堆行为不完全一致。Mono是JIT运行时支持在编辑器或部分平台动态编译C#代码。它的GC历史比较久传统上是分代GC配合Boehm GC的实现在某些Unity版本中表现是“回收不够激进”容易出现托管堆只增不减的情况。Mono在移动端上已经被Unity逐渐边缘化目前主要用于编辑器、Windows/macOS编辑器模式和部分不需要AOT的平台。IL2CPP会把C#代码转换成C再编译成原生代码。它使用的GC是Unity自研的UnityGC因此在iOS、Android、WebGL等平台上表现更一致。从内存管理角度看IL2CPP的GC同样不能完全杜绝卡顿但由于代码会经历AOT编译很多泛型和接口调用可以提前生成代码某些场景下堆分配模式会比Mono更稳定。对实际项目而言绝大多数移动端项目应该以IL2CPP为目标。这不仅是出于性能考虑也因为Apple App Store早在2023年就要求新上架App必须包含arm64支持Mono在iOS上已经不适用了。从内存角度说IL2CPP的GC行为在iOS和Android上更统一调试经验也更容易复用。3. 原生内存真正压垮移动端的“冰山”如果把Unity项目的内存比作一座冰山很多人只看到了托管堆露出水面的部分而真正庞大的是沉浸在水下的原生内存。特别是在3D项目中一张2048x2048的RGBA纹理在内存里未经压缩时约16MB一张4096x4096的纹理就是64MB。如果美术资源没有做好压缩几个场景一加载内存直接被吃穿。原生内存主要由以下资源构成纹理像素数据。要注意纹理在CPU侧和GPU侧的占用。开启Read/Write后CPU侧还会保留一份可读拷贝内存翻倍。网格顶点数据和索引数据。SkinnedMeshRenderer的骨骼权重数据也有额外成本。音频AudioClip的解码后的PCM数据。未压缩音频体积很大。动画AnimationClip中的曲线数据特别是人形动画的压缩情况。ShaderShader变体和着色器字节码。AssetBundle加载后如果没有卸载Bundle本身及其包含的资源会持续占用内存。字体动态字体在字形被使用后会缓存字形纹理。原生内存的管理方式和托管堆截然不同。C#对象在失去引用后等待GC即可但纹理和网格不会因为C#引用消失而自动卸载。它们必须通过Object.Destroy、Resources.UnloadUnusedAssets、AssetBundle.Unload(true)等方式被显式卸载。这条规则是很多资源泄漏问题的根源。例如用AssetBundle.LoadFromFile加载了一个Bundle如果你只是丢掉对AssetBundle对象的引用而不调用assetBundle.Unload(false)那么这个Bundle对应的文件句柄和已经加载的资源会继续驻留在内存里。更好的做法是用AssetBundle.Unload(false)卸载Bundle本身并保留已加载的Asset给场景使用等场景切换时再通过Resources.UnloadUnusedAssets统一清理。4. Profiler与Memory Profiler先定位问题再优化在没有数据的情况下谈内存优化都是方向错误的盲调。Unity自带的Profiler窗口和Memory Profiler包是排查内存问题的两件核心工具。4.1 传统Profiler的Memory视图打开Window - Analysis - Profiler选择Memory模块。这个视图会显示Total Allocated当前总分配内存。Total Reserved运行时预留的内存。GC Used Memory托管堆上已使用内存。GC Alloc某帧内新分配的堆内存。排查GC Alloc问题时可以把CPU模块和Memory模块配合使用。在CPU模块里勾选GC Alloc列进入Play模式后每一行函数调用都会显示它分配了多少字节。经常可以看到某个看似无害的Update方法每帧分配几百字节甚至几KB。如果项目中的代码每帧都分配几KB累积下来GC就会频繁触发。在移动端性能优化时常见的目标是“核心战斗逻辑尽量做到每帧0 GC Alloc”这是完全可以实现的。4.2 Memory Profiler包传统Profiler只能看到内存的总体趋势不能告诉你“当前哪张纹理占用最大、它被哪个对象引用”。要定位深层的原生资源泄漏推荐使用Memory Profiler包。它是Unity官方提供的内存分析工具可以通过Package Manager安装。它能抓取一次快照然后详细列出所有托管对象、原生对象、资源的内存占用和引用关系。使用建议是先抓一张“干净场景”的快照作为基线。进入一个关卡或打开某个界面抓第二张快照。退出关卡或关闭界面再抓第三张快照。对比快照1和快照3的差异如果内存没有回落说明存在泄漏或资源未卸载。Memory Profiler里可以按“Total Size”排序快速找到占内存最大的资源。如果发现某张纹理被多个AssetBundle以不同实例加载了也可以直接从引用列表里看到线索。4.3 移动端真机分析编辑器里的Profiler数据只能作为参考真机上的内存行为和编辑器差异很大。Unity Profiler支持通过Android Profiler或iOS Instrument进行真机连接。Android平台还可以使用Unity的Adaptive Performance或Perfetto等工具。但在做内存分析时真机最直接的方法是看系统级内存占用。Android上可以通过adb shell dumpsys meminfo packageName查看应用的内存细分iOS上需要使用Xcode的Memory Report。系统级工具能告诉你应用“总共占了多少内存”但具体是哪个资源吃掉的仍然要靠Unity Memory Profiler定位。对Android设备还有一个很实用的排查法连续多次加载并卸载同一个场景在Logcat中持续观察PSS或RSS值。如果每次循环之后内存基线都在上涨且涨幅不是平缓而是阶梯状通常说明有资源没有正确卸载。5. 代码层面的内存优化从消灭GC Alloc开始5.1 字符串拼接是隐藏的分配大户C#中的string是不可变类型每次拼接都会创建一个新字符串并分配新内存。在Update方法中频繁拼接字符串是常见的GC Alloc来源。例如// 文件路径Assets/Scripts/DebugHud.cs public class DebugHud : MonoBehaviour { private float fps; private void Update() { fps 1f / Time.unscaledDeltaTime; } private void OnGUI() { // 不推荐每帧创建新字符串产生GC Alloc GUI.Label(new Rect(10, 10, 300, 30), FPS: fps.ToString(F2) Frame: Time.frameCount); } }FPS: fps.ToString(F2) ...这行代码在IL层面会经历多次字符串创建。如果频繁刷新UI或日志会产生大量垃圾。推荐做法是使用StringBuilder配置一个持续复用的实例在需要时清空重写// 文件路径Assets/Scripts/DebugHud.cs using System.Text; using UnityEngine; public class DebugHud : MonoBehaviour { private StringBuilder sb new StringBuilder(128); private float fps; private void Update() { fps 1f / Time.unscaledDeltaTime; } private void OnGUI() { sb.Clear(); sb.Append(FPS: ); sb.Append(fps.ToString(F2)); sb.Append( Frame: ); sb.Append(Time.frameCount); GUI.Label(new Rect(10, 10, 300, 30), sb.ToString()); } }需要注意的是StringBuilder.ToString()依然会创建一个新字符串但每帧只产生一次分配相比多次拼接已经大幅减少。5.2 闭包和匿名函数在循环中的陷阱在foreach或for循环中直接使用Lambda表达式容易造成闭包捕获变量从而产生额外的类对象和堆分配。// 不推荐循环中使用闭包 for (int i 0; i enemies.Count; i) { var enemy enemies[i]; button.onClick.AddListener(() Attack(enemy)); }编译器会为这段闭包代码生成一个闭包类循环里的每次迭代都可能创建新的闭包对象。虽然现代C#编译器有所优化但反复使用仍会产生不必要的GC压力。一种做法是把循环变量提取为局部变量或者提前创建事件处理时使用逻辑上等价但避免闭包的方式例如使用UnityAction的实例方法而不是Lambda。如果在性能敏感处需要大量动态创建的委托可以考虑用对象池缓存委托封装对象。需要强调的是闭包方向的优化比较依赖项目的Unity和C#版本因为较新的C#编译器会复用部分闭包结构。不建议一看到Lambda就急着重构先看Profiler里的GC Alloc热点是否真的指向这个位置再做优化。5.3 LINQ在热代码路径中要谨慎LINQ方便但它在背后会生成大量迭代器对象和中间结果。在UI点击等低频路径上LINQ没问题在每帧执行一次的Update中对集合做Where、OrderBy、ToList操作会产生明显的GC Alloc。// 不推荐每帧执行LINQ排序 var sorted enemies.Where(e e.IsAlive).OrderBy(e e.Distance).ToList();最稳妥的替代方案是手写循环配合List的Sort方法或者使用原生数组并结合索引排序。简单场景里for循环的性能远比LINQ可预测// 推荐直接遍历并维护一个已排序列表或手动选出目标 private Enemy FindNearestEnemy(ListEnemy enemies, Vector3 pos) { Enemy best null; float minDist float.MaxValue; for (int i 0; i enemies.Count; i) { Enemy e enemies[i]; if (!e.IsAlive) continue; float dist (e.transform.position - pos).sqrMagnitude; if (dist minDist) { minDist dist; best e; } } return best; }选择sqrMagnitude而不选magnitude也避免了开平方的运算是一种微优化在大量目标遍历时收益明显。5.4 装箱发生在你毫无察觉时当一个值类型被转换成object或接口类型时会发生装箱在堆上分配新对象。最常见的触发点包括object o 42; // 装箱 int x 100; string s string.Format(value {0}, x); // x被装箱 // 比较隐蔽的Hashtable、ArrayList、非泛型接口使用 Hashtable table new Hashtable(); table.Add(key, 1); // 1被装箱在游戏代码里常见的装箱来源是使用非泛型集合、Debug.Log格式化、enum与字符串拼接、事件系统传参等。比如很多人习惯在UI文本中这样写text.text $Score: {score};如果score是int编译器在插值时会触发int的装箱。推荐使用score.ToString()或预分配好的StringBuilder替代。在Unity 2020.2以上版本C# 9的字符串插值针对IFormattable做了优化但为了保险在热路径中仍建议显式转字符串。5.5 Update中的空引用与临时数组分配另一个常见的GC来源是每帧在Update中创建数组或List来存储临时结果。比如// 不推荐每帧创建新数组 void Update() { Collider[] hits Physics.OverlapSphere(transform.position, radius); for (int i 0; i hits.Length; i) { ... } }Physics.OverlapSphere这种非分配版本的替代是Physics.OverlapSphereNonAlloc它会向已有的数组写入结果。数组声明在类字段里只需分配一次// 文件路径Assets/Scripts/RadarDetector.cs using UnityEngine; public class RadarDetector : MonoBehaviour { [SerializeField] private float radius 5f; [SerializeField] private LayerMask targetLayer; private Collider[] results new Collider[32]; private void Update() { int count Physics.OverlapSphereNonAlloc(transform.position, radius, results, targetLayer); for (int i 0; i count; i) { // 处理 results[i] } } }Unity物理系统提供了一系列NonAlloc版本API例如RaycastNonAlloc、SphereCastNonAlloc、BoxCastNonAlloc运行时物理检测的首选方案应该统一使用这些非分配版本。5.6 协程与事件委托的泄漏协程是另一个容易忽略的内存点。StartCoroutine会创建协程对象如果协程内部引用了某个MonoBehaviour或其他大对象并且在对象应该销毁时没有停止协程协程会持续持有引用导致对象无法被GC回收。类似的还有事件委托。当一个对象A订阅了对象B的事件而A被销毁后没有退订B会继续持有A的引用。经典场景是UI管理器订阅了Player的属性变化事件而Player已经切换关卡被销毁但UIManager仍持有Player引用从而让Player及其关联资源都无法释放。规则是谁注册事件谁负责退订。通常在OnEnable中订阅在OnDisable中取消订阅。如果对象生命周期较长需要在OnDestroy中做一个兜底退订。6. 资源内存管理覆盖常用资源和AssetBundle6.1 纹理的压缩与Read/Write设置纹理是移动端内存大户。任何纹理在导入时都需要检查以下设置Format优先使用ASTCAndroid和ASTC或PVRTCiOS不要使用RGBA32这种未压缩格式。Unity编辑器导入设置里的Compression要设为ASTC 6x6或更合适的档位。Read/Write Enabled默认关闭。这个选项如果开启Unity会在CPU侧保存一份纹理数据的副本内存占用直接翻倍。只有代码确实需要实时读取像素时才打开例如做纹理像素替换或动态生成贴图时。Max Size根据纹理实际显示大小设置上限。如果UI上只用到256x256导入设置为2048就是浪费。Generate Mip Maps3D场景的纹理通常需要开启但UI纹理和Sprite图集中的纹理一般不需要。Mipmap会额外增加约三分之一的内存。举个例子一张2048x2048的RGBA纹理未压缩时约16MB如果设为ASTC 6x6压缩可以降到约1.2MB。一个项目里可能上百张贴图这个差异是几十MB到几百MB的差距。6.2 Mesh数据优化Mesh的内存占用主要来自顶点数据。每个顶点可能包含position、normal、uv、tangent、color等属性不同属性组合对内存影响很大。一个10000顶点的Mesh在复杂顶点格式下可能占用数MB。可操作的优化点关闭Read/Write Enabled除非运行时需要修改Mesh或做Mesh合并。在导入设置中根据是否需要法线、切线、UV2等精确配置顶点属性。使用Mesh.Optimize()重新排序顶点索引改善GPU缓存命中率。粒子系统、骨骼动画等如果使用过多的独立Mesh内存压力会上升。6.3 AudioClip的加载类型音频的资源属性可以在内存和CPU解码之间做平衡。Unity提供三种Load TypeDecompress On Load适合短小的音效加载时完全解码播放时CPU开销低但内存占用高。Compressed In Memory适合较长的音乐保持压缩状态在内存中播放时实时解码内存较低但CPU略有开销。Streaming适合游戏背景音乐按块从磁盘/文件流式读取内存占用非常低但要注意移动端的文件IO会导致偶发的延迟或卡顿。对体量大的游戏把所有音频都设为Decompress On Load是移动端内存超标的常见原因之一。需要区分短音效和长BGM分别设置加载策略。6.4 AssetBundle与Addressables显式卸载是核心AssetBundle出现之前Resources目录是常用的资源加载方式。Resources.Load有全局索引所有Resources中的资源常驻内存包。对中大型项目Resources不是资源管理的首选因为它难以控制单个资源的释放除非用Resources.UnloadUnusedAssets统一清理。AssetBundle机制更灵活你可以加载特定Bundle加载其中Asset在合适的时机卸载Bundle。核心方法// 加载阶段 AssetBundle bundle AssetBundle.LoadFromFile(Path.Combine(Application.streamingAssetsPath, ui/common.ab)); GameObject prefab bundle.LoadAssetGameObject(ui_common_icon.prefab); // 使用资源 Instantiate(prefab); // 场景切换时 // 保留已经加载出的Asset实例同时卸载Bundle自身 bundle.Unload(false);Unload(false)会卸载AssetBundle的内存镜像但会保留通过它加载出来的Asset。这样Asset还能继续被场景中的GameObject引用等场景切换时再调用Resources.UnloadUnusedAssets()统一释放。Unload(true)则会直接卸载Bundle和所有关联的Asset。如果还有实例引用这些Asset会出现材质丢失、网格丢失、纹理变全紫等视觉崩溃。所以Unload(true)要谨慎通常只在你确定没有实例引用这些资源时使用。如果项目使用Addressables寻址和引用计数由Addressables系统接管内部仍然依赖AssetBundle。Addressables的核心设计是引用计数// 加载 var handle Addressables.LoadAssetAsyncGameObject(enemy_prefab); yield return handle; GameObject enemy handle.Result; Instantiate(enemy, position, rotation); // 释放引用计数 Addressables.Release(handle);使用Addressables时常见误区是只做LoadAssetAsync而忘了Release或者对Instantiate生成的对象只调用Destroy而不通过Addressables.ReleaseInstance释放。地址引用计数管理不当会导致资源持续驻留在AssetBundle缓存中和手工AssetBundle时代的泄漏套路相同。我的建议是新项目只要资源量大、有热更需求或者团队规模超过三五个人直接使用Addressables做资源管理不要自己封装AssetBundle。Addressables学习曲线虽然高一点但它在资源依赖追踪、远程加载、引用计数上已经做得很完善避免重造轮子的坑。6.5 Resources.UnloadUnusedAssets的使用时机Resources.UnloadUnusedAssets()会遍历当前所有不再被引用的Assets并卸载它们。但它是一个耗时操作一次完整遍历在资源很多时可能造成明显的帧停顿。因此不能每个Update或每帧调用应该在以下时机调用场景切换完成之后。通关或进入主城等大流程节点切换完成后。关闭大型UI界面后且这次界面加载了大量一次性图集资源。调用时应配合异步流程避免用户卡在某一帧using System.Collections; using UnityEngine; public class SceneSwitchHelper : MonoBehaviour { public IEnumerator UnloadAfterSceneChange() { yield return Resources.UnloadUnusedAssets(); yield return null; } }Resources.UnloadUnusedAssets()返回的是AsyncOperation用yield等待它完成中间可以插入一个空帧避免极端情况下的卡顿。7. 常见内存问题与排查思路内存问题的排查有时候比解决更费劲下面整理的是Unity团队实际开发中最高频遇到的一批问题遇到相似现象时可以直接按表格切入。问题现象可能原因排查方式解决方案帧时间周期性尖刺GC频繁触发Profiler CPU模块观察GC标记减少GC Alloc使用对象池场景切换后内存不回落资源未卸载Memory Profiler前后快照对比正确调用AssetBundle.Unload或Resources.UnloadUnusedAssets纹理加载后Android内存暴涨纹理未压缩或Read/Write开启Memory Profiler查Texture资源大小压缩格式改为ASTC关闭Read/Write加载同一个Prefab后资源重复AssetBundle重复加载Memory Profiler查找资源引用实例数做好资源加载管理器/Addressables引用计数MonoBehaviour或Player无法被GC事件或协程持有引用检查OnDestroy的事件退订和协程停止OnDisable/OnDestroy时统一清理Shader加载后产生大量变体材质引用了大量变体ShaderLab资源大小检查关闭不需要的Shader变体使用ShaderVariantCollection托管堆持续增长存在静态集合持有对象检查静态List/Dictionary减少静态集合场景切换时清理需要释放的数据AudioClip播放后内存占用偏高Decompress On LoadAudioImporter加载类型检查长音乐改为Streaming或Compressed In Memory在排查具体问题时我常用的一条基线是先分清楚是托管堆还是原生内存问题。托管堆问题看Profiler的GC Alloc曲线和Memory Profiler中的GC Managed Objects大小。原生内存问题看Memory Profiler中的Native Objects或引擎Profiler里的资源总内存。如果原生内存不回落优先怀疑资源加载管理器、AssetBundle缓存和静态引用。8. 内存优化最佳实践清单下面这些实践是项目中长期积累下来的规范适合直接纳入团队开发约定。8.1 代码层建立“零GC Alloc”的战斗代码标准战斗系统是每帧都要运行的核心逻辑。对于一个追求流畅的ARPG或射击类游戏主循环里的移动、碰撞检测、伤害计算、技能状态机都应当尽量做到零GC Alloc。可以定期直接打开Profiler在CPU模块中勾选GC Alloc列然后打一场实时战斗把所有GC Alloc超过0的脚本逐一列出来处理。这个动作如果放到版本提测前做一轮大概率会发现很多隐藏分配热点。实际上在进入战斗前可以先预热资源、提前加载必要对象战斗过程中避免动态创建任何一次性对象。对于子弹、敌人、飘字、粒子特效这些高频对象应该统一走对象池。8.2 对象池是减少GC Alloc的第一利器我们用一个简单的对象池示例来说明做法// 文件路径Assets/Scripts/Pool/BulletPool.cs using System.Collections.Generic; using UnityEngine; public class BulletPool : MonoBehaviour { public GameObject bulletPrefab; private StackGameObject pool new StackGameObject(); public GameObject Get() { if (pool.Count 0) { GameObject go pool.Pop(); go.SetActive(true); return go; } return Instantiate(bulletPrefab); } public void Release(GameObject go) { go.SetActive(false); pool.Push(go); } }// 文件路径Assets/Scripts/Combat/PlayerShoot.cs using UnityEngine; public class PlayerShoot : MonoBehaviour { [SerializeField] private BulletPool pool; [SerializeField] private Transform muzzle; [SerializeField] private float fireCooldown 0.1f; private float timer; private void Update() { timer Time.deltaTime; if (Input.GetMouseButton(0) timer fireCooldown) { timer 0f; Fire(); } } private void Fire() { GameObject bullet pool.Get(); bullet.transform.position muzzle.position; bullet.transform.rotation muzzle.rotation; // 子弹移动逻辑由子弹自身的Bullet组件负责 } }对象池的关键是即使对象池里的GameObject被SetActive(false)它仍然会占用场景层级和逻辑内存所以对象池适合复用频率高的对象。对内存的收益是避免反复Instantiate和Destroy造成的GC压力。8.3 资源层建立资源大小预算表项目到中期应该为不同平台建立内存预算表。例如以Android 4GB内存为基准可以给Unity应用设定内存预算上限整包应用内存占用预算约1.5GB到2GBAndroidiOS按设备不同浮动。托管堆预算通常控制在100MB以内超过就要审查代码。纹理总预算占据大头应该单独统计所有Texture2D的Bytes大小加总。Mesh、Audio、Animation的预算分别列项。这些预算表不需要一开始就做得特别细但至少让团队所有成员知道单个界面加载的Texture总大小不能超过多少技能特效的单次加载内存峰值不能超过多少。8.4 真机测试是最终标尺内存优化最忌讳只在编辑器里看数据。编辑器使用Desktop平台的Asset导入和格式选项和移动端的纹理压缩、Shader编译路径完全不同。A项目在Mac编辑器里内存占用300MB到了Android真机上可能是700MB。规范的流程应该是每个里程碑版本都跑一轮真机内存测试分别记录以下指标Application总内存Android的PSS或iOS的Memory Footprint。Unity Profiler里的Native内存总量。GC Used Memory。一次完整场景循环进入关卡 - 战斗 - 返回主界之后的内存基线。如果这一轮循环后内存基线比进入前上涨超过30MB就要优先排查资源泄漏。9. 升级路径与后续学习方向内存管理不是一个可以“一劳永逸”的优化点。随着版本迭代、场景扩张、资源量增加内存问题会反复出现。比较好的做法是把内存优化嵌入到版本的日常节奏中而不是等到项目快上线时才做一次大规模优化。如果要从更深处理解Unity内存管理后续值得关注的方向包括理解Petit GC和Boehm GC的差异。Unity的GC在不同后端下有不同的实现策略。阅读Addressables的源码理解它如何管理AssetBundle的依赖图与引用计数。学习ECSDOTS架构通过把游戏逻辑迁移到Job System和Burst Compiler减少主线程上C#堆分配的压力但这需要较高的重构成本新项目可以考虑尝试老项目不建议中途强行改造。关注Unity官方Memory Profiler的新版本更新新版工具在Native Object追踪上越来越精确。真正到生产级项目的阶段内存管理更像是一个“工程纪律”问题。团队成员都要对GC Alloc、资源生命周期、对象池、纹理压缩这些基础概念有统一认知否则再好的工具也救不了一个每个脚本都在乱new和动态加载的项目。对正在读这篇文章的开发者我建议从今天开始做一件事打开你的项目用Profiler抓一次GC Alloc排序从最高的脚本往下处理三个分配热点。不要贪多先做完这三个再跑一次帧时间你会直观地看到优化的价值。