Appearance
内存优化
Unity Memory Profiler 能看什么?
面试高分回答
Unity Memory Profiler 主要用来看某一刻内存里到底有什么、谁占得多、谁没有释放、是谁还引用着它。它更像“内存快照分析工具”,不是专门看某一帧 CPU 卡顿的工具。
它能看什么?
可以看总体内存: 比如 Managed Memory、Native Memory、图形资源、音频资源、纹理、网格等大概占了多少。
可以看托管堆: 也就是 C# 对象,比如数组、字符串、List、Dictionary、自定义类对象。托管堆一直涨不下来,就要怀疑 C# 对象被引用住了。
可以看 Unity 原生对象: 比如 Texture2D、Mesh、AudioClip、Material、AnimationClip、GameObject、Component 等。
可以看资源占用: 最常见就是查大纹理、大 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# 对象,比如类实例、数组、字符串、List、Dictionary。它由 Mono 或 IL2CPP 的 GC 管理,没有引用后等待 GC 回收。
Native Memory 是 Unity 引擎和系统侧内存,主要放纹理、网格、音频、材质、动画、RenderTexture、物理数据、引擎内部对象等。它通常不是 C# GC 直接管理的,需要通过 Destroy、UnloadUnusedAssets、Addressables.Release、Dispose 等方式配合释放。
最容易混淆的点
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 没有释放。 这类东西经常需要 Destroy、Release 或 Dispose。
简单例子
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% 内存。
举个最常见的例子
一张 1024 × 1024 的 RGBA32 贴图:
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 × 索引大小 + 额外数据顶点缓冲怎么算
每个顶点不只是一个位置,它可能包含很多属性:
Position:Vector3,通常 12 字节。 Normal:Vector3,通常 12 字节。 Tangent:Vector4,通常 16 字节。 UV0:Vector2,通常 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 Load、Compressed In Memory、Streaming。它们本质是在 内存占用、CPU 解码成本、I/O 成本 之间做取舍。
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 就越占内存。
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 里加载出来的资源,以及实例化出来的对象,还会额外占内存。
要分三层理解
第一层是 AssetBundle 本体。 它常驻会占一定 Native Memory,主要是 Bundle 的元数据、目录、压缩块、加载入口等。
第二层是从 Bundle 里 LoadAsset 出来的资源。 比如 Texture、Mesh、AudioClip、Material、Prefab。这些资源加载出来后会占自己的内存,很多时候比 Bundle 本体更大。
第三层是 Instantiate 出来的对象。 Prefab 实例化后,场景里的 GameObject、Component、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 不同、运行时被重复加载。这三种问题看起来都叫重复资源,但排查手段不完全一样。
第一种:同一个资源被重复打包
比如多个 AssetBundle 都依赖同一张贴图、同一个材质、同一个 Shader,但这个公共依赖没有被单独抽出来,就可能被多个 Bundle 各带一份。
排查方法:看 Build Report、Build 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 时,瞬间峰值太高导致闪退或系统杀进程。
峰值内存为什么会高?
最常见是切场景时,旧场景资源还没释放,新场景资源已经开始加载,中间还会有解压缓冲、序列化数据、实例化对象,所以内存会短时间叠加。
比如:
旧场景 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 或系统直接杀进程。处理思路是:先确认是不是内存问题,再区分是常驻内存太高,还是加载过程峰值太高,然后针对资源规格、加载流程、缓存策略和运行时兜底做优化。
排查流程
先用低端真机复现,抓 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,降低资源规格,减少加载峰值,严格释放资源,给缓存和对象池设上限,收到低内存回调时主动降级和清理。面试里能说出“常驻内存”和“峰值内存”分开治理,就很加分。