尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Unity内存管理机制全解析:从GC分配到资源生命周期优化

Unity内存管理机制全解析:从GC分配到资源生命周期优化 最近在帮一个项目排查线上问题场景非常典型游戏在玩家手机上的运行表现还可以帧率基本能维持在 30 以上但玩的时间一长内存曲线就开始稳步爬升。一开始上升很慢看不出异常直到某个大副本切换场景游戏直接闪退。抓回来的日志里写着OutOfMemory团队第一反应是“纹理太多了”结果把所有贴图压了一遍问题依然存在。最后用 Profiler 深挖才发现在场景切换、UI 刷新、怪物死亡掉落这些看似不起眼的逻辑里托管堆不断分配、极少释放内存碎片越攒越多终于在某个临界点把系统压垮。这个例子很能说明问题。Unity 项目一旦进入中后期性能优化几乎就是内存优化的同义词。很多团队在初期不重视内存管理机制到了线上才被现实狠狠教育。与其等到闪退和卡顿追着跑不如先把 Unity 的内存管理机制彻底搞懂。这篇我会把 Unity 的内存分成几个层次讲清楚然后落到 Profile 工具使用、资源生命周期、常见分配误区和工程规范上尽量给你一条可以直接照着做的路径。1. Unity 里的内存并不是“一块内存”很多开发者对 Unity 内存的理解是Unity 引擎负责管理所有对象用完就回收不需要操心。这个理解只对了一半而且容易在项目后期引发灾难。Unity 的内存管理本质上可以分为三层内存区域管理方式主要存放内容典型问题Native 内存C 侧由引擎直接管理纹理、网格、音频、着色器、动画片段等资源资源未卸载、AssetBundle 未释放Managed 托管堆C# 侧由 Mono/IL2CPP 运行时管理脚本对象、字符串、数组、容器、闭包GC 分配频繁、堆增长不回收图形驱动内存显存与共享内存RenderTexture、图形缓冲、GPU 资源分辨率过高、RenderTexture 泄漏先说Native 内存。Unity 引擎本身是 C 写的当你加载一个 Texture2D、Mesh、AudioClip 时大部分数据实际上存在引擎的 C 对象里。这部分内存不归 C# 的垃圾回收器管而是由引擎的引用计数和资源生命周期决定。也就是说你在 C# 层把一个Texture2D对象置为null并不代表 GPU 显存里的图片数据被释放了还得看引擎侧有没有真正销毁它。再说Managed 托管堆。你在 C# 脚本里写的new Listint()、string.Format、LINQ查询这些分配都发生在托管堆上。对应的运行时是 Mono 或 IL2CPP它们都有垃圾回收机制但 GC 不是“实时”的而是在检测到内存压力或达到阈值时才触发。一旦触发就会暂停脚本执行表现就是帧率瞬时掉一下也就是大家常说的GC SpikeGC 卡顿。至于图形驱动内存在移动端特别重要。Unity 纹理默认可能带有 mipmapRenderTexture 如果设了过高的分辨率显存压力会立刻上来。这块内存通常和 Native 内存交织在一起Profiler 里也能看到但分析时需要区分是“引擎分配的”还是“驱动分配的”避免误判。三层内存互相独立又互相影响。很多新手优化的误区是只盯着 Managed Heap或者只压贴图大小看不到全貌。后面所有章节都会以这个分层模型为基础展开。2. 托管堆与垃圾回收为什么 Unity 的 GC 经常被骂要理解 Unity 内存管理机制绕不开 C# 的托管堆与垃圾回收。这里我先给一个明确判断Unity 的 GC 在设计上偏向“自动”、但实现上并不适合高频小对象分配的游戏逻辑。导致 GC 卡顿的本质原因有两个一是分配过快二是回收时 STWStop The World。2.1 谁在跑你的 C# 代码Mono 与 IL2CPPUnity 脚本运行时有两种选择Mono 和 IL2CPP。MonoJIT 编译支持运行时反射编译快调试方便但性能相对偏低移动端已逐步淡出主流。IL2CPP先把 C# 转成 C再编译成原生二进制。静态代码执行效率更高但托管堆和 GC 还是保留着的。这里必须澄清一个常见误解很多人以为用了 IL2CPP 就没有 GC 了这是错的。IL2CPP 只是把 IL 转换成 C运行时依然需要管理 .NET 对象生命周期垃圾回收算法依然存在只是实现方式有差异。从 Unity 2020.2 开始官方引入了增量式垃圾回收Incremental GC把一次完整的 GC 暂停拆成多步执行目标是把一次几百毫秒的卡顿变成多次几毫秒的小停顿。听起来挺美好但它只是把“痛苦”分散了并没有消除。如果你的逻辑每秒分配了成千上万个对象GC 还是会频繁触发帧率依然会有波动。2.2 托管堆的分配与回收策略C# 的托管堆和 Java 的堆类似分代概念简化后可以理解为新分配的对象进入第 0 代GC 时如果还存活会晋升到第 1 代、第 2 代。Unity 的 GC 在移动端通常触发条件托管堆剩余空间不足时分配新对象系统内存紧张时主动 GC手动调用System.GC.Collect()不推荐频繁使用。问题是Unity 托管堆一旦扩容大部分情况下不会把内存还给操作系统。假设你的项目在某次战斗中分配了 512 MBGC 回收后堆容量可能仍然保留在 512 MB只是里面的“空房间”变多了。这会导致 Profiler 里看到 Managed Heap 始终维持在高位Android 系统的内存水位也降不下来最终被系统判定为内存占用过高。2.3 最容易被忽略的 GC 分配源很多团队优化 GC 时只盯着new关键字忽略了基础设施里的隐式分配。我列几个高频 GC 分配源// 1. 字符串拼接 string text score.ToString() 分; // 2. 装箱 object o someInt; // 3. LINQ 与匿名方法 var list allItems.Where(x x.isActive).ToList(); // 4. 协程 WaitForSeconds yield return new WaitForSeconds(1f); // 5. UnityEngine 里的临时数组 Vector3[] corners new Vector3[4]; // 每次调用都 new // 6. foreach 某些容器 foreach (var kv in dictionary) { ... }逐个解释字符串拼接每次都会产生新字符串对象循环里拼接就会高频分配。装箱发生在值类型转成object或接口时例如把int塞进ArrayList、Debug.Log拼接对象或者使用某些非泛型 API。LINQ的Where、Select、ToList都会生成迭代器和临时集合遇到性能敏感的逻辑需要避免。协程如果不缓存WaitForSeconds每次yield都 new 一个对象。foreach 遍历 Dictionary在部分 Mono / 旧版本 .NET 运行时会带来额外的迭代器分配虽然新版本改进过但直觉上仍需注意。从工程角度我的建议不是“零分配”这么极端而是在每帧都会执行的 Update、热点循环、战斗单位频繁交互的逻辑里把分配控制在“分配目标数量内”。比如规定一次普通攻击的Update逻辑产生 0 或 1 个对象以内这样 GC 压力即可控。3. 内存泄漏的本质不是“内存没释放”而是“引用还被占着”继续深入的关卡有一个几乎绕不开的问题为什么资源明明加载了却一直不释放为什么场景切换后内存还在涨最根本的原因是Unity 对象“活着”的条件是被引用。这个引用可以来自场景中的 GameObject、C# 静态字段、事件的发布方订阅方、协程或引擎内部的资源关系表。理解了这一点内存泄漏的本质就很清楚了不是内存没有释放而是你的代码仍然持有它垃圾回收器认为它“还在用”。3.1 静态引用是最隐蔽的泄漏看一个很容易犯错的例子public class GameData { public Texture2D icon; public AudioClip clickSound; } public static class DataManager { public static ListGameData allData new ListGameData(); }如果allData在关卡加载时不断添加数据但游戏流程里从不清理那么它引用的所有 Texture 和 AudioClip 都会常驻内存。哪怕你调用了Resources.UnloadUnusedAssets()只要静态列表还挂着引用资源就不会被真正卸载。很多项目在跳转场景时只销毁了场景中的 GameObject忘了清理静态缓存内存自然随着游戏进程持续增长。排查思路很简单在 Profiler 的 Memory 面板里看资源引用关系找出谁还持有它。3.2 事件订阅不注销C# 的事件是另一个高频泄漏点。比如一个 UI 管理器注册了某个 Boss 的血量变化事件BossBattleManager.OnBossHealthChanged UpdateHealthBar;Boss 场景结束后如果BossBattleManager本身被销毁或不再使用但 UI 窗口还挂着UpdateHealthBar的订阅那么BossBattleManager就无法被 GC。这种“不知道谁在收我的事件”的问题在 UI 系统、网络消息分发里尤其普遍。工程上的解决办法是成对出现注册和注销写在同一个生命周期里要么在OnEnable/OnDisable要么在OnDestroy里统一撤销。绝不要只在初始化时注册却忘了清理。3.3 资源加载层级带来的额外引用Unity 的 Resources 系统会把 Resources 目录下的所有资源打进包游戏运行时只要有 Resources.Load 的调用资源就一直驻留。一旦你用 AssetBundle 加载了一个资源该资源依赖的其他资源也会被加载并缓存。如果 B 资源被 A 引用、A 又被 C 引用C 不释放B 就无法卸载。这种依赖链问题在大型项目中经常导致“明明做了资源卸载内存还是下不来”。要解决它必须引入引用计数或 Addressables 的资源管理方案通过统一入口控制加载与释放而不是让各处代码直接AssetBundle.LoadAsset。4. 从 Profiler 开始诊断内存问题到底出在哪没有数据支撑的优化都是玄学。Unity 提供了多个工具最常用的是Profiler Memory 面板和Memory Profiler 包。我建议团队把这套诊断流程固定下来作为每个里程碑的必查项。4.1 核心分析Build 真机在 Profiler 中观察 Memory点击 Window Analysis Profiler切到 Memory 模块。重点关注几个字段Total Allocated当前总分配内存。Total ReservedUnity 从系统中预留的内存一般大于已分配。Managed Heap Used托管堆中实际使用的字节数。Managed Heap Reserved托管堆从系统获得的字节数。Graphics显存 / 图形资源占用。Audio、Video、Asset等分类项。如果Managed Heap Reserved很高、Managed Heap Used却不高说明堆扩容过但没有返还给系统。这可能引发内存“虚高”和低端机的系统杀进程即使你的实际资源占用不高。4.2 真机 Profile 实跑流程完整流程分为步选择目标设备或编辑器建议同时开 Development Build 与 Autoconnect Profiler。设置 Profile 时间为 30 分钟场景包含主界面、战斗、结算、切场景、切后台再回前台。每完成一个阶段记录 Managed Heap、Total Reserved 和资源分类数值。如果某个阶段后内存没有回到阶段前水平就说明有泄漏或未释放的资源。这个流程一定得在真机上跑。编辑器里的内存行为与真机差距极大编辑器自身会缓存大量资源经常掩盖问题。4.3 用 Memory Profiler 包找泄漏源Unity 自带的 Profiler 可以看总量但定位“谁持有了这个资源”需要更细粒度的工具。官方维护的Memory Profiler包com.unity.memoryprofiler能从底层快照托管堆对象、Native 资源和引用关系。基本用法Window Package Manager 搜索 Memory Profiler Install打开后点击“Take Snapshot”引擎会采集当前进程的内存快照。你可以在快照里搜索某个 Asset 的名字看到它的引用路径哪个 C# 对象、哪个 JavaScript / 闭包 / 静态字段 / 场景对象还在引用它。这套玩法比肉眼盯代码高效得多。5. Resources、AssetBundle 与 Addressables该用谁怎么释放考察一个成熟项目的内存管理绕不开资源加载架构。很多项目早期图省事把所有美术资源塞进 Resources 目录后面想优化但发现全项目到处是Resources.Load直接抓狂。5.1 Resources 目录的隐藏成本Resources 机制会把目录里的所有资源全部打进主包或 StreamingAssets 的依赖中。它方便但有几个坏处启动包体变大加载慢所有 Resources 资源在启动后都可被加载且默认常驻不方便热更新和分包管理卸载需要Resources.UnloadUnusedAssets()而这个接口在部分情况下并不及时因为是异步扫描资源关系的操作。我见过最夸张的项目Resources 目录有 2 个 G 的贴图导致构建产物巨大、启动时间十几秒。这种结构越早重构成本越低。5.2 AssetBundle 的正确释放姿势AssetBundle 是更底层、更灵活的资源打包方式。团队需要自行管理 AB 的加载、依赖和卸载。核心 API 有三组// 加载 AssetBundle bundle AssetBundle.LoadFromFile(path); AssetBundleManifest manifest bundle.LoadAssetAssetBundleManifest(AssetBundleManifest); AssetBundle depBundle AssetBundle.LoadFromFile(depPath); // 获取资源 Texture2D tex bundle.LoadAssetTexture2D(icon_hp); // 卸载 bundle.Unload(false); // false 只卸载 AB 的容器不卸载已加载的 Asset bundle.Unload(true); // true 会一起卸载 AB 加载出来的 Asset重要区别要看懂Unload(false)只卸载 AssetBundle 自己的数据和容器已经从该 AB 中加载出来的资源对象仍然驻留并且在大多数情况下还会继续用底层资源。Unload(true)则会强制卸载该 AB 加载出的所有资源此时如果场景中还有对象引用这些资源这些引用会变成“空引用”之后访问就会出现崩溃或资源丢失。正确的释放策略要配合引用计数依赖该资源的对象都已销毁这时才Unload(true)。如果还有对象在用只能减少计数。这也是 Addressables 存在的意义。5.3 Addressables用引用计数封装资源生命周期Unity 官方的Addressables提供了更可控的资源管理模型。它内部管理 AssetBundle对外提供一个异步加载地址的 API并且自动追踪引用计数。基本用法using UnityEngine.AddressableAssets; using UnityEngine.ResourceManagement.AsyncOperations; AsyncOperationHandleGameObject handle Addressables.InstantiateAsync(Enemy/Prefabs/Boss); handle.Completed op { GameObject go op.Result; // 使用完成后释放 Addressables.ReleaseInstance(go); }; // 如果只加载资源不实例化 AsyncOperationHandleTexture2D texHandle Addressables.LoadAssetAsyncTexture2D(Textures/HPBar); texHandle.Completed op { Texture2D tex op.Result; // 用完释放 Addressables.Release(texHandle); };Addressables 的明显优势自动管理 AssetBundle 依赖关系引用计数为 0 时自动卸载 AB 和资源支持热更新内容目录与资源分组、构建管线打通。但引入 Addressables 不是免费午餐。它需要学习组配置、远程与本地目录方案。团队如果打算上 Addressables最好是在项目早期统一规范中后期再迁移成本会明显上升。5.4 场景切换与资源清理策略无论是 Addressables 还是 AssetBundle完整的内存回收流程需要显式触发。Unity 的Resources.UnloadUnusedAssets()接口可以清理未引用的原生资源但它不会清理被 C# 对象引用的资源。所以标准的场景切换流程是卸载旧场景 LoadSceneAsync销毁不再使用的场景对象清理静态事件、静态缓存、协程可选执行Resources.UnloadUnusedAssets()加载新场景根据项目需要判断是否调用System.GC.Collect()。注意Resources.UnloadUnusedAssets()是异步的在它执行完成之前立刻加载新场景可能造成帧率波动所以要安排成协程来处理IEnumerator CleanAfterLeavingScene() { yield return null; Resources.UnloadUnusedAssets(); System.GC.Collect(); yield return new WaitForSeconds(0.5f); // 再加载新场景 }业内对“是否该手动调System.GC.Collect()”有争议。我的看法是在大型场景切换、战斗结束回主城这类明确需要降内存的时刻手动调一次是合理的。前提是把它当作可控的尖峰处理选在 Loading 界面、无操作阶段而不是战斗进行中。6. 托管堆分配的代码级优化从 Update 里抠掉 GC 压力想要内存管理机制真正落地光会换资源加载方案不够。日常编码里的习惯才是决定 GC 频率的隐形因素。下面提供一套可以直接用于团队 Code Review 的代码优化思路。6.1 缓存一切高频对象常见的缓存对象WaitForSeconds实例UI 文本颜色、字体样式碰撞检测用的数组临时矩阵、向量数组。错误示例void Update() { StartCoroutine(WaitAndDo()); } IEnumerator WaitAndDo() { yield return new WaitForSeconds(2.0f); // 每秒都 new Debug.Log(do); }正确示例private readonly WaitForSeconds _wait new WaitForSeconds(2.0f); IEnumerator WaitAndDo() { yield return _wait; Debug.Log(do); }看起来微不足道如果是战斗里大量技能、Buff 协程同时运行节省的分配相当可观。6.2 用 StringBuilder 处理复杂字符串日志、数值飘字、排行榜 UI 都属于高频率字符串操作。string是不可变对象每一次都产生新对象。string result ; for (int i 0; i count; i) { result item[i].name; // 高频分配 }改成StringBuilder后尽量预分配容量var sb new StringBuilder(128); sb.Clear(); for (int i 0; i count; i) { sb.Append(item[i].name); } string result sb.ToString();6.3 用对象池处理频繁的实例化和销毁怪物死亡后的掉落物、飘字、子弹、粒子特效这些对象如果反复 Instantiate / Destroy会同时带来 C# 对象分配和原生资源加载销毁的开销。对象池不是一个新概念核心就是复用。Unity 有官方ObjectPool类也可以用第三方插件或手写using UnityEngine.Pool; public class BulletPool : MonoBehaviour { public GameObject bulletPrefab; private ObjectPoolBullet _pool; void Awake() { _pool new ObjectPoolBullet( createFunc: () Instantiate(bulletPrefab).GetComponentBullet(), actionOnGet: bullet bullet.gameObject.SetActive(true), actionOnRelease: bullet bullet.gameObject.SetActive(false), actionOnDestroy: bullet Destroy(bullet.gameObject), collectionCheck: false, defaultCapacity: 50, maxSize: 200); } public Bullet GetBullet() { return _pool.Get(); } public void ReleaseBullet(Bullet bullet) { _pool.Release(bullet); } }对象池需要注意对象释放后如果仍然被某段代码引用再次取出时可能会读到旧状态。正确的做法是池化对象在OnEnable中重置自己的状态不残留上次战斗的数据。6.4 避免装箱和隐式分配装箱最容易出现在日志打印和集合类中。举例Debug.Log(HP: player.Hp); // player.Hp 是 int会被装箱为 object真的需要在每帧输出这种内容时尽量改用字符串拼接前的缓存或者只在低频率下输出。另外ArrayList、Hashtable这些非泛型集合在添加值类型时都会装箱应尽量使用Listint、Dictionarystring, int等泛型容器。6.5 协程接口、闭包与 async/await闭包Lambda捕获局部变量时编译器通常会在堆上生成一个对象。如果闭包在每帧执行的逻辑里创建比如注册按钮点击事件、临时事件委托也会产生分配。button.onClick.AddListener(() DoSomething(index));如果DoSomething需要在每次点击后执行且按钮还可能在旧对象销毁后保留引用那么这个 Lambda 也带来了事件泄漏的风险。更好的方式是定义一个明确方法注册 / 注销都走显式路径。7. 贴图、网格与音频资源显存与 Native 内存优化讲完托管堆回到资源侧。移动端游戏的内存压力大头常常不是 C# 堆而是贴图和网格的 Native 内存。7.1 Texture 是显存第一大户一张 1024x1024 的 RGBA32 纹理占用显存大约是 4 MB。一个中大型项目贴图数量上百张如果没有正确的压缩格式和 mipmap 管理内存很容易爆掉。针对移动端建议使用 ASTC 压缩格式iOS 与主流 Android GPU 都支持纹理内存通常能降 60% 以上不必要加 mipmap 的 UI 贴图不要勾选 Generate Mip Maps图集Sprite Atlas内的贴图保持统一格式避免重复上传RenderTexture 按设备分辨率动态创建用完释放实时阴影相关的 Shadow Map 分辨率要谨慎高分辨率 RT 在移动端代价极高。在项目早期就应该整理一份纹理压缩规范表。例如用途推荐分辨率推荐格式是否需要 mipmapUI 图标64-256RGBA Compressed ASTC否UI 大背景1024-2048RGBA Compressed ASTC否3D 角色主贴图1024-2048RGBA Compressed ASTC是透贴特效贴图256-512RGBA Compressed ASTC是UI 半透明光效256-512RGBA Compressed ASTC否这不是能一条规则跑到黑的但建立规范后美术同学提交资源时更容易自查。7.2 网格与骨骼动画网格的顶点数据位置、法线、UV、切线、骨骼权重都存入 Native 内存。角色数量多、精度高的项目网格内存紧随贴图之后。常见手段开启 Mesh Compression 压缩精度控制在肉眼可接受范围内关闭不必要的 Read/Write读写权限否则 Unity 会在内存中额外保留一份 CPU 可访问副本使用 GPU Skinning 时部分 SkinnedMeshRenderer 的本地副本可以避免上传到 GPU动画压缩改为 Optimal 或高压缩减少 AnimationClip 内存。7.3 音频剪辑音频通常会以压缩和 PCM 两种形态存在。AudioClip 在加载时默认会保留完整音频数据在内存中如果同时播放很多长音频内存上涨明显。优化手段Load Type 选择 Compressed In Memory 或 Streaming短音效一次性加载进内存长 BGM 使用流式加载原始音频尽量用 Vorbis / ADPCM 减小体积检测不必要的立体声转单声道。8. 实战排查一套内存泄漏与卡顿的排查流程本节提供一个按步骤执行的排查流程。当你遇到“内存持续增长”或“每过一段时间就闪退”时直接照做。8.1 准备脚本周期性记录内存数据你需要一份能输出关键内存指标的工具代码。可以放在 Debug 面板也可以输出到日志using System; using UnityEngine; using UnityEngine.Profiling; public static class MemoryLogger { public static string GetMemoryInfo() { return string.Format( [Memory] ManagedHeapUsed: {0}MB, ManagedHeapReserved: {1}MB, TotalAllocated: {2}MB, TotalReserved: {3}MB, Texture: {4}MB, Mesh: {5}MB}, Profiler.GetMonoHeapSizeLong() / (1024 * 1024), Profiler.GetMonoUsedSizeLong() / (1024 * 1024), Profiler.GetTotalAllocatedMemoryLong() / (1024 * 1024), Profiler.GetTotalReservedMemoryLong() / (1024 * 1024), Profiler.GetAllocatedMemoryForGraphicsDriver() / (1024 * 1024), 0); } }运行测试时每隔固定时间把这个日志打印出来。如果总在内存在某个流程结束后没有回落直接定位到那个流程。8.2 用最小复现法压缩问题范围内存问题通常不会那么容易直接复现。比较有效的方法是二分定位准备好一条流程随机禁用功能性逻辑比如关闭技能特效、关闭伤害飘字、关闭小地图更新等看看哪个模块被关闭后内存泄漏消失。找到了嫌疑模块再逐步裁掉这个模块内部的片段直到缩小到某个具体的脚本或资源。这个过程可能很枯燥但比盲改贴图质量和频繁Resources.UnloadUnusedAssets()有效得多。不要用优化贴图的方式来掩盖内存泄漏那是拆东墙补西墙。8.3 两张快照的差异分析使用 Memory Profiler 时一个技巧是进入场景前拍一张快照 A完整执行一轮战斗和切场景回主界面后拍快照 B对比快照 A 与 B 的差异。差异里新增且没有释放的 Native Object、Managed Object 数量基本就是泄漏对象。如果 A 到 B 经历了场景切换但大量同名字对象依然存活说明清理逻辑没有生效。此时逐个点开对象看它的引用路径来自哪里顺着引用链就能找到问题代码。8.4 常见情况Unity 场景切换后资源仍滞留一个非常常见的反应是“场景都切了怎么 UI 的对象还在内存里”可能原因有旧场景的 GameObject 被加进DontDestroyOnLoad且从未移出UI 图集被旧 UI 窗口引用窗口被关闭但 Canvas 还没销毁某个单例对象持有旧场景的 List 资源加载系统缓存了 AB且没有调用 Unload场景未通过SceneManager.UnloadSceneAsync正确卸载。排查建议写一个运行时检查工具定期扫描当前活动场景数、DontDestroyOnLoad 根节点数、Resources 缓存表长度、Addressables 引用计数。如果发现问题输出定位日志。9. 内存优化常见误区与工程红线下面列出我这些年看到团队反复踩的坑。有些问题出在技术上更多的出在流程意识上。误区真相建议只要用了 Addressables 就自动管理内存Addressables 只是帮你引用计数业务代码泄漏照样泄漏学习分组与释放生命周期定期用 Profiler 核验GC.Collect 是优化工具它只是手动触发一次 GC如果代码高频分配调用越频繁越卡只在切场景、回大厅、Loading 时调用把对象设为 null 就会释放对 C# 托管对象成立但不代表原生资源释放结合资源系统释放必要时调用引擎 APIAB.Unload(false) 后内存完全释放只释放 AB 容器资源仍可能被引用检查资源是否被场景/C#对象持有渲染内存问题最先怀疑贴图可能是 Mesh、Audio、RenderTexture 或资源缓存问题先用 Profiler 分类归因再动手优化编辑器 Profiler 足够发现所有内存问题编辑器比真机“宽容得多”许多泄漏在编辑器里不易触发必须真机 长时间运行 低端机验证内存优化是后期的事后期项目重构成本数倍于早期初期就定资源规范与生命周期方案还有两条工程红线要特别强调生产环境不轻易使用DestroyImmediate卸载资源它可能会在引擎迭代中破坏引用关系热更新与资源加载逻辑必须有统一入口禁止在不了解引用的地方直接AssetBundle.LoadAsset。10. 优化后的效果验证怎么判断内存优化真的有效修改完以后你需要一套验证指标。建议记录以下数据并形成趋势报告PSSProportional Set Size按比例分摊后的物理内存在 Android 系统里的峰值与均值特定流程前后的Total Reserved差值Managed Heap Used 在 5 分钟持续操作中的最大值每帧 GC Alloc 量单位 B触发 GC 的频次低端机如 2G 内存机型连续运行 30 分钟是否稳定。验证时同样要跑多条路线主界面待机、战斗循环、功能混用、切后台回前台、长时间挂机。内存泄漏通常不是一次操作触发而是经过多轮场景切换和资源加载才表现出来。所以压力测试至少要模拟“进入战斗退出再进入”循环 30 次以上观察内存曲线是否呈阶梯式上升。我记得有个项目优化前跑 15 次战斗后 PSS 从 800 MB 涨到 1.4 GB优化后连续跑 50 次战斗内存稳定在 900 MB 左右。差距不是某个单点优化造成的而是资源生命周期、GC 分配、缓存清理、贴图格式四方面一起改善的结果。这也是我想表达的核心观点内存优化是体系性问题靠一两条技巧无法根治。11. 快速自检清单给正在做性能优化的团队结合 Unity 内存管理机制这份清单可以直接复制到团队的 CheckList 里[ ] 项目使用的自定义资源加载方案或 Addressables 分组是否有文档[ ] 场景 A 切换到场景 B 后旧场景的 GameObject 是否全部销毁[ ] 单例和静态类里是否还有对 GameObject / Texture / Mesh 的引用[ ] 事件注册和注销是否配对[ ] 协程是否缓存了 WaitForSeconds[ ] Update 中是否有字符串拼接、LINQ、装箱操作[ ] 对象池是否覆盖了子弹、飘字、VFX 等高频对象[ ] 贴图格式是否是 ASTC / ETC2是否误开 mipmap[ ] Mesh 是否关闭了 Read/Write[ ] 音频是否按用途选择了 Streaming 或 Compressed In Memory[ ] 场景切换时是否执行了资源卸载与手动 GC[ ] 是否在真机上持续 Profile 30 分钟如果多数选项是否定的你的项目大概率会有内存增长隐患。趁早处理远远好过等玩家卡顿、闪退后再救火。12. 总结Unity 内存管理的关键认知Unity 的内存管理不是单一机制而是Native 资源、Managed 托管堆、图形驱动内存三层体系的协同。任何一层不理顺都可能把整体压垮。我的核心判断是大部分 Unity 内存问题不是“跑得太久才内存不够”而是“设计阶段就种下了泄漏或过度分配的种子”。资源加载方式不统一、引用生命周期不清、高频 Update 逻辑随手分配对象这些才是根源。Profiler 与 Memory Profiler 能帮你定位问题但最终能不能解决取决于团队是否建立了内存管理机制和性能红线。后续可以继续深入的方向Unity 增量式 GC 在不同平台的实际表现与参数调优Addressables 的加载、缓存、依赖管理与远程内容更新真机环境下的 PSS / GPU Memory 监控方案Mono/IL2CPP 下托管堆内部结构与 GC 算法细节更底层的 Unity Native 插件内存分配与跨语言边界管理项目级统一资源规范与构建产物体积优化。如果你的项目正在经历内存飙升、切换场景卡顿或低端机闪退建议先把今天说的排查流程完整走一遍。内存优化是一项系统性工程先把机制弄明白再谈技术选型会少走很多弯路。
返回列表