Skip to content

内存优化

Unity Memory Profiler 能看什么?

面试高分回答

Unity Memory Profiler 主要用来看某一刻内存里到底有什么、谁占得多、谁没有释放、是谁还引用着它。它更像“内存快照分析工具”,不是专门看某一帧 CPU 卡顿的工具。

unity-memory-profiler-what-to-see

它能看什么?

可以看总体内存: 比如 Managed MemoryNative Memory、图形资源、音频资源、纹理、网格等大概占了多少。

可以看托管堆: 也就是 C# 对象,比如数组、字符串、List、Dictionary、自定义类对象。托管堆一直涨不下来,就要怀疑 C# 对象被引用住了。

可以看 Unity 原生对象: 比如 Texture2DMeshAudioClipMaterialAnimationClipGameObjectComponent 等。

可以看资源占用: 最常见就是查大纹理、大 Mesh、大 AudioClip、重复加载的贴图、重复材质实例。

可以看引用关系: 比如一个资源为什么没有释放,是不是被单例、静态字段、事件、对象池、ScriptableObject、Addressables handle 引用着。

可以对比快照: 比如进入场景前拍一张,进入场景后拍一张,退出场景后再拍一张。对比后就能看到哪些对象增加了,哪些对象没有释放。

它适合解决什么问题?

适合查内存泄漏。 比如切场景后,旧场景的怪物、UI、贴图、音频还在内存里。

适合查资源过大。 比如一张 UI 图没压缩,或者角色贴图分辨率太高。

适合查重复加载。 比如同一个图标被多个 AssetBundle 重复打包,导致内存里出现多份。

适合查引用链。 比如对象明明 Destroy 了,但还有 C# 单例列表保存着它,导致托管对象或资源没有被释放。

它和普通 Profiler 的区别

普通 Profiler 更适合看运行时趋势:这一帧有没有 GC Alloc,什么时候触发 GC.Collect,内存曲线有没有上涨。

Memory Profiler 更适合看快照细节:当前内存里具体有哪些对象、每个对象占多少、对象之间怎么引用、两张快照差异是什么。

面试里可以这样说排查流程

我会先在稳定状态拍第一张快照。 然后进入目标玩法,比如打开背包、切场景、打一场战斗,再拍第二张快照。 接着退出玩法或释放资源后拍第三张快照。 最后对比第二张和第三张,看应该释放的对象有没有下降。 如果没下降,就点进去看引用链,找是不是静态缓存、事件订阅、对象池、Addressables 引用计数、DontDestroyOnLoad 对象把它引用住了。

一句话总结

TIP

Memory Profiler 主要看内存快照、对象占用、资源明细、引用关系和快照差异。查“为什么内存涨了不降”“资源为什么没释放”“谁占内存最多”时,它非常有用。

Native Memory 和 Managed Memory 区别是什么?

面试高分回答

Managed Memory 是 C# 托管内存,主要放 new 出来的 C# 对象,比如类实例、数组、字符串、ListDictionary。它由 Mono 或 IL2CPP 的 GC 管理,没有引用后等待 GC 回收。

Native Memory 是 Unity 引擎和系统侧内存,主要放纹理、网格、音频、材质、动画、RenderTexture、物理数据、引擎内部对象等。它通常不是 C# GC 直接管理的,需要通过 DestroyUnloadUnusedAssetsAddressables.ReleaseDispose 等方式配合释放。

unity-native-managed-memory

最容易混淆的点

Unity 里的很多对象有“两层”:

C# 层有一个 managed wrapper,也就是托管包装对象。 Unity 引擎层有一个 native object,也就是真正的原生资源。

比如一个 Texture2D 变量,在 C# 里可能只是一个很小的引用和包装对象,但真正占大内存的是背后的纹理像素数据,它通常在 Native 或 Graphics 内存里。

所以:C# GC 回收 wrapper,不等于 Unity 原生资源一定立刻释放。

Managed Memory 常见问题

C# 对象一直被引用,GC 回收不了。 比如静态 List、单例缓存、事件没有取消订阅、闭包捕获、对象池只进不出。

