Unity性能优化系列内存篇 - 移动端内存优化

性能优化系列 · 内存篇。一句话摘要:先在真机快照中找出真正占内存的资源,再判断它该不该常驻;必须加载的资源按设备档压缩、缩小和分档,最后同时看峰值与回落,确认资源已经卸载。

内存优化解决的是"哪些东西一直留在内存里、峰值时为什么同时出现"。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 的加载句柄,但场景里仍有由它创建的实例在使用网格、贴图或材质。项目的处理方式是,所有受资源管理的实例都由统一入口创建,并自动附加一个对象资源跟踪组件

  1. 创建实例时,跟踪组件登记该实例依赖的资源,并增加对应引用计数;
  2. 只要实例仍存活、引用计数大于 0,资源管理层就不会卸掉对应资源;
  3. 实例销毁时,跟踪组件在生命周期回调里自动减少登记过的引用计数;
  4. 异步实例化期间额外保活资源,生成完成并完成登记后再归还这份临时引用,避免加载与实例建立之间出现释放窗口。

下面是 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
  • 自己创建的 RenderTextureTexture2D 要明确 ReleaseDestroy 的时机;关闭窗口时检查 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
  • 对象池与窗口缓存:容量是否随着一次峰值永久增长,是否有清理边界;
  • 预加载与公共资源:确认它们确实被多个流程复用,而不是因为依赖被动长期驻留。

不要先把所有资源降一档。先定位"占用 × 同时存在"最大的组合,再选择压缩、拆分、延迟加载、降低分辨率或缩短缓存时间。

用快照定位没有卸载的资源

每条关键路径固定四个采样点:

  1. 冷启动或稳定大厅后的基线;
  2. 进入玩法或打开大窗口后的加载完成点;
  3. 特效、列表、预览或战斗最密集时的峰值;
  4. 返回大厅、关闭窗口或卸载场景后的稳定点。

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 真机。

总结

内存优化的主线不是"多调用一次释放",而是建立资源生命周期:谁加载,谁持有,何时不再需要,峰值时哪些内容不能同时存在。用快照验证基线、峰值和恢复,才能确认优化既没有提前释放,也没有把资源悄悄留在内存里。

相关推荐
WiChP1 天前
【V0.1B15】从零开始的2D游戏引擎开发之路
游戏引擎
天天喝旺仔2 天前
Docker 镜像瘦身实战:多阶段构建把体积缩小 90%
运维·后端·ci/cd·docker·云原生·容器·性能优化
呆呆敲代码的小Y2 天前
【游戏开发】xLua 性能优化技巧
junit·性能优化·lua·xlua
raindayinrain2 天前
深入理解Linux内核--系统调用,性能优化
linux·性能优化·系统调用·返回值·入参·内核态用户态地址访问
youngerwang2 天前
【WSL2 VHDX 文件格式深度研究报告(开发 + 性能优化向)】
性能优化·wsl·vhdx·文件格式解析
weixin_431600442 天前
前端数据埋点(7):Web Vitals——页面慢不慢,不是再包一层 fetch
前端·性能优化·sentry·数据埋点
zhchyun20083 天前
# 【Unity UI 进阶】仿 Element UI 打造企业级 Unity UI 组件库(06)
ui·unity·游戏引擎
袁震3 天前
HarmonyOS 应用包体积优化与上架自检实战:从 76.2MB 到 3.6MB
java·华为·性能优化·harmonyos
raindayinrain3 天前
深入理解Linux内核--ext文件系统,open/write/read/close执行流程,性能优化
linux·性能优化·文件系统·文件系统调用