解决 Unity 游戏中的 LMK 问题是一个系统化的流程:

获取内存快照
使用 Unity Profiler 获取 Unity 管理的内存快照。 图 2 显示了 Unity 用于处理游戏内存的内存管理层。

受管理的内存
Unity 的内存管理实现了一个受控内存层,该层使用受管堆和垃圾收集器来自动分配和分配内存。托管内存系统是基于 Mono 或 IL2CPP 的 C# 脚本环境。受管内存系统的优势在于,它利用垃圾回收器自动释放内存分配。
C# 非托管内存
非托管 C# 内存层可提供对原生内存层的访问权限,从而在使用 C# 代码时能够精确控制内存分配。可以通过 Unity.Collections 命名空间以及 UnsafeUtility.Malloc 和 UnsafeUtility.Free 等函数访问此内存管理层。
原生内存
Unity 的内部 C/C++ 核心使用原生内存系统来管理场景、资源、图形 API、驱动程序、子系统和插件缓冲区。虽然直接访问受到限制,但您可以使用 Unity 的 C# API 安全地处理数据,并受益于高效的本地代码。原生内存很少需要直接交互,但您可以使用分析器监控原生内存对性能的影响,并调整设置以优化性能。
如图 3 所示,C# 和原生代码之间不共享内存。C# 所需的数据会在每次需要时分配到受管内存空间中。
为了让受管游戏的代码 (C#) 能够访问引擎的原生内存数据,例如,对 GameObject.transform 的调用会进行原生调用,以访问原生区域中的内存数据,然后使用 Bindings 将值返回给 C#。绑定可确保每个平台都具有适当的调用约定,并处理将受管类型自动封送为相应原生类型的过程。
这种情况仅在首次发生,因为用于访问 transform 属性的受管理的 shell 保留在原生代码中。缓存 transform 属性可以减少托管代码和原生代码之间的来回调用次数,但缓存的实用性取决于该属性的使用频率。另请注意,当您访问这些 API 时,Unity 不会将部分原生内存复制到托管内存中。

如需了解详情,请参阅 Unity 中的内存简介。
此外,建立内存预算对于确保游戏顺畅运行至关重要,而实现内存消耗分析或报告系统可确保每个新版本都不会超出内存预算。将 Play 模式测试与持续集成 (CI) 相集成,以验证游戏特定区域的内存消耗情况,这是另一种可帮助您获得更深入洞见的策略。
管理资产
这是内存消耗量中最具影响力和可操作性的部分。尽早进行分析。
Android 游戏的内存用量可能会因游戏类型、资源数量和类型以及内存优化策略而有很大差异。不过,内存使用量的常见贡献者通常包括纹理、网格、音频文件、着色器、动画和脚本。
检测重复的素材资源
第一步是使用内存分析器、build 报告工具或 Project Auditor 检测配置不当的资源和重复的资源。
纹理
分析游戏支持的设备,并确定正确的纹理格式。 您可以使用 Play Asset Delivery、Addressable 或通过 AssetBundle 以更手动的方式拆分高端设备和低端设备的纹理包。
请遵循优化移动游戏性能和优化 Unity 纹理导入设置讨论帖子中提供的最广为人知的建议。然后尝试以下解决方案:
使用 ASTC 格式压缩纹理以减少内存占用空间,并尝试使用更高的块速率(例如 8x8)。
如果必须使用 ETC2,请将纹理打包到图集中。将多个纹理放置到单个纹理中可确保其为 2 的幂 (POT),从而减少绘制调用次数并加快渲染速度。
优化 RenderTarget 纹理格式和大小。避免使用不必要的高分辨率纹理。在移动设备上使用较小的纹理可节省内存。
使用纹理通道打包来节省纹理内存。
网格和模型
首先,检查基本设置(第 27 页),然后验证以下网格导入设置:
- 合并冗余的较小网格。
- 减少场景中对象的顶点数(例如静态对象或远处的对象)。
- 为高几何体资源生成细节层次 (LOD) 组。
材质和着色器
- 在构建过程中以编程方式剥离未使用的着色器变体。
- 将常用的着色器变体整合到超级着色器中,以避免着色器重复。
- 启用动态着色器加载,以解决 VRAM/RAM 中预加载着色器占用大量内存的问题。不过,如果着色器编译导致帧抖动,请注意。
- 使用动态着色器加载来防止加载所有变体。 如需了解详情,请参阅博文:改进了着色器构建时间和内存使用情况。
- 通过利用
MaterialPropertyBlocks
正确使用材质实例化。
音频
首先检查基本设置(第 41 页),然后验证以下网格导入设置:
- 使用 FMOD 或 Wwise 等第三方音频引擎时,移除未使用的或冗余的
AudioClip
引用。 - 预加载音频数据。针对在运行时或场景启动期间不需要立即使用的片段停用预加载。这有助于减少场景初始化期间的内存开销。
动画
- 调整 Unity 的动画压缩设置,以最大限度地减少关键帧数量并消除冗余数据。
- 关键帧缩减:自动移除不必要的关键帧
- 四元数压缩:压缩旋转数据以减少内存用量
您可以在动画导入设置中的骨架或动画标签页下调整压缩设置。
重复使用动画片段,而不是为不同对象复制动画片段。
使用 Animator Override Controller 可重复使用 Animator Controller 并替换不同角色的特定动画片段。
烘焙基于物理特性的动画:如果您的动画是由物理特性驱动的或程序化的,请将其烘焙到动画片段中,以避免运行时计算。
优化骨架绑定:在绑定中使用较少的骨骼,以降低复杂性和内存消耗。
- 避免为小型或静态对象添加过多的骨骼。
- 如果某些骨骼未进行动画处理或不需要,请将其从骨架中移除。
缩短动画片段时长。
- 剪裁动画片段,使其仅包含必要的帧。避免存储未使用的动画或过长的动画。
- 使用循环动画,而不是为重复动作创建长视频片段。
如果不需要,请避免使用 Animator。对于简单的视觉特效,请使用补间动画库或在脚本中实现视觉效果。动画师系统可能会占用大量资源,尤其是在低端移动设备上。
在处理大量动画时,请使用 Job System 来实现动画效果,因为该系统经过了全面重新设计,可更高效地利用内存。
场景
加载新场景时,它们会以依赖项的形式引入素材资源。不过,如果没有适当的资产生命周期管理,这些依赖项就不会受到引用计数器的监控。因此,即使在卸载未使用的场景后,资源也可能仍保留在内存中,从而导致内存碎片。
- 使用 Unity 的对象池来重复使用用于循环游戏元素的 GameObject 实例,因为对象池使用堆栈来保存一组可重复使用的对象实例,并且不是线程安全的。最大限度减少
Instantiate
和Destroy
可同时提升 CPU 性能和内存稳定性。 - 正在卸载资源:
- 在不太关键的时刻(例如启动画面或加载屏幕)有策略地卸载资源。
- 频繁使用
Resources.UnloadUnusedAssets
会因大型内部依赖项监控操作而导致 CPU 处理出现峰值。 - 检查 GC.MarkDependencies 配置文件标记中是否存在较大的 CPU 峰值。
移除或降低其执行频率,并使用 Resources.UnloadAsset 手动卸载特定资源,而不是依赖于包罗万象的
Resources.UnloadUnusedAssets()
。
- 重构场景,而不是不断使用 Resources.UnloadUnusedAssets。
- 为
Addressables
调用Resources.UnloadUnusedAssets()
可能会意外卸载动态加载的软件包。请仔细管理动态加载的资源的生命周期。
其他
由场景过渡引起的内存碎片 - 当调用方法
Resources.UnloadUnusedAssets()
时,Unity 会执行以下操作:- 释放不再使用的素材资源的内存
- 运行类似于垃圾收集器的操作,以检查受管对象堆和原生对象堆中是否有未使用的资源,并将其卸载
- 清理纹理、网格和资源内存,前提是没有有效的参考文件
AssetBundle
或Addressable
- 在此方面做出更改非常复杂,需要团队共同努力才能实施相关策略。 不过,一旦掌握了这些策略,它们就能显著改善内存使用情况、减小下载大小并降低云费用。如需详细了解如何使用Addressables
在 Unity 中管理资源,请参阅。集中式共享依赖项 &mdash: 将着色器、纹理和字体等共享依赖项系统地分组到专用软件包或
Addressable
组中。这样可以减少重复,并确保高效卸载不必要的资源。使用
Addressables
进行依赖项跟踪 - Addressables 可简化加载和卸载,并自动卸载不再引用的依赖项。根据游戏的具体情况,过渡到Addressables
以进行内容管理和依赖项解析可能是一个可行的解决方案。使用“分析”工具分析依赖项链,以确定不必要的重复项或依赖项。 或者,如果您使用的是 AssetBundle,请参阅 Unity 数据工具。TypeTrees
- 如果游戏的Addressables
和AssetBundles
是使用与播放器相同的 Unity 版本构建和部署的,并且不需要与其他播放器 build 向后兼容,请考虑停用写入TypeTree
,这应该会减小 bundle 大小和序列化文件对象内存占用。在本地 Addressables 软件包设置 ContentBuildFlags 中修改 build 流程,以 DisableWriteTypeTree。
编写对垃圾收集器友好的代码
Unity 利用垃圾回收 (GC) 通过自动识别和释放未使用的内存来管理内存。虽然 GC 至关重要,但如果处理不当,可能会导致性能问题(例如帧速率飙升),因为此过程可能会暂时暂停游戏,从而导致性能卡顿和用户体验欠佳。
如需了解有关减少受管堆分配频率的实用技巧,请参阅 Unity 手册;如需查看示例,请参阅 UnityPerformanceTuningBible 第 271 页。
减少垃圾收集器分配:
- 避免使用会分配堆内存的 LINQ、lambda 和闭包。
- 使用
StringBuilder
代替字符串串联来处理可变字符串。 - 通过调用
COLLECTIONS.Clear()
重用集合,而不是重新实例化集合。
如需了解详情,请参阅电子书 Ultimate Guide to Profiling Unity games。
管理界面画布更新:
- 对界面元素进行动态更改 - 当更新文本、图片或
RectTransform
属性等界面元素时(例如,更改文本内容、调整元素大小或为位置添加动画效果),引擎可能会为临时对象分配内存。 - 字符串分配 - 文本等界面元素通常需要更新字符串,因为在大多数编程语言中,字符串都是不可变的。
- 脏画布 - 当画布上的内容发生变化时(例如调整大小、启用和停用元素或修改布局属性),整个画布或其中的一部分可能会被标记为“脏”并重新构建。这会触发临时数据结构(例如网格数据、顶点缓冲区或布局计算)的创建,从而增加垃圾生成量。
- 复杂或频繁的更新 - 如果画布包含大量元素或频繁更新(例如每帧更新一次),这些重建可能会导致严重的内存抖动。
- 对界面元素进行动态更改 - 当更新文本、图片或
启用增量 GC,通过在多个帧中分散分配清理操作来减少大型收集峰值。进行性能分析,以验证此选项是否能提升游戏的性能并减少内存占用量。
如果您的游戏需要采用受控方法,请将垃圾收集模式设置为手动。然后,在关卡发生变化时或在没有进行有效游戏的其他时刻,调用垃圾收集。
针对游戏状态转换(例如,关卡切换)调用手动垃圾回收 GC.Collect()。
从简单的代码实践开始优化数组,并在必要时使用原生数组或其他原生容器来处理大型数组。
使用 Unity 内存分析器等工具监控受管对象,以跟踪在销毁后仍存在的非受管对象引用。
使用 Profiler Marker 提交到性能报告工具,以实现自动化方法。
避免内存泄漏和碎片化
内存泄漏
在 C# 代码中,当对 Unity 对象的引用在对象被销毁后仍然存在时,称为“受管 Shell”的受管封装容器对象会保留在内存中。当场景卸载时,或者当附加内存的 GameObject 或其任何父对象通过 Destroy()
方法销毁时,与引用关联的本地内存会被释放。不过,如果未清除对场景或 GameObject 的其他引用,则受管理的内存可能会作为泄漏的 Shell 对象而保留。如需详细了解受管理的 Shell 对象,请参阅受管理的 Shell 对象手册。
此外,事件订阅、lambda 和闭包、字符串串联以及对池化对象管理不当也可能会导致内存泄漏:
- 如需开始操作,请参阅查找内存泄漏,了解如何正确比较 Unity 内存快照。
- 检查是否存在事件订阅和内存泄漏。如果对象订阅了事件(例如,通过委托或 UnityEvent),但在销毁之前未正确取消订阅,则事件管理器或发布者可能会保留对这些对象的引用。这会阻止对这些对象进行垃圾回收,从而导致内存泄漏。
- 监控在对象销毁时未取消注册的全局或单例类事件。例如,在对象析构函数中退订或取消挂钩委托。
- 确保销毁池化对象完全无效化对文本网格组件、纹理和父 GameObject 的引用。
- 请注意,在比较 Unity 内存分析器快照时,如果发现内存消耗存在差异但没有明确的原因,则该差异可能是由显卡驱动程序或操作系统本身造成的。
内存碎片化
当许多小分配以随机顺序释放时,就会发生内存碎片化。堆分配是按顺序进行的,这意味着当上一个内存块空间不足时,系统会创建新的内存块。因此,新对象不会填充旧块的空白区域,从而导致碎片化。此外,大型临时分配可能会在游戏会话期间导致永久性碎片化。
当在长期分配附近进行短期大型分配时,此问题尤为严重。
根据分配的生命周期对分配进行分组;理想情况下,长生命周期的分配应在应用生命周期的早期一起进行。
观察员和活动经理
- 除了 (内存泄漏)77 部分中提到的问题之外,随着时间的推移,内存泄漏还会导致碎片化,因为未使用的内存会分配给不再使用的对象。
- 确保销毁池化对象完全使对文本网格组件、纹理和父
GameObjects
的引用失效。 - 事件管理器通常会创建并存储列表或字典来管理事件订阅。如果这些对象在运行时动态增大和缩小,则由于频繁的分配和取消分配,它们可能会导致内存碎片化。
代码
- 协程有时会分配内存,但通过缓存 IEnumerator 的返回语句(而不是每次都声明新的返回语句),可以轻松避免这种情况。
- 持续监控池化对象的生命周期状态,以避免保留
UnityEngine.Object
虚引用。
素材资源
- 使用动态回退系统来打造由文本驱动的游戏体验,以避免预加载所有字体来支持多语言。
- 按类型和预期生命周期将资源(例如纹理和粒子)整理在一起。
- 压缩具有闲置生命周期属性的资源,例如冗余界面图片和静态网格。
基于生命周期的分配
- 在应用生命周期开始时分配长期存在的资源,以确保分配紧凑。
- 对于内存密集型或临时数据结构(例如物理集群),请使用 NativeCollections 或自定义分配器。
代码相关和可执行文件内存操作
游戏可执行文件和插件也会影响内存用量。
IL2CPP 元数据
IL2CPP 在构建时为每种类型(例如类、泛型和委托)生成元数据,然后在运行时将这些元数据用于反射、类型检查和其他运行时特定操作。此元数据存储在内存中,可能会显著增加应用的总内存占用空间。IL2CPP 的元数据缓存对初始化和加载时间有显著贡献。此外,IL2CPP 不会对某些元数据元素(例如泛型或序列化信息)进行重复数据删除,这可能会导致内存使用量过大。项目中的重复或冗余类型使用会加剧此问题。
可通过以下方式减少 IL2CPP 元数据:
- 避免使用反射 API,因为它们可能会显著增加 IL2CPP 元数据分配
- 停用内置软件包
- 实现 Unity 2022 完全泛型共享,这应有助于减少泛型造成的开销。不过,为了进一步减少分配,请减少泛型的使用。
代码剥离
除了减小 build 的大小之外,剥离代码还可以减少内存使用量。针对 IL2CPP 脚本后端进行构建时,受管字节码剥离(默认处于激活状态)会从受管程序集中移除未使用的代码。此流程通过定义根程序集,然后使用静态代码分析来确定这些根程序集使用的其他受管代码。系统会移除所有无法访问的代码。如需详细了解托管代码剥离,请参阅优化实战:使用 Unity 2020 LTS 更好地剥离托管代码博文和托管代码剥离文档。
原生分配器
尝试使用原生内存分配器来微调内存分配器。如果游戏内存不足,请使用较小的内存块,即使这会涉及较慢的分配器。如需了解详情,请参阅动态堆分配器示例。
管理原生插件和 SDK
找到有问题的插件 - 移除每个插件并比较游戏内存快照。这需要使用 Scripting Define Symbols 停用大量代码功能,并使用接口重构高度耦合的类。请参阅利用游戏编程模式提升代码水平,了解如何轻松停用外部依赖项,同时确保游戏可正常运行。
与插件或 SDK 作者联系 - 大部分插件都不是开源的。
重现插件内存使用情况 - 您可以编写一个简单的插件(使用此 Unity 插件作为参考)来执行内存分配。使用 Android Studio 检查内存快照(因为 Unity 不会跟踪这些分配),或者在同一项目中调用
MemoryInfo
类和Runtime.totalMemory()
方法。
Unity 插件会分配 Java 内存和原生内存;以下是具体操作方法:
Java
byte[] largeObject = new byte[1024 * 1024 * megaBytes];
list.add(largeObject);
原生代码
char* buffer = new char[megabytes * 1024 * 1024];
// Random data to fill the buffer
for (int i = 1; i < megabytes * 1024 * 1024; ++i) {
buffer[i] = 'A' + (i % 26); // Fill with letters A-Z
}