每帧产生临时对象,导致 GC Alloc。 比如字符串拼接、LINQ、装箱、临时 List、闭包、频繁 new。

Native Memory 常见问题

资源加载后没有释放。 比如 Texture、Mesh、AudioClip、Material、RenderTexture 一直留在内存。

Addressables 或 AssetBundle 引用计数没释放。 资源管理系统认为还有人在用,所以不会卸载。

动态创建的材质、RenderTexture、ComputeBuffer、NativeArray 没有释放。 这类东西经常需要 DestroyReleaseDispose

简单例子

c
using Unity.Collections; // 引入 NativeArray 所在命名空间
using UnityEngine; // 引入 Unity 常用类型

public class NativeMemoryExample : MonoBehaviour // 定义 Native 内存示例组件
{ // 类开始
    private NativeArray<int> numbers; // 定义一个 NativeArray,它使用 Native 侧内存

    private void Start() // 游戏对象启动时调用
    { // 方法开始
        numbers = new NativeArray<int>(1000, Allocator.Persistent); // 分配一块需要手动释放的 Native 内存
    } // 方法结束

    private void OnDestroy() // 对象销毁时调用
    { // 方法开始
        if (numbers.IsCreated) // 判断 NativeArray 是否已经创建
        { // if 开始
            numbers.Dispose(); // 手动释放 NativeArray 占用的 Native 内存
        } // if 结束
    } // 方法结束
} // 类结束

面试排查怎么说

IMPORTANT

如果 Managed Memory 一直涨,我会先查 C# 引用链:静态变量、事件订阅、集合缓存、对象池、闭包。

如果 Native Memory 一直涨,我会查资源生命周期:纹理、网格、音频、材质实例、RenderTexture、Addressables handle、AssetBundle 是否释放。

如果切场景后内存没降,我会用 Memory Profiler 拍快照对比,看旧场景对象或资源为什么还被引用。

一句话总结

NOTE

Managed Memory 看 C# 对象和 GC,Native Memory 看 Unity 引擎资源和显式释放。Unity 对象经常 managed 和 native 两边都有,所以排查内存时不能只看 C# GC。

Texture 内存怎么算?

面试高分回答

Texture 内存主要看三个东西:分辨率、纹理格式、是否开启 MipMap

最基础公式是:

内存 = 宽 × 高 × 每像素字节数

如果开启 MipMap,完整 Mip 链大约会额外增加 33% 内存。

unity-texture-memory-calc

举个最常见的例子

一张 1024 × 1024RGBA32 贴图:

RGBA32 是每像素 4 字节。

所以:

1024 × 1024 × 4 = 4,194,304 bytes

约等于 4 MB

如果开启 MipMap:

4 MB × 1.33 ≈ 5.33 MB

常见格式大概怎么算

`RGBA32`:每像素 4 字节。
`1024 × 1024` 约 `4 MB`。

`RGB24`:每像素 3 字节。
`1024 × 1024` 约 `3 MB`。

`Alpha8`:每像素 1 字节。
`1024 × 1024` 约 `1 MB`。

`BC1 / DXT1 / ETC2 RGB`:约 4 bpp,也就是每像素 0.5 字节。
`1024 × 1024` 约 `0.5 MB`。

`BC3 / DXT5 / ETC2 RGBA`:约 8 bpp,也就是每像素 1 字节。
`1024 × 1024` 约 `1 MB`。

`ASTC` 要看块大小。
比如 `ASTC 4x4` 约 1 字节每像素,质量好但占用高。
`ASTC 6x6` 约 0.44 字节每像素,是移动端常见折中。
`ASTC 8x8` 约 0.25 字节每像素,更省内存但质量更低。

还要注意这些倍率

CubeMap 有 6 个面,所以大约是普通 2D 纹理的 6 倍。

Texture Array 要乘数组层数。

RenderTexture 还要看颜色格式、深度缓冲、MSAA 倍数。比如 4x MSAA 会明显增加显存占用。

一个简单计算工具

c
using UnityEngine; // 引入 Unity 常用类型

