性能优化系列 · 内存篇。一句话摘要:先在真机快照中找出真正占内存的资源,再判断它该不该常驻;必须加载的资源按设备档压缩、缩小和分档,最后同时看峰值与回落,确认资源已经卸载。
内存优化解决的是"哪些东西一直留在内存里、峰值时为什么同时出现"。GC 也会影响内存曲线和偶发卡顿,但它主要处理托管堆中的短时分配与回收;纹理、网格、音频、RenderTexture、AssetBundle 与 Native 内存的长期占用,不会因为一次 GC 自动消失。GC 会在后续单独成篇展开,本文只处理资源与运行时内存的长期占用、峰值和回收。
本文从一个使用异步资源加载、窗口复用、预加载与临时渲染纹理的移动项目中提炼方法。项目名称、资源名和真实预算均已省略。
先看内存指标
不要一打开 Profiler 就盯总内存,然后开始删资源。先在目标真机的 Memory 模块中判断压力来自托管堆、图形资源,还是整个进程;再用 Memory Profiler 快照定位到具体对象。不同 Unity 版本的显示名称略有差异,但含义基本一致:
| 指标 | 表示什么 | 用来回答什么 |
|---|---|---|
| System Used Memory | 系统报告为当前 Player 使用的内存 | 整个进程是否正在逼近目标机的可承受范围;内存验收优先看它的峰值与恢复 |
| GC Used / GC Reserved Memory | 托管堆已使用量 / Unity 为托管堆保留的空间 | 是业务 C# 对象长期持有,还是堆只是暂时保留;它不是纹理、网格和 RT 的总和 |
| Gfx Used Memory | 驱动估算的纹理、RenderTexture、Shader、网格等图形资源内存 | 图形资源是否是主要压力来源;再用快照拆到纹理、网格、材质和 RT |
| Texture / Mesh / Audio Memory | 对应类别的统计占用 | 哪一类资源应先排查;不要把它们与 System Used 简单相加 |
| Object / Material Count | 当前原生对象和材质实例数量 | 是否有窗口、实例、材质或对象池随着流程次数持续增长 |
| GC Allocated in Frame | 这一帧新增的托管分配 | 用于排查 GC 卡顿,不代表长期内存大头;详细治理见 GC 篇 |
Profiler 的 Memory 模块展示系统使用、托管堆、图形、音频等汇总计数;详细快照可以收集对象引用,继续追到"谁还持有它"。Unity Memory Profiler 模块说明
先在 Release 包的目标机上跑一遍完整路径,记录曲线;再在异常点抓 Memory Profiler 快照。Editor、Development 包和连接 Profiler 本身都会改变内存水位,只能用于定位,不能直接当作玩家设备预算。
增长和峰值分别怎么看
| 现象 | 优先检查 | 常见原因 |
|---|---|---|
| 返回稳定场景后内存长期高于基线 | 快照差分与引用链 | 资源句柄、静态引用、窗口缓存或对象池未释放 |
| 切场景时突然超高,之后能回落 | 加载时序与峰值快照 | 新旧场景、预加载和常驻 UI 同时存在 |
| 打开预览/截图/遮罩后增长 | GPU 与纹理对象 | RenderTexture、Texture2D、材质或 UI 引用未清理 |
| 低端机被系统杀进程 | 真机系统内存与最长路径 | 总量或峰值超过设备可承受范围 |
移动端没有可依赖的桌面级交换空间。内存问题的验收不是"这台开发机还能跑",而是目标机型在最重路径上没有持续内存压力、没有被系统杀进程,并在流程结束后恢复到可接受水位。
按这个顺序处理
如果答案是否定的,重点是卸载时机、引用关系和加载顺序:不该常驻的资源不要预加载,不再使用的资源要能退,且旧资源应尽量在新资源的大批加载前离场。
下面是项目中抽象出的生命周期约定。
1. 先判断资源是否必须常驻
资源句柄必须有唯一的所有者
项目中的异步资源加载统一记录句柄,并在窗口、玩法或场景的生命周期结束时释放。这个做法的关键不是多写一行 Release,而是回答"谁有资格决定该资源不再被使用"。
Addressables 的 Release 会释放操作及其关联资源;由 InstantiateAsync 创建的对象则应走相应的 ReleaseInstance 生命周期。Addressables Release API
建议按以下边界分配所有权:
- 短时窗口持有的资源,在窗口永久关闭且不再复用时释放;临时隐藏不等同于释放;
- 场景或玩法持有的资源,在场景卸载、玩法结算或统一切换阶段集中释放;
- 共享资源交给公共缓存或资源管理层计数,单个使用者不能擅自释放;
- 异步加载的成功、失败、取消和对象销毁路径都要收尾,不能只在成功回调里处理。
Release 后内存不一定立即归零:其他句柄、依赖或场景对象仍可能持有资源。相反,提前释放仍在使用的资源会造成间歇问题。先定义所有权,再统一管理,是比四处补释放更可靠的做法。
统一实例化入口,让存活实例自动保活资源
资源从缓存中实例化后,最危险的情况是:调用方已经释放了 prefab 的加载句柄,但场景里仍有由它创建的实例在使用网格、贴图或材质。项目的处理方式是,所有受资源管理的实例都由统一入口创建,并自动附加一个对象资源跟踪组件:
- 创建实例时,跟踪组件登记该实例依赖的资源,并增加对应引用计数;
- 只要实例仍存活、引用计数大于 0,资源管理层就不会卸掉对应资源;
- 实例销毁时,跟踪组件在生命周期回调里自动减少登记过的引用计数;
- 异步实例化期间额外保活资源,生成完成并完成登记后再归还这份临时引用,避免加载与实例建立之间出现释放窗口。
下面是 C# 风格伪代码。重点不在组件名称,而在"创建入口交接引用、实例销毁归还引用"必须是一条统一链路;它表达职责,不是可直接复制的项目源码:
csharp
private async Task<GameObject> CreateManagedInstanceAsync(string assetKey, Transform parent)
{
var loadRef = ResourceManager.Acquire(assetKey);
var prefab = await loadRef.Task;
if (prefab == null)
{
ResourceManager.Release(loadRef);
return null;
}
var instance = Object.Instantiate(prefab, parent);
var tracker = instance.GetComponent<ObjectResourceTracker>()
?? instance.AddComponent<ObjectResourceTracker>();
ResourceManager.Retain(loadRef); // 交给实例持有一份引用
tracker.Track(loadRef);
ResourceManager.Release(loadRef); // 创建入口的临时引用归还
return instance;
}
private void OnDestroy()
{
foreach (var resourceRef in _trackedReferences)
ResourceManager.Release(resourceRef);
_trackedReferences.Clear();
}
这样"实例销毁 → 引用计数减少 → 无其他使用者时资源可释放"成为统一链路,调用方不用在每个 Destroy 后手写一组容易漏掉的资源释放逻辑。它解决的是及时回收,不是强制立即卸载:资源仍可能被其他实例、共享依赖或缓存使用。
对象池是这个机制的边界。对象回到池里通常只是隐藏,并不会触发销毁,因此它会继续持有资源引用;只有清池或真正销毁对象时,引用才会自动归还。池容量、清理时机和资源缓存策略必须一起设计,不能只因为"对象复用了"就认为资源会自动退出内存。
临时 GPU 资源要显式结束
截图、角色预览、相机转 UI 和遮罩流程容易创建临时 RenderTexture。它们不在托管堆上,GC.Collect() 不能替代释放。
- 短命中间纹理使用
RenderTexture.GetTemporary,在成功、失败和取消路径都调用RenderTexture.ReleaseTemporary;Unity 会复用匹配的临时纹理。RenderTexture.GetTemporary - 自己创建的
RenderTexture或Texture2D要明确Release与Destroy的时机;关闭窗口时检查 RawImage、材质或全局 shader 是否仍引用它; - 不要因为"以后可能再用"就长期保存全分辨率截图或预览 RT。按窗口策略、分辨率和设备档位评估,不再需要就释放。
预加载由"同时存在量"决定
预加载用等待时间换内存。项目里会观察已加载资源的时序:新场景资源是否过早出现,旧玩法、结算或窗口资源是否已经退出。新旧资源交叠通常比单个资源偏大更容易制造峰值。
因此预加载的判断不是"加载越早越好",而是:它是否缩短了玩家可感知的等待,且不会让低档设备的基线和峰值越界。必要时调整顺序为"进入清理阶段 → 释放旧内容 → 再加载新内容",而非无差别地提前加载。
Bundle 依赖会让"只加载一个资源"变成加载一组资源
Addressables 的引用计数正确,并不代表 Bundle 划分就没有内存问题。一个资源所在 Bundle 只要依赖另一个 Bundle,加载前者时后者也会进入内存;而且依赖按 Bundle 级别计算,Bundle 内某个资源有跨 Bundle 依赖,可能让同 Bundle 的其他资源也间接带入该依赖。用 Build Layout Report 检查依赖链,而不是只看某个资源的文件大小。Addressable AssetBundle 内存说明
Bundle 的粒度没有固定答案:少而大的 Bundle 往往降低 Bundle 本身的总开销,但不利于提前卸掉局部内容;多而小的 Bundle 更容易控制流程峰值,却会增加 Bundle 数和依赖管理成本。对大厅、玩法、结算、常驻 UI 等关键流程,应该根据"哪些内容总是一起出现、哪些内容必须单独卸载"来拆,而不是只按目录或资源类型拆。
Bundle 的默认策略应当严格:最后一个引用归零时,立即将 Bundle 移出缓存并执行对应的释放流程,不要留下无归属的常驻项。对于 Addressables,就是在不再使用时释放最后一个加载句柄;实际 Bundle 的卸载时点仍由其资源与依赖的引用状态决定。
只有明确要保留热资源、并已设定上限时,才把"引用归零即释放"改为缓存策略:可按最近使用时间做 LRU 淘汰,或在缓存 Bundle 数超过阈值时清理最久未使用的项。缓存策略要同时定义容量、淘汰顺序和切场景/内存压力时的强制清理,否则只是把未释放资源换了一个名字。这样既能避免刚释放又重载的 asset churn,也不会让 Bundle 无限滞留。Addressables 的引用计数与 churn 行为可对照官方内存管理说明。
排查 Bundle 是否真的退出内存,优先在真机 Release 包上观察。Unity 官方明确说明,Editor 的 Play Mode 与 Editor 共用进程,内存数据会受 Editor 本身及其资源管理影响,不能替代目标设备的 Player 验收。Profiling your application
若只能先在 Editor 验证 Addressables,Use Existing Build 最接近正式包的 Bundle 加载方式;Simulate Groups 可用于检查依赖布局和引用策略,但它同样从 Asset Database 加载资源,不能用来证明 Bundle 已实际卸载。Use Asset Database 则以快速迭代为目的,和正式包最不相似。也就是说,Editor 可以帮助定位引用没有归零的问题,最终的资源卸载与内存回落仍应回到真机包确认。Addressables Play Mode Scripts
2. 再判断资源质量与导入设置
运行时内存通常由纹理、网格、音频、Shader、托管堆、Native 插件和资源缓存共同构成。优先从快照中最大的类别开始;在大多数移动项目里,纹理往往是最先需要核对的大头,但不要只凭经验跳过快照。
纹理:先找最大的,再压到视觉可接受的最小值
先在 Memory Profiler 中按纹理的运行时占用排序,结合使用场景确认它是不是同时存在的资源:一张只在过场出现的高分辨率贴图,和十张长期驻留的中等尺寸贴图,优化优先级并不相同。然后在固定机位、目标设备和目标画质下,逐级降低 Max Size,观察画面与内存变化。
Unity 的平台覆盖可以分别设置纹理的最大尺寸和压缩格式。导入图原始尺寸再大,也可以在 iOS、Android 等目标平台设置合适的 Max Size;压缩格式则要按目标设备支持情况选择。纹理导入设置
移动端运行时使用的纹理应默认启用平台压缩;在目标机型支持 ASTC 的前提下,Android 与 iOS 可优先使用 ASTC,而不是把 PNG、JPG 或 PSD 的源文件格式误当作运行时压缩格式。ASTC 每个压缩块固定为 128 bit,块越大,内存越低、失真风险越高。以未压缩 RGBA32(32 bpp)为参照,常用档位如下:
| ASTC 块大小 | 位率 | 相对 RGBA32 的理论内存压缩比 | 常见取舍 |
|---|---|---|---|
| 4×4 | 8 bpp | 4:1 | 近景角色、细节丰富或对压缩伪影敏感的 UI |
| 5×5 | 5.12 bpp | 6.25:1 | 质量与内存之间的折中 |
| 6×6 | 3.56 bpp | 约 9:1 | 大多数常规 3D 贴图的起点 |
| 8×8 | 2 bpp | 16:1 | 远景、低频背景或细节不敏感的资源 |
压缩比是按相同分辨率、完整 RGBA 数据计算的理论值;开启 MipMap、不同尺寸、运行时副本或不支持 ASTC 的兼容包都会改变最终内存。对仍需覆盖不支持 ASTC 的旧设备,应为该设备范围准备兼容格式或调整设备支持策略,不能让 Unity 在运行时解压成未压缩纹理。Unity 的 ASTC 格式说明可参考平台纹理压缩格式。
用 AssetPostprocessor 把基础导入规则自动化
很多资源问题不需要每次靠人工检查。可以用 AssetPostprocessor 把"移动端纹理必须使用 ASTC"这类底线写成导入门禁,至少避免漏配为未压缩格式;再配合构建前扫描,拦住后续手动改坏的资源。下面是 C# 风格伪代码:默认使用 ASTC 6×6,但不在这里自动修改 Max Size。
csharp
private sealed class MobileTextureImportGuard : AssetPostprocessor
{
private void OnPreprocessTexture()
{
var importer = (TextureImporter)assetImporter;
ApplyAstc(importer, "Android");
ApplyAstc(importer, "iPhone");
}
private static void ApplyAstc(TextureImporter importer, string platformName)
{
var settings = importer.GetPlatformTextureSettings(platformName);
settings.overridden = true;
settings.format = TextureImporterFormat.ASTC_6x6;
importer.SetPlatformTextureSettings(settings);
}
}
这个门禁只负责兜底,不替代资源判断。近景角色或对压缩伪影敏感的 UI 可以有明确的例外规则,改用 ASTC 4×4;远景与低频背景则可评估 8×8。Max Size 与是否启用 MipMap 仍应由资源负责人在固定机位和目标设备上确认,因为它们直接取决于展示尺寸和视觉要求,不能由统一脚本盲猜。
检查时按贴图职责区分,不要只盯 Base Map:
- 主颜色、UI 和大面积背景图:优先看屏幕占比与近距离展示需求。全屏、角色脸部等高频近景内容要保留足够细节,边角装饰、远景和小图标通常可以更小;
- Lit 的法线贴图:它主要提供表面起伏感。很多中远景物体并不需要和主颜色贴图同样的分辨率,逐级缩小后检查轮廓附近、高光和近景是否出现明显块状或闪烁;
- 金属度 / 光滑度等材质参数贴图:它们影响的是反射和高光变化,细节是否可见取决于材质、光照、镜头距离与物体运动。若没有明显视觉损失,通常可比主颜色贴图更积极地降低分辨率;
- 烘焙 Lightmap 与反射探针 Cubemap :它们常被归在纹理总量里而被忽略。除检查分辨率和数量外,还要按设备档验证 Lightmap Encoding、压缩质量与流送设置;低质量编码更省内存,但可能出现色带或压缩伪影。Unity Lightmap Compression
- 图集与变体:避免同一内容因多个图集、主题或档位变体在同一流程中重复常驻。
Sprite Atlas 可以用 Variant 做分辨率档位:以高分辨率 Master Atlas 为源,创建一个或多个 Variant Atlas,再通过 Scale 生成低分辨率版本。它是低档机降低 UI 纹理常驻内存的直接手段:高档机选择主图集,低档机只加载缩放后的变体图集。Unity 的 Variant Atlas 继承主图集内容,Scale 可设为 0.1~1。主精灵图集和变体精灵图集
资源配置上,应在玩家进入 UI 前根据设备等级选定对应图集资源:高档加载高分辨率图集,低档加载低分辨率 Variant,而不是把两份都加载后再决定显示哪一份。若依赖图集的自动运行时加载,要特别检查 Master 与 Variant 的 Include in Build 配置;两者同时纳入并自动解析时,不能把它当成"必然加载低分辨率"的保证。切换设备档位时也要先释放旧图集,再重建相关 UI 或重新加载新档位资源,避免短时间内两套图集叠加成峰值。
NPOT(Non-Power-of-Two,宽或高不是 2 的幂)资源也要纳入导入检查,不能让它们无规则地留在工程中。优先在源图阶段裁掉无效透明边、按实际展示尺寸重出图,并把可调整的 3D 贴图处理为 256、512、1024 这类尺寸;再确认图集最终输出和平台覆盖后的实际尺寸。对必须保持精确比例的 UI 图,可保留 NPOT,但要确认它没有被单独长期常驻,或在导入时被放大到更高的 2 次幂尺寸。Texture Importer 的 Non Power of 2 可选择保持原尺寸、取最近/较大/较小的 2 次幂;这是导入期的明确取舍,不应保持默认后不再核对。Unity NPOT 纹理说明
MipMap、Read/Write 也要按用途判断。完整 MipMap 链通常会多出约 33% 的纹理内存,但 3D 物体的镜头距离会变化时,它能让远处使用更低分辨率的层级,减少闪烁与采样带宽;这类贴图通常应保留 MipMap。UI、图标、固定尺寸的界面背景等不会随距离缩小的贴图,通常应关闭它。大型 3D 场景还可以评估 Mip Map Streaming:只有项目的纹理流送设置和贴图 Importer 中的 Streaming Mip Maps 同时配置正确,才会在运行时按需保留高层级;它是用更复杂的加载与带宽调度换取峰值控制,仍要在目标设备上验收。Unity 纹理与 MipMap 说明
只有 CPU 确实需要读写像素时才打开 Read/Write,其他贴图应关闭它。压缩不是只看包体大小,最终要在真机确认运行时内存、加载耗时和压缩伪影。
原则只有一条:在不影响玩家可感知画面的前提下,把每张纹理压到最小。 这里的"不影响"必须来自同机位的 A/B,而不是在 Inspector 中凭原图判断。
除纹理外,继续检查:
- 音频 :不要只看文件大小,要同时检查
Load Type、压缩格式、采样率与并发数。长背景音乐和长语音可评估Streaming,以更少的内存换取流送 CPU 与 I/O;较大的普通音频可用Compressed In Memory;只有频繁播放、且对播放时解码敏感的短音效才评估Decompress On Load。后者会显著增加常驻内存,不能用于长音频。Unity AudioClip Load Type - 网格、动画与 Shader :检查切场景后是否仍被旧对象、材质或常驻系统持有。模型的
Read/Write Enabled只在运行时确实要读写 Mesh 数据、运行时创建 MeshCollider 等必要场景开启;关闭后 Unity 可以卸掉一份 Mesh 数据副本。网格规模较大时还要确认没有无必要地使用 32 位索引。动画导入的Anim. Compression不要长期保持Off:优先评估Optimal或关键帧精简,并用镜头中的动作效果校准误差阈值;关闭压缩会增加文件和运行时内存。Unity Model Importer Unity Animation Compression - 对象池与窗口缓存:容量是否随着一次峰值永久增长,是否有清理边界;
- 预加载与公共资源:确认它们确实被多个流程复用,而不是因为依赖被动长期驻留。
不要先把所有资源降一档。先定位"占用 × 同时存在"最大的组合,再选择压缩、拆分、延迟加载、降低分辨率或缩短缓存时间。
用快照定位没有卸载的资源
每条关键路径固定四个采样点:
- 冷启动或稳定大厅后的基线;
- 进入玩法或打开大窗口后的加载完成点;
- 特效、列表、预览或战斗最密集时的峰值;
- 返回大厅、关闭窗口或卸载场景后的稳定点。
Snapshot 不是每帧都抓。进入采样点后先让异步加载、动画和资源释放趋于稳定,再从连接的目标设备抓取快照;抓取本身有开销,适合用来分析状态,而不是替代连续曲线。每份快照都记录设备档位、画质、场景入口和操作路径,避免把不同条件的数据放在一起比较。
重点比较第 1 与第 4 份快照,以及第 3 份峰值。先看分类总量和对象数量的差异,找到增长最大的纹理、网格、音频或 Native 对象;再查看对象的引用关系,判断它是资源句柄、场景依赖、窗口缓存、静态事件还是对象池仍在持有。若资源在第 4 份快照仍存在,再回到其所有者和释放路径处理,而不是先手动调用一次全局清理。
Profiler 的 Memory 模块适合看 System Used Memory、纹理/网格内存、GC Used Memory 和对象数量趋势;Memory Profiler 适合保存和比较快照、查看对象引用与内存布局。Unity Memory Profiler 模块说明
Editor 的内存不能作为最终预算。用 Release 包在目标设备采样;平台和报告工具的选择见《Unity 性能优化:工具与数据采集》。
回归:同时看峰值和恢复
内存预算要按低、中、高设备档分别定义和记录:稳定基线、流程峰值、流程结束后的恢复值、持续运行时长,以及是否发生系统内存警告或闪退。预算取决于目标机型、系统版本和项目内容,不能直接照搬其他项目的一个固定 MB 数字。
每次只改一类因素,例如只调整一组预加载的时机,或只释放一类窗口资源。在相同设备、画质和操作路径下比较四个采样点。加载更快但峰值更高,或回落变差,都不是无条件的收益。
其他应该注意的点
- 对象池只进不出:池容量没有上限或场景结束不清理,会把临时压力变成常驻内存;
- Addressables 只加载不登记句柄:后续不知道谁该 Release,或多人重复释放同一资源;
- 临时 RT 漏掉取消路径:成功时释放,关闭窗口或异常时没释放,峰值会随使用次数上涨;
- 收到系统低内存通知还保持全部缓存 :订阅
Application.lowMemory,按预先定义的优先级停止预加载、清理可重建缓存和低优先级资源;不要等系统杀进程后才处理,也不要在回调中临时猜测该删什么。Unity Application.lowMemory - 只在 Editor 看内存:Editor、Profiler 与开发包都会改变内存水位,最终必须回到 Release 真机。
总结
内存优化的主线不是"多调用一次释放",而是建立资源生命周期:谁加载,谁持有,何时不再需要,峰值时哪些内容不能同时存在。用快照验证基线、峰值和恢复,才能确认优化既没有提前释放,也没有把资源悄悄留在内存里。