尽管现代移动设备的物理 RAM 容量在不断增加,但由于高分辨率素材资源和复杂的渲染流水线,游戏内存消耗量正以更快的速度增长。
内存用量过高导致的关键问题
- 低内存终止守护程序 (LMK) 终止:操作系统强制关闭后台应用,甚至前台应用,以解决系统范围内的内存不足问题。
- 丢帧和卡顿 (jank):托管堆中频繁的垃圾回收 (GC) 峰值或操作系统级内存交换会造成处理瓶颈。
- 散热限制和耗电过快:持续的内存分配、取消分配和内存页提交会产生大量的 CPU 开销,导致发热量增加和电池电量消耗更快。
Android 内存管理更新
- Android 17:引入
MemoryLimiter:Android 17 引入了MemoryLimiter,该功能可主动监控应用 内存消耗量是否超出设备特定的阈值。如果应用超出其内存限制,系统会立即终止该应用。由于此机制比传统的 LMK 更严格,因此管理峰值内存用量现在比以往任何时候都更加重要。
减少 Unity 中的内存用量
在 Unity 中,一旦引擎扩展其内部内存池(原生块分配器和托管堆)以适应较高的峰值负载,它就会保留这些内存页面,而不是立即将其返回给操作系统。因此,即使在卸载大型资源后,基准常驻内存仍会保持较高水平,从而使应用极易受到 Android OS 进程终止(例如 LMK 或 MemoryLimiter)的影响。
为防止操作系统级强制执行,必须通过以下三个关键支柱来实现内存优化:
- 类别 A:减少峰值内存用量
- 类别 B:消除不必要的资源和系统占用空间
- 类别 C:消除不必要的 GC 分配
类别 A:减少内存用量峰值
Unity 的分配器会保留已释放的内存页以供重用,而不是立即将其返回给操作系统,因此基准内存往往反映的是达到的最高峰值,而不是当前用量。因此,与其事后清理,不如从一开始就防止出现峰值。
1. 避免 AssetBundle 过大
由于 Unity 的 Bundle Loading 机制,巨大的 AssetBundle 会导致严重的内存膨胀和 OOM 崩溃。保持软件包的模块化,以确保高效的资源管理。
主要问题
- 内存开销高:请求单个微小资源会强制 Unity 将整个资源包文件(包括标头、元数据和流式缓冲区)加载到 RAM 中。
- 卸载陷阱:如果某个 bundle 中的任何资源正在使用中,则整个 bundle 都无法卸载,从而导致未使用的数据滞留在 RAM 中。
最佳实践
- 保持软件包的模块化:按场景或生命周期对素材资源进行逻辑分组。
- 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(背景音乐) | 流式 | 以小缓冲区的形式从磁盘串流音频,以消除内存峰值。 |
| 长 SFX | 内存压缩 | 保持较低的 RAM 开销,并在播放期间实时解压缩音频。 |
| 短促而频繁的音效 | 加载时解压缩 | 在加载时将音频解压缩到 RAM 中,以避免在播放期间产生运行时 CPU 开销。 |
4. 实现对象池和释放策略
在场景转换期间,对象池中剩余的未释放实例会无限期地保留预留内存,从而导致基准内存占用空间不必要地增大。
- 操作:在场景转换或低活动期间,定期清除或修剪未使用的池化对象,使 Unity 能够返回或重复使用该预留空间以进行其他分配。
类别 B:消除不必要的内存用量
通过消除冗余的图形资源并直接渲染目标缓冲区,可以降低基准内存占用空间。
1. 优化渲染纹理和相机深度
- 移除深度/模板缓冲区:对于仅需要颜色数据的渲染纹理,请将深度模板格式设置为无。
- 停用界面相机深度纹理:对于不需要深度数据的界面相机,请在 URP 相机设置中停用深度纹理生成,以消除
CopyDepthPass 和关联的 GPU 纹理内存。
2. 优化纹理和网格
| 类别 | 优化指南 |
|---|---|
| 纹理压缩 | 始终应用目标平台压缩格式(例如,Android 的 ASTC)。 |
| 已启用读/写 | 除非必要,否则请保持停用状态。启用此设置会在 CPU 和 GPU RAM 之间复制纹理内存。 |
| Mipmap | 为固定在恒定相机距离的界面纹理或对象停用 Mipmap,可节省约 33% 的纹理内存。 |
| 网格复杂性 | 减少不必要的多边形数量和顶点流,以减少 GPU 和原生内存占用空间。 |
3. 剥离着色器变体并优化内存
超级着色器(例如 URP Lit Shader)使用 #multi_compile 和 shader_feature 关键字封装了许多功能。如果不进行优化,组合爆炸会产生数万个独特的着色器变体,导致 build 大小膨胀、原生内存消耗巨大,并在游戏过程中出现 GPU 驱动程序编译问题。
A. 着色器变体内存开销的机制
- 组合爆炸:随着每个关键字组的添加,可能的变体总数呈指数级增长。
- 块分配架构:Unity 将编译后的二进制变体分组为称为块的压缩内存块(默认大小:4 MB)。
- 原生内存膨胀:当运行时代码请求块中的单个变体时,整个 4 MB 块都会解压缩到 RAM 中。如果未优化,这些块中打包的数千个未使用的变体将永久占用本地内存。
B. Unity 内置的多阶段剥离流水线:Unity 会在构建时根据目标平台图形设置和未使用的引擎功能(例如雾效、光照贴图和 XR 设置)自动剥离不必要的变体。此外,如果 shader_feature 变体的关键字未被项目中的任何素材资源积极使用,系统会自动将其过滤掉,而无论 #multi_compile 变体的使用情况如何,系统都会强制将其纳入。
C. 自定义自动化剥离架构 (IPreprocessShaders):由于静态分析无法检测到使用运行时 C# 脚本 (Material.EnableKeyword) 动态修改的关键字,因此标准剥离通常不够。为确保仅包含实际使用的变体,您可以在质量保证测试套件期间使用 Player.log(在编辑器设置中启用记录着色器编译)或 Profiler 跟踪记录(Shader.CreateGPUProgram 标记)收集变体。然后,在编辑器脚本中实现 IPreprocessShaders.OnProcessShader,以过滤掉在运行时从未执行过的变体,仅将必要的变体保留在 build 中。
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 访问器而导致的装箱和枚举器堆分配。
// 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>并将其传递给 Dictionary 构造函数。
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 可以成功收集封装容器。