public static class TextureMemoryCalculator // 定义纹理内存计算工具类
{ // 类开始
    public static float CalculateUncompressedMB(int width, int height, int bytesPerPixel, bool mipMap) // 计算未压缩纹理内存 MB
    { // 方法开始
        int pixelCount = width * height; // 计算像素总数
        int byteCount = pixelCount * bytesPerPixel; // 计算基础字节数
        float mb = byteCount / 1024f / 1024f; // 把字节转换成 MB
        if (mipMap) mb *= 1.333f; // 如果开启 MipMap,就大约增加三分之一内存
        return mb; // 返回最终内存大小
    } // 方法结束
} // 类结束

一句话总结

WARNING

Texture 内存不是看图片文件大小,而是看它加载到内存或显存后的格式。面试里可以说:纹理内存 = 分辨率 × 格式;MipMap 约多 33%;压缩格式按 bpp 算;CubeMap、TextureArray、RenderTexture 还要额外乘面数、层数或 MSAA 倍数。

Mesh 内存怎么算?

面试高分回答

Mesh 内存主要看:顶点缓冲 Vertex Buffer、索引缓冲 Index Buffer、额外数据,以及是否保留 CPU 可读副本

最核心公式是:

Mesh 内存 ≈ 顶点数 × 单顶点大小 + 三角形数 × 3 × 索引大小 + 额外数据

unity-mesh-memory-calc

顶点缓冲怎么算

每个顶点不只是一个位置,它可能包含很多属性:

PositionVector3,通常 12 字节。 NormalVector3,通常 12 字节。 TangentVector4,通常 16 字节。 UV0Vector2,通常 8 字节。 Color32:通常 4 字节。 骨骼权重、骨骼索引:角色蒙皮 Mesh 会有额外数据。

比如一个顶点包含:

Position + Normal + Tangent + UV

那单顶点大小大约是:

12 + 12 + 16 + 8 = 48 字节

如果有 10000 个顶点:

10000 × 48 = 480000 bytes

大约 0.46 MB

索引缓冲怎么算

Mesh 的三角形通常用索引表示。每个三角形 3 个索引。

如果是 16-bit index,每个索引 2 字节。 如果是 32-bit index,每个索引 4 字节。

比如有 20000 个三角形,使用 16-bit index:

20000 × 3 × 2 = 120000 bytes

大约 0.11 MB

如果换成 32-bit index:

20000 × 3 × 4 = 240000 bytes

大约 0.23 MB

完整例子

一个 Mesh 有:

10000 个顶点 20000 个三角形 每个顶点有 Position + Normal + Tangent + UV 索引用 16-bit

大概内存:

c
顶点缓冲 = 10000 × 48 = 480000 bytes
索引缓冲 = 20000 × 3 × 2 = 120000 bytes
合计 ≈ 600000 bytes ≈ 0.57 MB

这还没算 BlendShape、骨骼权重、CPU 可读副本等额外开销。

哪些东西会让 Mesh 更占内存?

顶点数多。 高模、复杂场景、没有做 LOD 的模型会明显增加内存。

顶点属性多。 法线、切线、多套 UV、顶点色、骨骼权重都会增加单顶点大小。

使用 32-bit index。 当顶点数超过 65535 时,通常需要 32-bit index,索引缓冲会变大。

开启 Read/Write Enabled。 Unity 可能会保留一份 CPU 侧 Mesh 数据,同时 GPU 侧也有一份,内存可能明显变高。运行时不需要读写 Mesh 的模型,通常应关闭。

BlendShape 多。 角色表情、捏脸、形变动画会保存额外的顶点 delta 数据,BlendShape 越多越占内存。

简单计算工具

c
using UnityEngine; // 引入 Unity 常用类型

public static class MeshMemoryCalculator // 定义 Mesh 内存计算工具类
{ // 类开始
    public static float CalculateMeshMB(int vertexCount, int vertexStride, int triangleCount, int indexSize) // 计算 Mesh 近似内存 MB
    { // 方法开始
        int vertexBytes = vertexCount * vertexStride; // 计算顶点缓冲字节数
        int indexBytes = triangleCount * 3 * indexSize; // 计算索引缓冲字节数
        int totalBytes = vertexBytes + indexBytes; // 计算总字节数
        float totalMB = totalBytes / 1024f / 1024f; // 把字节转换成 MB
        return totalMB; // 返回近似内存大小
    } // 方法结束
} // 类结束

