虽然现代移动设备的物理 RAM 容量持续增长,但由于高分辨率资源和复杂的渲染流水线,游戏内存消耗量超过了这一增长速度。
内存用量过高导致的主要问题
- 低内存终止 (LMK):操作系统强制关闭后台应用 ,甚至前台应用,以解决系统范围内的内存短缺问题。
- 掉帧和卡顿:托管堆中频繁的垃圾回收 (GC) 峰值或操作系统级内存交换会造成处理 瓶颈。
- 热节流和电池耗尽:持续的内存分配、 取消分配和内存页提交会产生显著的 CPU 开销、 导致发热量增加和电池电量消耗加快。
Android 内存管理更新
- Android 17:引入了
MemoryLimiter:Android 17 引入了MemoryLimiter,该功能会根据设备专用阈值主动监控应用 内存消耗量。超出内存限制的应用会在系统级立即终止。由于此机制比传统的 LMK 更严格,因此管理内存使用量峰值现在比以往任何时候都更加重要 。
减少 Unity 中的内存用量
在 Unity 中,引擎一旦扩展其内部内存池(原生块分配器 和托管堆 )以适应较高的峰值负载,就会保留这些内存页,而不是立即将其返回给操作系统。
因此,即使在卸载大型资源后,基准常驻内存 仍会膨胀,导致应用极易受到 Android 操作系统进程终止(例如 LMK 或 MemoryLimiter )的影响。
为防止操作系统级强制执行,必须通过以下三个关键支柱来优化内存:
- 类别 A:减少内存使用量峰值
- 类别 B:消除不必要的资源和系统占用空间
- 类别 C:消除不必要的 GC 分配
类别 A:减少内存使用量峰值
Unity 的分配器会保留释放的内存页以供重复使用,而不是立即将其返回给操作系统,因此基准内存往往反映的是达到的最高峰值,而不是当前用量。因此,从一开始就防止峰值比事后清理更有效。
1. 避免过大的 AssetBundle
由于 Unity 的 Bundle 加载 机制,巨大的 AssetBundle 会导致严重的内存膨胀和 OOM 崩溃。保持 Bundle 模块化,以确保高效的资源管理。
主要问题
- 内存开销高:请求单个小型资源会强制 Unity 将整个 Bundle 文件(包括标头、元数据和流式缓冲区)加载到 RAM 中。
- 卸载陷阱:如果Bundle中的任何资源正在使用,则 整个 Bundle 都无法卸载,从而将未使用的资源困在 RAM 中。
最佳实践
- 保持 Bundle 模块化:按场景或生命周期以逻辑方式对资源进行分组。
- Unity 6.6+ 提示:使用 内容目录 防止 意外的跨 Bundle 依赖项。
2. 优化 ScriptableObjects 中的资源引用
ScriptableObject 中的直接 UnityEngine.Object 序列化字段会创建直接硬引用,强制所有引用的资源在 ScriptableObject 本身加载或实例化后立即加载到 RAM 中。
// BEFORE: Loading SceneRequiredAssets forces _worldAsset and _spawnSettings into RAM immediately
public class SceneRequiredAssets : ScriptableObject
{
public string sceneName;
public Object _worldAsset;
public Object _spawnSettings;
}
// AFTER: Use AssetReference to enable asynchronous, on-demand loading using Addressables
public class SceneRequiredAssets : ScriptableObject
{
public string sceneName;
public AssetReference _worldAsset;
public AssetReference _spawnSettings;
}
3. 配置音频片段和声道
将所有音频片段直接以未压缩格式加载到内存中会产生较大的永久性内存峰值。应根据音频剪辑的使用情况配置音频加载设置:
| 音频类别 | 负载类型 | 原因 |
|---|---|---|
| BGM(背景音乐) | 流式 | 以小缓冲区从磁盘流式传输音频,以消除内存峰值。 |
| 长音效 | 内存中压缩 | 保持较低的 RAM 开销,并在播放期间即时解压缩音频。 |
| 短音效和频繁音效 | 加载时解压缩 | 加载时将音频解压缩到 RAM 中,以避免播放期间的运行时 CPU 开销。 |
除了加载类型之外,还可以应用以下音频配置来进一步减少内存开销:
- 为非定向音频启用“强制单声道”:许多音频资源(例如界面音效、旁白和 3D 空间化音效)不需要立体声道分离。在这些资源的
AudioClip导入设置中启用强制单声道,即可将它们的 内存占用空间和缓冲区大小减半(减少 50%)。 - 按设备层级限制活跃音频声道:每个活跃音频语音
都会分配原生 DSP 混音器和解压缩缓冲区。在内存较低的设备上,
减少同时活跃声道的最大数量,例如,通过
在 Unity 中使用 Max Real Voices (例如从 32 降至 16)降低
AudioSettings.Reset,或者在第三方音频引擎中限制软件播放声道。
4. 实现对象池和释放策略
在场景转换期间保留在对象池中的未释放实例会无限期地保留预留内存,从而导致基准内存占用空间不必要地膨胀。
- 操作:在场景 转换或低活动期间定期清除或修剪未使用的池化对象,以便 Unity 将该 预留空间返回或重复用于其他分配。
类别 B:消除不必要的内存用量
消除冗余图形资源和渲染目标缓冲区可直接降低基准内存占用空间。
1. 优化渲染纹理和相机深度
- 移除深度/模板缓冲区:对于仅需要颜色数据的渲染纹理,将深度模板格式设置为无 。
- 停用界面相机深度纹理:对于不需要深度数据的界面相机,请在 URP 相机设置中停用深度纹理生成,以
消除
CopyDepthPass 和关联的 GPU 纹理内存。
2. 优化纹理和网格
| 类别 | 优化指南 |
|---|---|
| 纹理压缩 | 始终应用目标平台压缩格式(例如,Android 的 ASTC)。 |
| 启用读/写 | 除非必要,否则保持停用状态。启用此功能会在 CPU 和 GPU RAM 中复制纹理内存。 |
| Mipmap | 为界面纹理或固定在恒定相机距离的对象停用 Mipmap,从而节省约 33% 的纹理内存。 |
| 网格复杂性 | 减少不必要的多边形数量和顶点流,以降低 GPU 和原生内存占用空间。 |
3. 剥离着色器变体并优化内存
Uber 着色器(例如 URP Lit Shader)使用 #multi_compile 和 shader_feature 关键字封装了许多功能。如果不进行优化,
组合爆炸会创建数万个唯一的
着色器变体,导致 build 大小膨胀、原生内存
消耗巨大,以及游戏期间 GPU 驱动程序编译卡顿。
A. 着色器变体内存开销的机制
- 组合爆炸:随着 每个添加的关键字组,可能的变体总数呈指数级增长。
- 块分配架构:Unity 将编译后的二进制变体 分组到称为 块 的压缩内存块中(默认值:4 MB)。
- 原生内存膨胀:当运行时代码请求块中的单个变体时, 整个 4 MB 的块都会解压缩到 RAM 中。如果不进行优化,这些块中打包的数千个未使用变体将永久占用原生内存。
B. Unity 内置的多阶段剥离流水线:Unity 会在 build 时根据目标平台图形
设置和未使用的引擎功能(例如雾、光照贴图和 XR
设置)自动剥离
不必要的变体。此外,如果项目的任何素材资源都未主动使用 shader_feature 变体的关键字,则会自动过滤掉这些变体,而无论使用情况如何,都会强制包含 #multi_compile 变体。但是,如果在 URP 或流水线设置中仍启用了渲染或后处理功能(例如
其他光源、实时阴影或未使用的 UberPost 效果),Unity 无法确定您的
内容是否实际使用了这些功能,并会生成所有相应的 #multi_compile
变体。审核并停用未使用的流水线设置,使其与您的
实际渲染要求相符,并防止生成不必要的变体。
C. 自定义自动化剥离架构
(IPreprocessShaders) 由于静态分析无法
检测使用运行时 C# 脚本
(Material.EnableKeyword) 动态修改的关键字,因此标准剥离通常不够充分。为
确保仅包含实际使用的变体,您可以在质量保证测试套件期间使用 Player.log (在编辑器设置中启用
Log Shader Compilation)或 Profiler Traces
(Shader.CreateGPUProgram 标记)收集变体。然后,在编辑器脚本中实现
IPreprocessShaders.OnProcessShader,以过滤掉
在运行时从未执行过的变体,仅在 build 中保留必要的
变体。
D. 整合冗余着色器:Lit 和 Simple
Lit 等 Uber 着色器包含许多 #multi_compile 指令,并在生成大量变体时消耗大量内存:
- 分析同时使用情况:分析内存用量,检查 两个着色器是否同时消耗大量内存,并验证 项目是否确实需要这两个着色器。
- 标准化为单个着色器:如果不需要基于物理的渲染 (PBR) ,请配置资源以消除冗余的 Lit 着色器 用量,并标准化为 Simple Lit (例如,检查导出的 FBX 模型在导入期间是否意外应用了默认的 Lit 素材资源)。
类别 C:消除不必要的 GC 分配
托管堆中的垃圾回收 (GC) 分配会导致内存碎片、堆扩展(永远不会缩小)以及 GC 暂停期间严重的掉帧。
1. 防止 lambda 闭包分配
当 lambda 表达式捕获外部局部变量时,C# 会在堆上生成隐式显示类。在 Update 中执行此操作会在每一帧分配闭包实例。
// Bad: Capturing local variable 'targetId' allocates a new closure object on the Heap every frame
void Update()
{
int targetId = 100;
Monster target = monsterList.Find(m => m.Id == targetId);
}
// Good 1: Replace with a standard 'for' loop (Recommended: 0 B allocation)
void Update()
{
int targetId = 100;
Monster target = null;
for (int i = 0; i < monsterList.Count; i++)
{
if (monsterList[i].Id == targetId)
{
target = monsterList[i];
break;
}
}
}
// Good 2: Use a static lambda (C# 9.0+) if no outer variables are captured
Monster target = monsterList.Find(static m => m.Id == 100);
2. 使用 stackalloc 和 Span
通过使用堆栈内存,避免为短期临时数组进行堆分配。
// Before: Allocates an array on the Heap every call (GC Target)
Vector2[] pos = new Vector2[4];
// After: Utilizes Stack memory using System.Span (0 B Heap Allocation)
System.Span<Vector2> pos = stackalloc Vector2[4];
3. 优化集合迭代
避免因 LINQ 扩展或 ReadOnlyCollection 访问器而导致的装箱和 Enumerator 堆分配。
// Before: LINQ Count() causes internal GetEnumerator() heap allocations
bool hasData = component != null && component.parameters.Count(parameter => parameter.overrideState) > 0;
// After: Replaced with indexer and direct loop iteration
bool hasData = HasDataOptimized(component);
private bool HasDataOptimized(TestComponent component)
{
if (component == null) return false;
var count = component.parameters.Count;
for (var i = 0; i < count; ++i)
{
if (component.parameters[i].overrideState)
return true;
}
return false;
}
4. 其他 GC 分配预防规则
- 避免 使用
Camera.allCameras,因为它会在每次调用时在堆上生成新的Camera[]数组。请改为缓存相机数组并将其传递给Camera.GetAllCameras(_allCameras)。 - 避免对
IReadOnlyList<T>使用foreach**:迭代接口 会导致结构枚举器装箱,从而生成 GC 分配。请改用标准for循环。 - 缓存协程对象:缓存
WaitForSeconds实例,而不是 重复实例化yield return new WaitForSeconds(time);。 - 字典中的自定义结构体键:使用自定义结构体作为字典
键会调用默认的
Equals,从而触发对象装箱。实现IEqualityComparer<T>并将其传递给字典构造函数。
public struct TypeKey
{
public int v1;
public int v2;
public class TypeKeyComparer : IEqualityComparer<TypeKey>
{
public bool Equals(TypeKey x, TypeKey y) => x.v1 == y.v1 && x.v2 == y.v2;
public int GetHashCode(TypeKey obj) => obj.v1.GetHashCode() ^ obj.v2.GetHashCode();
}
}
// Pass custom comparer during Dictionary initialization to prevent boxing
public readonly Dictionary<TypeKey, int> _typeKeyDictionary = new(new TypeKey.TypeKeyComparer());
5. 最大限度地减少泛型和反射的使用
虽然泛型方法提供了出色的代码重用性和可维护性,但在 Unity IL2CPP(中间语言到 C++)后端环境中过度使用它们可能会对项目产生负面影响。
- IL2CPP 代码膨胀:对于每种唯一的泛型类型组合,IL2CPP 都会生成一个专用版本的代码。过度使用复杂的泛型可能会导致生成的 C++ 代码出现“组合爆炸”,从而显著增加应用的二进制大小和原生内存占用空间。
- 反射开销:使用反射的方法(例如
System.ReflectionAPI)本质上很慢,并且经常在运行时导致堆 分配。 - 最佳实践:谨慎使用泛型,优先考虑
架构清晰度,而不是广泛、不加区分的应用。在性能至关重要的情况下,请选择具体类型或基于接口的多态性。对于反射,请在初始化期间缓存结果(例如
MethodInfo或FieldInfo),而不是在更新循环中查询它们。
6. 避免泄漏的托管 Shell
每个 UnityEngine.Object(例如 MonoBehaviour、Texture 或 GameObject)都有一个 C#“托管 Shell”封装容器,用于与原生 C++ 引擎通信。
- 问题:如果托管 Shell 由静态引用、 持久性事件订阅或未清理的闭包保留在内存中,则 GC 无法回收 内存。即使原生对象被销毁,托管封装容器也会保留,从而导致“幽灵”内存泄漏,使托管堆膨胀。
- 解决方案:始终实现稳健的清理模式。销毁对象或转换场景时,请使用
-=运算符显式取消订阅事件,并将对UnityEngine.Object类型的静态引用设为 null。此清理操作可确保 GC 在原生引擎释放其句柄后成功收集封装容器。
7. 为 Addressables 使用二进制目录
在 Unity 2022 及更早版本中,Addressables 默认使用 JSON 目录,而不是二进制目录(这是 Unity 2023 及更高版本中的默认设置)。如果您的项目使用 JSON 目录,则在启动时加载大型目录会将整个文件读取到单个大型 C# 字符串中,并在 JSON 解析和 Base64 解码期间分配数千个托管字符串和临时 byte[] 数组。这会导致 GC 分配峰值大幅增加,从而永久扩展托管堆,因此请切换到二进制目录以直接流式传输紧凑数据。