一句话总结

NOTE

Texture 内存主要看分辨率和格式;Mesh 内存主要看顶点数、顶点属性、三角形索引、BlendShape、骨骼数据和 Read/Write。面试里说清楚 Vertex Buffer + Index Buffer + Extra Data,就很专业。

AudioClip 加载类型对内存有什么影响?

面试高分回答

AudioClip 的加载类型会直接影响内存和播放时性能。Unity 常见有三种:Decompress On LoadCompressed In MemoryStreaming。它们本质是在 内存占用、CPU 解码成本、I/O 成本 之间做取舍。

unity-audioclip-loadtype-memory

Decompress On Load

加载时直接把音频解压成 PCM 数据放进内存。

优点是播放时 CPU 成本低,适合短音效、频繁播放的声音,比如攻击、受击、按钮、脚步声。

缺点是内存占用最大。一个原本压缩后很小的音频,解压后可能变大很多。

Compressed In Memory

压缩数据保存在内存里,播放时再实时解码。

优点是比解压后更省内存。

缺点是播放时需要 CPU 解码。如果同时播放很多这种音频,CPU 压力会增加。

适合中等长度、不太频繁播放的音频,比如语音、提示音、剧情台词。

Streaming

音频不会整段一次性加载进内存,而是播放时边读边播。

优点是内存占用最低。

缺点是有 I/O 成本和流式解码成本,如果磁盘读取或包体加载压力大,可能出现延迟或卡顿。

适合长音频,比如 BGM、环境音乐、长语音。

怎么选?

短音效:Decompress On Load。 因为短,而且播放频繁,宁愿多占一点内存,也要降低播放时 CPU 成本。

中等语音:Compressed In Memory。 内存和 CPU 做折中。

长 BGM:Streaming。 因为整首音乐解压到内存非常浪费,流式播放更合适。

还要注意两个设置

Preload Audio Data:开启后,音频会在资源加载时预加载到内存;关闭后可以按需加载,减少初始内存和加载压力。

Load In Background:尽量后台加载音频,减少主线程卡顿,但不代表没有内存和 I/O 成本。

一句话总结

IMPORTANT

短音效用 Decompress On Load 换低 CPU,长音乐用 Streaming 换低内存,中等长度音频用 Compressed In Memory 做折中。面试里这样回答,会比只说“短音效解压,BGM 流式”更完整。

AnimationClip 内存如何优化?

面试高分回答

AnimationClip 内存主要来自动画曲线和关键帧。骨骼越多、动画属性越多、采样率越高、关键帧越密,Clip 就越占内存。

unity-animationclip-memory-optimization

AnimationClip 为什么会占内存?

动画不是只保存“一个动作名字”,它会保存很多曲线。

比如一个骨骼可能有:

Position 曲线 Rotation 曲线 Scale 曲线

每条曲线里又有很多关键帧。 所以一个角色骨骼多、动作长、采样率高,内存就会上去。

常见优化方式

降低采样率。
很多动作不一定需要 60 FPS 烘焙,普通动作 30 FPS 甚至更低也可能够用。采样率降低,关键帧数量就会减少。

开启动画压缩。
在模型导入设置里使用动画压缩,例如 Keyframe Reduction 或 Optimal,并调整 Position Error、Rotation Error、Scale Error。误差越大,压缩越狠,但动作可能失真。

删除无用曲线。
很多动画里会带无意义的 Scale 曲线,比如全程都是 `1,1,1`。这种曲线保留了也浪费内存。

减少骨骼数量。
骨骼越多,曲线越多。远处角色、LOD 角色、怪物小兵可以用更简化的骨架。

减少 BlendShape 动画。
表情、捏脸、形变动画可能产生大量 BlendShape 曲线,内存会明显增加。只保留真正需要的形变。

拆分和按需加载。
剧情长动画、Boss 专属动画、一次性演出动画,不要一直常驻内存,可以用 Addressables 按需加载,用完释放。

避免重复 Clip。
多个角色如果动作一致,可以共用通用动作,减少重复打包和重复加载。

面试里可以这样说

NOTE

我会先用 Memory Profiler 看 AnimationClip 的内存占用,找最大的 Clip。然后检查它的骨骼数量、动画时长、采样率、是否有无用 Scale 曲线、BlendShape 曲线是否过多。优化时会从导入压缩、降低采样、清理无用曲线、拆分按需加载几个方向处理,最后再对比优化前后的快照。

一句话总结

IMPORTANT

AnimationClip 优化就是:少骨骼、少曲线、少关键帧、合理压缩、按需加载。不能只看动画时长,更要看它保存了多少属性曲线和关键帧。

AssetBundle 常驻会占内存吗?

面试高分回答

会占内存。AssetBundle 常驻时,至少会占用 Bundle 本体相关内存,比如 AssetBundle 对象、目录信息、依赖信息、压缩块缓存、文件句柄或内部管理结构。除此之外,从 Bundle 里加载出来的资源,以及实例化出来的对象,还会额外占内存。unity-assetbundle-resident-memory

要分三层理解

第一层是 AssetBundle 本体。 它常驻会占一定 Native Memory,主要是 Bundle 的元数据、目录、压缩块、加载入口等。

第二层是从 Bundle 里 LoadAsset 出来的资源。 比如 TextureMeshAudioClipMaterialPrefab。这些资源加载出来后会占自己的内存,很多时候比 Bundle 本体更大。

第三层是 Instantiate 出来的对象。 Prefab 实例化后,场景里的 GameObjectComponent、Animator 状态、材质实例等也会占内存。

Unload(false) 和 Unload(true)

bundle.Unload(false): 卸载 AssetBundle 本体,让这个 Bundle 不能再继续 LoadAsset。但已经加载出来的 Asset 通常还可以继续存在。

bundle.Unload(true): 卸载 AssetBundle 本体,并尝试卸载从这个 Bundle 加载出来的 Asset。这个更危险,如果场景对象还在用这些资源,可能导致引用失效。

什么时候适合常驻?

适合常驻:公共 Shader、公共材质、通用 UI 图集、常用字体、常用音效、基础配置资源。

不适合常驻:关卡专属资源、剧情资源、活动资源、一次性界面、大型角色资源、大型 BGM。

项目里一般怎么管理?

用引用计数。 资源被使用时计数加一,不再使用时计数减一。计数归零后,先释放 Asset 引用,再考虑卸载对应 Bundle。

用分组策略。 公共 Bundle 可以常驻,场景 Bundle 跟随场景生命周期,临时 Bundle 用完就卸载。

用 Memory Profiler 验证。 切场景或关闭界面后拍快照,看旧资源、旧 Bundle、旧实例是否还在。

一句话总结

WARNING

AssetBundle 常驻一定会占内存,但要区分:Bundle 本体占一份,加载出来的 Asset 占一份,实例化对象还可能再占一份。面试里可以说:我会用引用计数和资源分组控制常驻范围,公共资源常驻,场景和临时资源按生命周期卸载。

如何发现重复资源?

面试高分回答

发现重复资源,要先分清楚三种情况:同一个资源被重复打包、内容一样但 GUID 不同、运行时被重复加载。这三种问题看起来都叫重复资源,但排查手段不完全一样。

unity-find-duplicate-assets

第一种:同一个资源被重复打包

比如多个 AssetBundle 都依赖同一张贴图、同一个材质、同一个 Shader,但这个公共依赖没有被单独抽出来,就可能被多个 Bundle 各带一份。

排查方法:看 Build ReportBuild Layout、Addressables 的 Analyze 工具,检查哪些资源出现在多个 Bundle 或多个 Group 的依赖里。

解决方式:把公共依赖抽到 shared Bundle 或公共 Addressables Group。

第二种:内容一样但 GUID 不同

比如两张图片内容完全一样,但路径不同,.meta 文件不同,GUID 也不同。Unity 会认为这是两个完全不同的资源。

排查方法:对资源做 Hash 扫描,比如 MD5、SHA,找内容完全相同的文件;也可以先按文件名、尺寸、格式、大小筛一遍。

解决方式:保留一份源资源,其他地方统一引用它,删除复制品。

第三种:运行时重复加载

比如资源系统没有统一缓存,同一个资源被多个模块各自 LoadAsset,或者 Addressables handle 没有统一管理和释放,导致内存里长期留着多份资源或实例。

排查方法:用 Memory Profiler 拍快照,看内存里是否有重复 Texture、Mesh、AudioClip、Material,再看引用链是谁持有。

编辑器 Hash 扫描示例

c
using System.Collections.Generic; // 引入 Dictionary 和 List 容器
using System.IO; // 引入文件读取相关类型
using System.Security.Cryptography; // 引入 MD5 哈希计算类型
using UnityEditor; // 引入 Unity 编辑器 API
using UnityEngine; // 引入 Unity 常用类型

public static class DuplicateAssetFinder // 定义重复资源查找工具类
{ // 类开始
    [MenuItem("Tools/Find Duplicate Assets By Hash")] // 在 Unity 菜单栏添加工具入口
    public static void FindDuplicateAssetsByHash() // 定义查找重复资源的方法
    { // 方法开始
        string[] guids = AssetDatabase.FindAssets(""); // 找到项目里的所有资源 GUID
        Dictionary<string, List<string>> hashToPaths = new Dictionary<string, List<string>>(); // 用哈希值映射到资源路径列表
        foreach (string guid in guids) // 遍历所有资源 GUID
        { // foreach 开始
            string path = AssetDatabase.GUIDToAssetPath(guid); // 把 GUID 转成资源路径
            if (Directory.Exists(path)) continue; // 如果是文件夹就跳过
            string fullPath = Path.GetFullPath(path); // 转成完整文件路径
            if (!File.Exists(fullPath)) continue; // 如果文件不存在就跳过
            string hash = CalculateMD5(fullPath); // 计算文件 MD5
            if (!hashToPaths.ContainsKey(hash)) // 如果这个哈希还没有记录过
            { // if 开始
                hashToPaths[hash] = new List<string>(); // 创建一个路径列表
            } // if 结束
            hashToPaths[hash].Add(path); // 把当前资源路径加入对应哈希列表
        } // foreach 结束
        foreach (KeyValuePair<string, List<string>> pair in hashToPaths) // 遍历所有哈希分组
        { // foreach 开始
            if (pair.Value.Count <= 1) continue; // 如果只有一个资源,就不是重复内容
            Debug.Log("发现重复资源 Hash:" + pair.Key); // 输出重复资源的哈希
            foreach (string path in pair.Value) // 遍历这个哈希对应的所有资源路径
            { // foreach 开始
                Debug.Log("重复资源路径:" + path); // 输出重复资源路径
            } // foreach 结束
        } // foreach 结束
    } // 方法结束

    private static string CalculateMD5(string filePath) // 定义计算 MD5 的方法
    { // 方法开始
        using (MD5 md5 = MD5.Create()) // 创建 MD5 对象
        { // using 开始
            using (FileStream stream = File.OpenRead(filePath)) // 打开文件读取流
            { // using 开始
                byte[] hashBytes = md5.ComputeHash(stream); // 计算文件哈希字节数组
                return System.BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant(); // 把哈希字节转换成字符串
            } // using 结束
        } // using 结束
    } // 方法结束
} // 类结束

一句话总结

TIP

发现重复资源要三路并查:构建报告查重复打包,Memory Profiler 查运行时重复常驻,Hash 或 GUID 工具查项目里内容重复。治理上要抽公共依赖、统一资源引用、建立资源规范和构建检查。

如何优化峰值内存?

面试高分回答

峰值内存优化的核心是:减少同一瞬间同时存在的旧资源、新资源、临时缓冲和实例对象。很多项目不是平均内存太高,而是在切场景、加载大资源、解压 AssetBundle、批量 Instantiate 时,瞬间峰值太高导致闪退或系统杀进程。unity-peak-memory-optimization

峰值内存为什么会高?

最常见是切场景时,旧场景资源还没释放,新场景资源已经开始加载,中间还会有解压缓冲、序列化数据、实例化对象,所以内存会短时间叠加。

比如:

旧场景 Texture、Mesh、AudioClip 还在。
新场景 AssetBundle 开始加载。
Bundle 解压产生临时内存。
新资源 LoadAsset 进入内存。
Prefab Instantiate 又创建 GameObject 和组件。

这些东西叠在一起,就形成峰值。

怎么优化?

先释放旧资源,再加载新资源。 切场景前先关闭旧 UI、停止旧特效、清理对象池、释放 Addressables handle 或 AssetBundle 引用,再加载新场景。

避免一次性 LoadAll。 LoadAll 很容易把暂时不用的资源也加载进内存。更好的方式是按需加载,或者分阶段加载。

分批加载和分帧实例化。 资源可以分组加载,Prefab 不要一帧 Instantiate 几百个,可以每帧生成一部分。

关闭不必要的 Read/Write。 Texture、Mesh 如果开启 Read/Write,可能会保留 CPU 侧副本,导致内存变高。

对象池要有上限。 对象池能减少 GC 和 Instantiate,但无限缓存会让内存常驻越来越高。池子应该有容量上限,场景切换时清理不用的池。

控制预加载范围。 预加载是为了避免运行时卡顿,但预加载太多会直接抬高峰值。只预加载即将用到的资源。

用 Memory Profiler 验证。 加载前拍一张,加载中峰值拍一张,加载完成后拍一张,退出场景后再拍一张,看峰值发生在哪个阶段,哪些对象没有下降。

一个切场景流程示例

c
using System.Collections; // 引入 IEnumerator 协程类型
using UnityEngine; // 引入 Unity 常用类型
using UnityEngine.SceneManagement; // 引入场景管理 API

public class SceneLoadFlow : MonoBehaviour // 定义场景加载流程组件
{ // 类开始
    public IEnumerator LoadSceneWithLowerPeak(string sceneName) // 定义降低峰值内存的场景加载协程
    { // 协程开始
        ClearOldPools(); // 清理旧场景对象池,减少常驻对象
        ReleaseOldAssets(); // 释放旧场景资源引用
        yield return Resources.UnloadUnusedAssets(); // 卸载没有引用的资源,降低加载新场景前的内存
        System.GC.Collect(); // 主动触发一次 GC,清理托管对象
        AsyncOperation operation = SceneManager.LoadSceneAsync(sceneName); // 异步加载新场景
        operation.allowSceneActivation = false; // 先不立刻激活场景,方便控制加载流程
        while (operation.progress < 0.9f) // 等待场景资源加载到可激活状态
        { // while 开始
            yield return null; // 等一帧,避免阻塞主线程
        } // while 结束
        yield return PreloadImportantAssetsStepByStep(); // 分批预加载真正必要的资源
        operation.allowSceneActivation = true; // 允许新场景激活
    } // 协程结束

    private void ClearOldPools() // 清理旧对象池
    { // 方法开始
        Debug.Log("清理旧对象池"); // 这里模拟清理对象池逻辑
    } // 方法结束

    private void ReleaseOldAssets() // 释放旧资源引用
    { // 方法开始
        Debug.Log("释放旧资源引用"); // 这里模拟 Addressables.Release 或 AssetBundle.Unload 逻辑
    } // 方法结束

    private IEnumerator PreloadImportantAssetsStepByStep() // 分步预加载关键资源
    { // 协程开始
        Debug.Log("预加载第一批关键资源"); // 预加载第一批资源
        yield return null; // 等一帧,避免同一帧加载过多
        Debug.Log("预加载第二批关键资源"); // 预加载第二批资源
        yield return null; // 再等一帧,继续摊平峰值
    } // 协程结束
} // 类结束

一句话总结

IMPORTANT

峰值内存优化不是只看“总资源大小”,而是看加载过程里有没有重叠:旧资源没释放、新资源已加载、临时缓冲还在、实例也创建了。面试里可以说:我会通过先释放后加载、分批加载、分帧实例化、关闭 Read/Write、控制对象池上限,并用 Memory Profiler 快照验证峰值下降。

如何处理低端机内存崩溃?

面试高分回答

低端机内存崩溃一般不是普通 C# 异常,而是 OOM 或系统直接杀进程。处理思路是:先确认是不是内存问题,再区分是常驻内存太高,还是加载过程峰值太高,然后针对资源规格、加载流程、缓存策略和运行时兜底做优化。unity-low-end-memory-crash-handling

排查流程

先用低端真机复现,抓 Android logcat 或 iOS 崩溃日志,看是否出现 low memory、OOM、系统杀进程。

再用 Memory Profiler 拍快照:进入玩法前、加载中峰值、加载完成、退出玩法后各拍一张。 如果加载中爆,重点优化峰值内存。 如果加载完成后仍然很高,重点优化常驻资源。 如果退出后不下降,重点查引用链和资源释放。

优化方向

低端机使用低配资源档:低分辨率贴图、低模、低音质、低特效数量。

降低渲染内存:降低 RenderTexture 分辨率、关闭高 MSAA、降低阴影和后处理质量。

控制加载峰值:先释放旧资源,再分批加载新资源,避免旧场景和新场景资源长时间重叠。

避免一次性 LoadAll:按需加载,分阶段加载,分帧 Instantiate。

严格释放资源:Addressables 要 Release,AssetBundle 要按引用计数卸载,对象池要有容量上限。

关闭不必要的 Read/Write:Texture、Mesh 不需要 CPU 读写时关闭,避免多一份 CPU 副本。

监听低内存回调:收到系统低内存警告时,清理缓存、降低画质、释放非关键资源。

运行时兜底代码

c
using System.Collections; // 引入 IEnumerator 协程类型
using UnityEngine; // 引入 Unity 常用类型

public class LowMemoryHandler : MonoBehaviour // 定义低内存处理组件
{ // 类开始
    private void OnEnable() // 组件启用时调用
    { // 方法开始
        Application.lowMemory += OnLowMemory; // 监听系统低内存事件
    } // 方法结束

    private void OnDisable() // 组件禁用时调用
    { // 方法开始
        Application.lowMemory -= OnLowMemory; // 取消监听系统低内存事件,避免重复回调
    } // 方法结束

    private void OnLowMemory() // 系统发出低内存警告时调用
    { // 方法开始
        StartCoroutine(ReleaseMemoryStepByStep()); // 启动分步释放内存流程,避免一帧做太多事
    } // 方法结束

    private IEnumerator ReleaseMemoryStepByStep() // 分步释放内存协程
    { // 协程开始
        ReduceQualityForLowMemory(); // 降低画质,减少后续内存压力
        yield return null; // 等待一帧,避免同一帧执行过多逻辑
        ClearRuntimeCaches(); // 清理运行时缓存和对象池
        yield return null; // 再等待一帧,让释放过程更平滑
        yield return Resources.UnloadUnusedAssets(); // 卸载没有引用的 Unity 资源
        System.GC.Collect(); // 触发托管 GC,清理无引用的 C# 对象
    } // 协程结束

    private void ReduceQualityForLowMemory() // 降低画质设置
    { // 方法开始
        QualitySettings.masterTextureLimit = 1; // 降低纹理清晰度,减少纹理内存压力
        QualitySettings.shadowDistance = 20f; // 降低阴影距离,减少渲染资源压力
        QualitySettings.antiAliasing = 0; // 关闭 MSAA,减少 RenderTarget 内存占用
    } // 方法结束

    private void ClearRuntimeCaches() // 清理运行时缓存
    { // 方法开始
        Debug.Log("清理非关键缓存和对象池"); // 这里替换成项目里的对象池清理、资源缓存清理逻辑
    } // 方法结束
} // 类结束

一句话总结

CAUTION

低端机内存崩溃要系统化处理:确认 OOM,降低资源规格,减少加载峰值,严格释放资源,给缓存和对象池设上限,收到低内存回调时主动降级和清理。面试里能说出“常驻内存”和“峰值内存”分开治理,就很加分。

文章评价

读完这篇,留下你的看法

暂无审核通过的评价。

登录账号后才能评价。

本站访客数0总站访问量0本页访问量0