Appearance
内存排查
玩 30 分钟内存持续增长,怎么查?
标准答案
玩 30 分钟内存持续增长,我会先判断它是正常缓存增长还是真正泄漏。正常缓存通常是前几分钟上升,之后趋于稳定;真正泄漏是每隔一段时间继续上涨,切场景、关闭界面、战斗结束后也不回落。
排查顺序是:真机复现 → 记录内存曲线 → Memory Profiler 抓快照 → 对比 Snapshot → 查引用链 → 修复释放逻辑 → 30 分钟回归验证。
底层原理
Unity 内存主要分几类:
Managed Heap:C# 对象,比如List、Dictionary、字符串、闭包、委托引用。Native Memory:Unity 引擎底层对象,比如 Texture、Mesh、AudioClip、AnimationClip。Asset Memory:资源加载后占用的内存,比如 Addressables、AssetBundle、实例化 Prefab。GPU Memory:贴图、RenderTexture、Mesh Buffer 等显存资源。
GC 只能回收没有引用的托管对象。如果对象还被静态变量、事件、缓存、对象池、单例、闭包引用着,GC 不会回收。Native 资源也不是简单靠 GC 释放,通常要 Destroy、Addressables.Release、AssetBundle.Unload、Resources.UnloadUnusedAssets 配合正确引用生命周期。
怎么查
第一步,看趋势。每隔 1 分钟记录一次 Managed、Native、总内存、GC 次数、当前场景、当前玩法状态。如果是战斗、背包、排行榜、聊天、特效这些操作后增长,就把操作固定下来重复测。
第二步,用 Unity Memory Profiler 抓两份快照:刚进入玩法一份,30 分钟后一份。然后做 Diff,看哪些类型数量或大小明显增加。比如:
Texture2D增长:可能贴图重复加载、图集没释放、头像缓存无限增长。GameObject增长:可能对象池只扩不收,或者隐藏对象没销毁。Material增长:可能运行时频繁访问renderer.material生成实例。List<T>、Dictionary<TKey,TValue>增长:可能缓存只加不删。Delegate、闭包对象增长:可能事件没有取消订阅。RenderTexture增长:可能临时 RT 没释放。
第三步,查引用链。Memory Profiler 里看对象为什么还活着,重点找:
- 静态字段。
- 单例管理器。
- 事件订阅者。
- 对象池集合。
- UI 缓存。
DontDestroyOnLoad对象。- Addressables handle。
- AssetBundle 依赖引用。
第四步,修复后必须回归。不能只看修完后不卡,要看 30 分钟、1 小时、反复切场景、反复进出界面后,内存曲线是否稳定。
Unity 常见原因
最常见的是事件没退订。比如 UI 关闭了,但还订阅着全局事件,事件系统引用着 UI,导致 UI 和它引用的贴图、Prefab 都不能释放。
第二类是资源引用计数没释放。Addressables LoadAssetAsync 或 InstantiateAsync 后没有对应 Release 或 ReleaseInstance,资源会一直被认为有人使用。
第三类是对象池只扩不收。对象池确实能减少 GC,但如果池子没有上限,或者归还时没有清理引用,长时间玩会变成“内存仓库”。
第四类是运行时隐式创建资源。例如频繁访问 renderer.material 会实例化材质;创建 new Texture2D、new RenderTexture 后没释放,也会持续增长。
辅助代码
c
using System; // 引入 GC 相关 API。
using UnityEngine; // 引入 Unity 基础 API。
using UnityEngine.Profiling; // 引入 Unity Profiler 内存 API。
public sealed class MemoryTrendLogger : MonoBehaviour // 定义内存趋势日志组件。
{ // 类开始。
[SerializeField] private float interval = 60f; // 每 60 秒记录一次内存。
private float timer; // 保存计时器累计时间。
private void Update() // 每帧更新计时器。
{ // Update 方法开始。
timer += Time.unscaledDeltaTime; // 使用不受暂停影响的真实时间累计。
if (timer < interval) return; // 没到记录间隔就直接返回。
timer = 0f; // 重置计时器。
LogMemory(); // 输出一次内存日志。
} // Update 方法结束。
private void LogMemory() // 定义输出内存信息的方法。
{ // LogMemory 方法开始。
long managed = GC.GetTotalMemory(false); // 获取当前托管堆大概占用。
long total = Profiler.GetTotalAllocatedMemoryLong(); // 获取 Unity 已分配总内存。
long reserved = Profiler.GetTotalReservedMemoryLong(); // 获取 Unity 保留内存。
long unused = Profiler.GetTotalUnusedReservedMemoryLong(); // 获取保留但未使用内存。
Debug.Log($"Memory managed={managed / 1024 / 1024}MB total={total / 1024 / 1024}MB reserved={reserved / 1024 / 1024}MB unused={unused / 1024 / 1024}MB scene={UnityEngine.SceneManagement.SceneManager.GetActiveScene().name}"); // 输出内存趋势日志。
} // LogMemory 方法结束。
} // 类结束。面试关键句
IMPORTANT
“我不会先猜是 GC 或贴图,而是先记录内存曲线,判断是否真的持续增长。然后用 Memory Profiler 对比 0 分钟和 30 分钟快照,按 Managed、Native、Asset 分类查增长对象,再沿引用链找到是谁持有它。最后修复释放、退订、引用计数或对象池上限,并用长时间压测证明曲线稳定。”
切场景后内存不下降,怎么查?
标准答案
切场景后内存不下降,我不会先判断成“泄漏”,而是先区分两件事:Unity 保留的内存没有还给系统,和旧场景对象或资源仍然被引用。前者可能是正常缓存或分配器行为;后者才是需要修的泄漏。
正确排查流程是:切场景前抓快照 → 切场景后等一帧 → 执行资源卸载 → 再抓快照 → 对比旧场景对象、Texture、Mesh、Material、Audio、GameObject 是否还存在 → 查引用链。
底层原理
Unity 切场景时,会销毁普通场景里的 GameObject,但不代表所有资源立刻释放:
Destroy通常是延迟到帧末执行。- 被
DontDestroyOnLoad标记的对象不会随场景销毁。 - 被静态变量、单例、事件、对象池、缓存持有的对象不会释放。
- Addressables 或 AssetBundle 的资源如果引用计数没降到 0,也不会卸载。
Resources.UnloadUnusedAssets()只会卸载“没有任何引用”的资源。- Unity 的 Native Allocator 可能保留已申请内存,所以系统看到的总内存不一定马上下降。
所以面试里要强调:不是看 Total Memory 一项,而是看旧场景资源是否还活着,以及它被谁引用。
重点查什么
优先查这几类:
DontDestroyOnLoad:跨场景管理器、音乐对象、UIRoot 是否重复创建。- 静态变量:
static List、static Dictionary是否保存了旧场景对象。 - 事件订阅:UI、角色、技能对象销毁前是否取消订阅。
- 对象池:池子是否持有旧场景 Prefab、特效、子弹、UI Item。
- Addressables:
LoadAssetAsync后是否Release,InstantiateAsync后是否ReleaseInstance。 - 隐式资源:频繁访问
renderer.material生成材质实例,RenderTexture没释放。 - AssetBundle:Bundle 卸了不代表已加载 Asset 一定没引用。
排查步骤
- 真机或目标平台复现,不只看 Editor。
- 切场景前用 Memory Profiler 抓 Snapshot。
- 切场景后等一帧,因为
Destroy不是立刻完成。 - 在加载界面或低频时机调用
Resources.UnloadUnusedAssets()。 - 再抓 Snapshot,做 Diff。
- 如果旧场景对象还在,点开引用链,看是静态字段、事件、池子、Addressables handle 还是常驻对象持有。
- 修复后反复切场景 10 到 20 次,看内存曲线是否稳定。
辅助代码
c
using System.Collections; // 引入 IEnumerator 协程类型。
using UnityEngine; // 引入 Unity 基础 API。
using UnityEngine.SceneManagement; // 引入 Unity 场景管理 API。
public sealed class SceneMemoryCleanup : MonoBehaviour // 定义场景切换清理组件。
{ // 类开始。
public IEnumerator SwitchSceneAndCleanup(string nextScene) // 定义切场景并清理的协程。
{ // 方法开始。
AsyncOperation op = SceneManager.LoadSceneAsync(nextScene); // 异步加载目标场景。
yield return op; // 等待目标场景加载完成。
yield return null; // 等待一帧,让 Destroy 延迟销毁生效。
yield return Resources.UnloadUnusedAssets(); // 卸载当前没有引用的资源,注意这个操作比较重。
System.GC.Collect(); // 触发托管 GC,适合大场景切换后的低频验证。
} // 方法结束。
} // 类结束。面试关键句
WARNING
“切场景后内存不下降,我会先判断是不是 Unity 分配器保留内存,而不是直接说泄漏。真正要查的是旧场景对象和资源是否还被引用。我会用 Memory Profiler 抓切换前后快照,做 Diff,看旧 Texture、Mesh、GameObject、Material 是否还活着,再沿引用链查静态变量、事件、对象池、DontDestroyOnLoad 和 Addressables handle。修复后用多次切场景压测验证内存曲线是否稳定。”
UI 关闭后贴图还在,怎么查?
标准答案
UI 关闭后贴图还在,我会先判断:这个 UI 是隐藏了,还是真的销毁并释放资源了。SetActive(false) 只是让界面不可见,Image.sprite、Sprite、Texture2D、加载句柄、对象池缓存仍然可能继续持有贴图,所以贴图不下降不一定是异常。
真正要查的是:这张 Texture2D 现在还被谁引用。
底层原理
UGUI 的引用链通常是:
c
UI Panel -> Image -> Sprite -> Texture2D只要链上任何对象还活着,贴图就不会被卸载。常见持有者有:
- UI 没销毁,只是
SetActive(false)。 - UI 被对象池保存,
Image.sprite没清空。 - 静态缓存保存了
Sprite或Texture2D。 - 事件系统引用着 UI,导致 UI 不能释放。
- Addressables 加载后没有
Release。 - SpriteAtlas 图集仍被其他 Sprite 使用。
- 公共 UI 图集本来就是常驻资源。
- TMP 字体图集、公共图标图集被其他界面共用。
所以不能只看“Texture 还在”,要看它是不是应该常驻,以及它的引用链是否合理。
怎么查
- 用 Memory Profiler 抓关闭 UI 前后的 Snapshot。
- 在快照里搜索
Texture2D或对应贴图名。 - 点开贴图,看
References,确认是谁持有它。 - 如果引用链指向旧 UI 的
Image,说明 UI 或对象池没清理。 - 如果引用链指向
SpriteAtlas,要确认这个图集是不是公共常驻图集。 - 如果引用链指向 Addressables handle,要检查是否调用了
Addressables.Release。 - 如果引用链指向静态字段、事件、单例缓存,就查对应模块的生命周期。
- 修复后反复打开关闭 UI,确认 Texture 数量和内存曲线稳定。
常见修复
关闭临时 UI 时,可以做这些事:
Image.sprite = null,断开 UI 到 Sprite 的引用。- 取消事件订阅,避免事件系统拉住 UI。
- 清理字典、列表、头像缓存、图标缓存。
- Addressables 加载的资源要配对
Release。 - 对象池归还时要重置状态,不能带着旧贴图回池。
Resources.UnloadUnusedAssets()很重,通常放在切场景、加载界面、低频清理点,不建议每次关 UI 都调用。
辅助代码
c
using UnityEngine; // 引入 Unity 基础 API。
using UnityEngine.UI; // 引入 UGUI Image 组件。
using UnityEngine.AddressableAssets; // 引入 Addressables API。
using UnityEngine.ResourceManagement.AsyncOperations; // 引入 Addressables 异步句柄类型。
public sealed class IconView : MonoBehaviour // 定义一个图标 UI 组件。
{ // 类开始。
[SerializeField] private Image iconImage; // 保存 Image 组件引用。
private AsyncOperationHandle<Sprite> iconHandle; // 保存图标 Sprite 的加载句柄。
private bool hasIconHandle; // 标记当前是否持有有效加载句柄。
public void SetIcon(AsyncOperationHandle<Sprite> handle) // 设置图标资源。
{ // 方法开始。
iconHandle = handle; // 保存 Addressables 返回的句柄。
hasIconHandle = true; // 标记当前持有资源句柄。
iconImage.sprite = handle.Result; // 把加载出的 Sprite 赋给 Image。
} // 方法结束。
public void ClearIcon() // 清理图标引用。
{ // 方法开始。
iconImage.sprite = null; // 断开 Image 到 Sprite 的引用。
if (!hasIconHandle) return; // 没有句柄就不需要释放。
Addressables.Release(iconHandle); // 释放 Addressables 资源引用计数。
hasIconHandle = false; // 标记句柄已经释放。
} // 方法结束。
private void OnDestroy() // UI 被销毁时执行清理。
{ // 方法开始。
ClearIcon(); // 确保销毁前断开引用并释放资源。
} // 方法结束。
} // 类结束。面试关键句
IMPORTANT
“UI 关闭后贴图还在,我不会直接说是 Unity 没释放。我会先确认这个 UI 是隐藏、回池还是销毁,然后用 Memory Profiler 找到 Texture2D 的引用链。如果它还被 Image、Sprite、静态缓存、事件、对象池或 Addressables handle 持有,就按生命周期修引用释放;如果它属于公共图集或常驻 UI 资源,那可能是合理保留。”
AssetBundle 卸载后内存没释放,可能为什么?
标准答案
AssetBundle 卸载后内存没释放,最常见原因是:你卸掉的是 Bundle 本体,不一定卸掉了已经加载出来的 Asset,更不一定销毁了实例化对象。
我会先区分四层东西:
AssetBundle:包对象和包内索引、压缩数据。Asset:从包里加载出来的Prefab、Texture、Material、AudioClip。Instance:实例化到场景里的GameObject。Dependency:这个 Bundle 依赖的材质包、贴图包、Shader 包。
只卸载其中一层,其他层仍然可能占内存。
底层原理
bundle.Unload(false) 的含义是:卸载 AssetBundle 本体,但已经 Load 出来的资源还活着。所以你会看到 Bundle 卸了,但 Texture2D、Mesh、Material 还在内存里。
bundle.Unload(true) 的含义是:连从这个 Bundle 加载出来的 Asset 也一起卸载。但它比较危险,如果场景里还有对象正在用这些资源,可能出现材质丢失、贴图丢失、引用异常,所以项目里一般不会随便对还在使用的资源直接 Unload(true)。
另外,即使资源真的没引用了,Unity 的底层分配器也可能保留一部分内存,不一定立刻还给操作系统。所以不能只看系统总内存,要用 Memory Profiler 看对象数量和引用链。
可能原因
- 你调用的是
Unload(false),所以 Asset 还在。 - Prefab 已经实例化,场景对象还没
Destroy。 - 资源被静态变量、单例、缓存、对象池持有。
- UI、角色、特效还引用着 Texture、Material、Mesh。
- Addressables 的 handle 没有
Release。 - 依赖 Bundle 没卸载,比如主包卸了,贴图包还在。
renderer.material运行时生成了材质实例,没有清理。Resources.UnloadUnusedAssets()没执行,未引用资源还没被扫描卸载。- 异步销毁、异步卸载还没完成,立刻看内存会误判。
- Unity Native Allocator 保留内存,看起来系统内存没下降。
怎么查
我会用 Memory Profiler 抓两张快照:卸载前一张、卸载后一张,然后 Diff:
- 如果
AssetBundle少了,但Texture2D还在,说明只是包卸了,Asset 没释放。 - 如果
GameObject还在,说明实例没有销毁。 - 如果
Material数量上涨,查是不是用了renderer.material。 - 如果引用链指向静态字段或 Manager,说明缓存没清。
- 如果引用链指向 Addressables handle,说明引用计数没降。
- 如果依赖包还在,查 Manifest 依赖关系和引用计数。
辅助代码
c
using System.Collections; // 引入 IEnumerator 协程类型。
using UnityEngine; // 引入 Unity 基础 API。
public sealed class BundleUnloadDemo : MonoBehaviour // 定义 AssetBundle 卸载示例组件。
{ // 类开始。
private AssetBundle bundle; // 保存当前加载的 AssetBundle 引用。
private GameObject instance; // 保存从 Bundle 实例化出来的对象。
public IEnumerator ReleaseBundleSafely() // 定义安全释放 Bundle 的协程。
{ // 方法开始。
if (instance != null) Destroy(instance); // 先销毁场景里的实例对象。
instance = null; // 清空实例引用,避免继续持有资源。
yield return null; // 等一帧,让 Destroy 延迟销毁生效。
if (bundle != null) bundle.Unload(false); // 卸载 Bundle 本体,但不强制卸载已加载 Asset。
bundle = null; // 清空 Bundle 引用,避免 C# 层继续持有。
yield return Resources.UnloadUnusedAssets(); // 卸载当前没有引用的资源。
System.GC.Collect(); // 在低频清理点触发托管 GC。
} // 方法结束。
} // 类结束。面试关键句
NOTE
“AssetBundle 卸载后内存没降,我不会只看 Unload 有没有调用。我会先区分 Bundle 本体、已加载 Asset、实例化对象和依赖 Bundle。Unload(false) 只卸包,不卸已经加载出来的资源;资源是否释放要看有没有实例、缓存、静态引用、Addressables handle 或依赖计数。最后用 Memory Profiler 的 Snapshot Diff 和引用链证明到底是谁没释放。”
大量字符串导致 GC,怎么优化?
标准答案
大量字符串导致 GC,我会先用 Profiler 定位 GC.Alloc 的调用栈,再优化高频路径里的字符串创建。重点不是“项目里不能用字符串”,而是不要在每帧、每个怪物、每个 UI Item、每条日志里反复创建临时字符串。
常见优化方向:
- 高频 UI 文本只在数值变化时刷新。
- 避免
Update里频繁+、插值$""、string.Format、ToString()。 - 多段拼接用
StringBuilder,但注意ToString()仍会生成最终字符串。 - TextMeshPro 优先用
SetText("HP: {0}", value)这类接口。 - 正式包关闭高频
Debug.Log。 - 聊天、日志、排行榜、战斗飘字等系统要做对象池和缓存。
- 常用固定文本,比如品质名、状态名、等级文案,可以预生成缓存。
底层原理
C# 的 string 是引用类型,但它是不可变对象。一旦字符串内容确定,就不能原地修改。
比如:
c
name = "HP:" + hp + "/" + maxHp;这行看起来只是拼了一次,但底层可能产生多个临时字符串。字符串越多,托管堆里的短命对象越多,Unity 的 GC 压力就越大。GC 一旦触发,就可能造成帧时间尖峰,表现为游戏突然卡一下。
在 Unity 里,字符串 GC 高频来源通常是:
- UI 数字每帧刷新。
Debug.Log("pos=" + transform.position)。- 战斗飘字大量生成。
- 排行榜、聊天、背包 Item 频繁拼文案。
ToString("F2")在 Update 中反复调用。- LINQ、闭包和字符串处理混在热路径里。
- JSON、CSV、协议文本在帧循环里反复解析。
Unity 工程实践
如果是血量、金币、倒计时这种 UI,我一般不会每帧刷新,而是做“脏标记”或“值变化检测”。
c
using TMPro; // 引入 TextMeshPro 文本组件命名空间。
using UnityEngine; // 引入 Unity 基础 API。
public sealed class HpTextView : MonoBehaviour // 定义血量文本显示组件。
{ // 类开始。
[SerializeField] private TMP_Text hpText; // 保存 TextMeshPro 文本组件引用。
private int lastHp = -1; // 记录上一次显示的当前血量。
private int lastMaxHp = -1; // 记录上一次显示的最大血量。
public void Refresh(int hp, int maxHp) // 刷新血量文本。
{ // 方法开始。
if (hp == lastHp && maxHp == lastMaxHp) return; // 数值没变化就不刷新 UI。
lastHp = hp; // 缓存当前血量。
lastMaxHp = maxHp; // 缓存最大血量。
hpText.SetText("HP: {0}/{1}", hp, maxHp); // 使用 TMP SetText 减少格式化分配。
} // 方法结束。
} // 类结束。如果是拼接多段内容,比如结算面板、日志面板、任务描述,可以复用 StringBuilder:
c
using System.Text; // 引入 StringBuilder。
using TMPro; // 引入 TextMeshPro。
using UnityEngine; // 引入 Unity 基础 API。
public sealed class ResultTextView : MonoBehaviour // 定义结算文本组件。
{ // 类开始。
[SerializeField] private TMP_Text resultText; // 保存结算文本组件。
private readonly StringBuilder builder = new StringBuilder(128); // 复用字符串构建缓冲区。
public void ShowResult(int gold, int exp, int killCount) // 显示结算内容。
{ // 方法开始。
builder.Clear(); // 清空旧内容但复用内部缓冲区。
builder.Append("Gold: "); // 追加金币标题。
builder.Append(gold); // 追加金币数值。
builder.Append("\nExp: "); // 追加经验标题并换行。
builder.Append(exp); // 追加经验数值。
builder.Append("\nKills: "); // 追加击杀标题并换行。
builder.Append(killCount); // 追加击杀数量。
resultText.SetText(builder); // 把 StringBuilder 内容交给 TMP 显示。
} // 方法结束。
} // 类结束。注意点
StringBuilder 不是万能的。它适合多段拼接、循环拼接、长文本构造;如果只是低频拼一两次,用普通字符串问题不大。优化时要看 GC.Alloc 数据,不要把所有字符串都改成复杂写法。
正式项目里我会重点盯这几个指标:GC.Alloc/frame、GC 触发频率、Main Thread 帧耗时尖峰、UI 文本刷新次数、日志输出量。优化后要能说出数据,比如“战斗中每帧 GC.Alloc 从 3KB 降到 0B,10 分钟内 GC 次数明显下降”。
面试关键句
TIP
“字符串 GC 的核心原因是 string 不可变,高频拼接、格式化和 UI 文本刷新会产生大量短命对象。我会先用 Profiler 找 GC.Alloc 调用栈,再针对热路径做值变化刷新、StringBuilder 复用、TMP SetText、日志裁剪和缓存。优化是否有效,用每帧 GC.Alloc 和帧时间尖峰来验证。”
Texture 内存占用过高,怎么处理?
标准答案
Texture 内存占用过高,我会先做量化,不会一上来就盲目压缩。排查顺序是:找最大纹理和重复纹理 → 算内存 → 看导入设置 → 改尺寸和压缩格式 → 处理 MipMap、Read/Write、图集、分包和卸载策略 → 真机验证画质与内存。
底层原理
纹理内存主要由三个因素决定:尺寸、像素格式、MipMap。
例如未压缩 RGBA32:
c
2048 * 2048 * 4 byte = 16 MB如果开启 MipMap,通常还会额外增加约三分之一内存。也就是说一张 2048 的 RGBA32 贴图,可能接近 21 MB。如果项目里有几十张这种贴图,内存会很快爆。
常见高内存原因:
- 贴图尺寸过大,实际显示很小。
- 使用
RGBA32、ARGB32这类未压缩格式。 - UI 贴图开启了不必要的
MipMap。 Read/Write Enabled开着,可能保留 CPU 侧副本。- 同一贴图被多个 Bundle 重复打包。
- 图集过大,某个小图标引用导致整张图集常驻。
- 临时 UI、头像、活动图加载后没有释放引用。
- 低端机和高端机使用同一套高清资源。
- RenderTexture 分辨率过高,也会占显存。
Unity 工程处理
我会优先处理收益最大的项:
- 用
Memory Profiler按Texture2D大小排序。 - 检查
Max Size,2048 能不能降到 1024 或 512。 - 检查平台压缩格式,移动端优先考虑
ASTC、ETC2、PVRTC。 - UI 贴图通常关闭
MipMap。 - 运行时不需要读像素的贴图关闭
Read/Write Enabled。 - 法线贴图使用正确的 Normal Map 导入类型。
- 查 Addressables 或 AssetBundle 是否重复打包。
- 公共图集和临时图集分开,避免临时资源被公共引用拉住。
- 大世界或大场景考虑 Texture Streaming。
- 做高中低画质分档,低端机用更小 Max Size。
平台格式选择
- Android:常用
ASTC或ETC2。 - iOS:常用
ASTC,老设备可能考虑PVRTC。 - UI:要平衡清晰度和压缩伪影,重要 UI 不要压得太狠。
- 法线贴图:压缩过强会影响光照细节。
- 带 Alpha 的贴图:要注意格式是否支持透明通道。
- HDR 贴图、Lightmap、Reflection Probe:要单独评估质量和内存。
辅助代码
下面这个脚本适合在开发期快速输出场景里用到的贴图信息,正式项目一般会做成编辑器工具或资源检查工具。
c
using UnityEngine; // 引入 Unity 基础 API。
using System.Collections.Generic; // 引入 List 集合类型。
public sealed class TextureMemoryReporter : MonoBehaviour // 定义纹理内存检查组件。
{ // 类开始。
public void ReportSceneTextures() // 输出当前场景 Renderer 使用的纹理信息。
{ // 方法开始。
Renderer[] renderers = FindObjectsOfType<Renderer>(); // 找到场景中所有 Renderer。
HashSet<Texture> textures = new HashSet<Texture>(); // 创建集合用于去重纹理引用。
foreach (Renderer renderer in renderers) // 遍历每个 Renderer。
{ // 循环开始。
foreach (Material material in renderer.sharedMaterials) // 遍历共享材质,避免访问 material 生成实例。
{ // 循环开始。
if (material == null) continue; // 材质为空则跳过。
Texture mainTex = material.mainTexture; // 获取主纹理引用。
if (mainTex == null) continue; // 主纹理为空则跳过。
textures.Add(mainTex); // 把纹理加入去重集合。
} // 循环结束。
} // 循环结束。
foreach (Texture texture in textures) // 遍历去重后的纹理。
{ // 循环开始。
Debug.Log($"Texture={texture.name}, size={texture.width}x{texture.height}"); // 输出纹理名称和尺寸。
} // 循环结束。
} // 方法结束。
} // 类结束。面试关键句
NOTE
“Texture 内存高,我会先用 Memory Profiler 找大户和重复资源,然后按尺寸、格式、MipMap、Read/Write、重复打包、图集策略和资源卸载逐项处理。纹理内存本质上是尺寸乘格式,2048 降到 1024 通常能省 75%,所以我会优先改 Max Size 和平台压缩格式,再结合真机画质验证。”
AudioClip 内存占用过高,怎么处理?
标准答案
AudioClip 内存占用过高,核心处理思路是:先按用途分类,再调整加载类型、压缩格式、采样率、声道和生命周期。短音效、背景音乐、语音不能用同一套导入设置。
我会先用 Memory Profiler 找出最大的 AudioClip,再看它们的 Load Type、Compression Format、Quality、Sample Rate、Force To Mono、Preload Audio Data 是否合理。
底层原理
AudioClip 的内存不只看原始文件大小,还取决于 Unity 加载后怎么存:
Decompress On Load:加载时解压到内存,播放开销低,但内存最大。Compressed In Memory:内存中保持压缩,播放时解码,省内存但增加 CPU。Streaming:边播边读,适合长 BGM,内存低但有 IO 和流式缓冲成本。Preload Audio Data:场景或资源加载时就把音频数据加载进内存。Force To Mono:把立体声转单声道,能减少声道数据。Sample Rate:采样率越高,数据越多;语音和低频音效不一定需要很高采样率。
所以 AudioClip 内存高,通常不是一个开关能解决,而是要按音频类型做策略。
Unity 工程处理
短音效,比如按钮音、受击音、技能命中音: 它们短、播放频繁、要求延迟低,可以考虑 Decompress On Load 或 ADPCM。如果数量很多,要控制常驻集合,只保留高频基础音效。
背景音乐 BGM: 时间长,不适合全量解压常驻。通常用 Streaming 或 Compressed In Memory,避免一首音乐解压后占几十 MB。
语音: 比如剧情语音、角色台词,可以降低采样率、降低质量、按需加载,播放结束后释放引用。大量语音不应该全部预加载。
UI 音效: 可以常驻一小组公共音效,但活动、剧情、临时界面的音频要跟随界面生命周期释放。
常见优化点
- 长 BGM 改成
Streaming。 - 大量短音效检查是否都
Decompress On Load。 - 不需要立体声的音效开启
Force To Mono。 - 语音类资源降低
Sample Rate。 - 非立即播放的音频关闭
Preload Audio Data。 - 大量语音、活动音频使用 Addressables 按需加载。
- 切场景、战斗结束、剧情结束后释放 AudioClip 引用。
- 检查 AudioSource、对象池、单例 AudioManager 是否一直持有旧 clip。
- 用 Memory Profiler 看
AudioClip数量和引用链。 - 真机验证 CPU 解码、IO、加载卡顿和听感质量。
辅助代码
c
using UnityEngine; // 引入 Unity 基础 API。
public sealed class AudioClipCleaner : MonoBehaviour // 定义音频资源清理组件。
{ // 类开始。
[SerializeField] private AudioSource audioSource; // 保存 AudioSource 引用。
public void StopAndReleaseClip() // 停止播放并释放当前音频引用。
{ // 方法开始。
if (audioSource == null) return; // 没有 AudioSource 就直接返回。
audioSource.Stop(); // 停止当前音频播放。
AudioClip clip = audioSource.clip; // 缓存当前 AudioClip 引用。
audioSource.clip = null; // 断开 AudioSource 对 AudioClip 的引用。
if (clip == null) return; // 如果没有音频资源就直接返回。
clip.UnloadAudioData(); // 卸载 AudioClip 已加载的音频数据。
} // 方法结束。
} // 类结束。注意:UnloadAudioData() 适合处理非频繁使用、可重新加载的音频数据。如果是高频音效,频繁卸载再加载反而会造成卡顿和 IO 压力。
面试关键句
NOTE
“AudioClip 内存高,我会先用 Memory Profiler 找出大 AudioClip,并按短音效、BGM、语音分类处理。短音效看播放延迟,BGM 看 Streaming,语音看采样率和按需加载。然后检查 Preload Audio Data、Force To Mono、压缩质量和生命周期引用。最后用真机数据验证内存下降是否换来了 CPU 解码或 IO 卡顿。”
Mesh 内存过高,怎么处理?
标准答案
Mesh 内存过高,我会先看它到底高在哪里:顶点数、索引数、顶点属性、BlendShape、骨骼数据、Read/Write 副本、Static Batching 副本。Mesh 内存不是只看“面数”,很多时候是顶点通道和副本导致的。
底层原理
Mesh 内存大致可以理解成:
Mesh 内存 ≈ 顶点数 x 顶点步长 + 索引数 x 索引大小 + BlendShape + Skin 数据 + 副本顶点步长里可能包含:
- Position:顶点坐标。
- Normal:法线。
- Tangent:切线,法线贴图常用。
- UV0、UV1、UV2:多套 UV 会增加内存。
- Color:顶点色。
- Bone Weight:骨骼权重。
- BlendShape Delta:表情、变形数据。
如果开启 Read/Write Enabled,Unity 可能还会保留一份 CPU 侧可读写 Mesh 数据;如果使用 Static Batching,Unity 可能生成合并后的网格副本,用内存换 CPU 提交效率。
怎么处理
我会按收益从大到小处理:
- 用 Memory Profiler 找最大的
Mesh和SkinnedMeshRenderer。 - 检查模型顶点数、三角形数是否明显超标。
- 美术侧减面,删除不可见面、内部面、重复点。
- 远距离模型使用
LOD Group,更远用 Billboard 或 Impostor。 - 删除不用的顶点通道,比如不用顶点色就不要 Color,不用法线贴图就不要 Tangent。
- 顶点数小于 65535 的 Mesh 尽量用 16 位索引。
- 不需要运行时读写 Mesh 的资源关闭
Read/Write Enabled。 - 谨慎使用
Mesh Compression,它能省内存,但可能带来精度问题。 - 角色模型重点查骨骼数量、骨骼权重、BlendShape 数量。
- 检查 Static Batching 是否让 Mesh 内存上涨过多。
- 大量相同模型优先考虑 GPU Instancing,而不是复制很多 Mesh。
- 切场景或资源卸载时确认 Mesh 没被缓存、对象池、Addressables 句柄继续引用。
Unity 常见坑
NOTE
Static Batching 不是免费优化。它能减少 DrawCall 或 CPU 提交成本,但会生成合并网格,内存可能变高。如果场景里很多静态物体共享同一个 Mesh,开启 Static Batching 后可能反而多出大量 Mesh 数据。
Read/Write Enabled 也很常见。运行时如果不需要改顶点、不需要读网格数据,就应该关闭,否则可能保留 CPU 侧副本。
SkinnedMesh 要特别注意。角色模型不仅有顶点和索引,还有骨骼权重、BindPose、BlendShape。表情很多、换装很多、角色数量很多时,Mesh 内存会明显上升。
辅助代码
下面是开发期 Mesh 检查工具,用来快速找顶点数过高的 Mesh。
c
#if UNITY_EDITOR // 只在 Unity 编辑器中编译这段工具代码。
using UnityEditor; // 引入 Unity 编辑器 API。
using UnityEngine; // 引入 Unity 基础 API。
public static class MeshAuditTool // 定义 Mesh 检查工具类。
{ // 类开始。
[MenuItem("Tools/Audit Mesh Assets")] // 在 Unity 菜单栏添加检查入口。
public static void AuditMeshes() // 定义执行 Mesh 检查的方法。
{ // 方法开始。
string[] guids = AssetDatabase.FindAssets("t:Mesh"); // 查找项目中所有 Mesh 资源。
foreach (string guid in guids) // 遍历每一个资源 GUID。
{ // 循环开始。
string path = AssetDatabase.GUIDToAssetPath(guid); // 把 GUID 转换成资源路径。
Mesh mesh = AssetDatabase.LoadAssetAtPath<Mesh>(path); // 按路径加载 Mesh 资源。
if (mesh == null) continue; // 如果加载失败就跳过。
int vertexCount = mesh.vertexCount; // 获取 Mesh 顶点数量。
int triangleCount = mesh.triangles.Length / 3; // 获取 Mesh 三角形数量。
if (vertexCount < 10000) continue; // 小于阈值的 Mesh 暂时不输出。
Debug.Log($"Mesh={path}, vertices={vertexCount}, triangles={triangleCount}, readable={mesh.isReadable}"); // 输出高顶点 Mesh 信息。
} // 循环结束。
} // 方法结束。
} // 类结束。
#endif // 结束编辑器条件编译。面试关键句
IMPORTANT
我会这样回答:“Mesh 内存高,我不会只说减面。我会先用 Memory Profiler 找 Mesh 大户,然后按顶点数、索引格式、顶点通道、BlendShape、SkinnedMesh 数据、Read/Write 副本和 Static Batching 副本逐项排查。优化时要权衡内存、渲染质量、CPU 提交和 GPU 成本,最后用真机数据验证。”
粒子特效内存过高,怎么处理?
标准答案
粒子特效内存过高,我会先拆开看:粒子系统本体内存、贴图材质内存、Trail/Collision/SubEmitter 模块内存、对象池常驻内存、资源引用未释放。不能只说“粒子太多”,因为很多时候真正的大头是贴图、材质、Trail、对象池或 Addressables 引用。
底层原理
粒子特效内存主要来自这些部分:
ParticleSystem内部会为粒子数据分配缓冲,Max Particles越大,上限越高。- 每个粒子可能保存位置、速度、颜色、大小、旋转、生命周期等数据。
Trail会额外保存轨迹点。Collision会增加碰撞相关数据和计算。SubEmitter可能让一个粒子继续生成更多粒子。- 粒子材质用的
Texture、Shader、Material也会常驻。 - 对象池虽然减少创建销毁,但池子太大也会让内存长期占着。
- 如果特效是 Addressables/AssetBundle 加载的,句柄不释放,资源也不会卸。
怎么处理
第一步,用 Memory Profiler 找大户:看 ParticleSystem、Texture2D、Material、Mesh、GameObject 数量和引用链。
第二步,降粒子预算:
- 降低
Max Particles。 - 降低
Emission Rate。 - 减少
Burst数量。 - 缩短
Start Lifetime。 - 减少持续播放的循环特效。
- 屏外或远距离特效暂停、降级或不播放。
第三步,精简模块:
- 少用
Trails,轨迹数据很容易膨胀。 - 少用
Collision,移动端尤其要谨慎。 - 少用
Lights,粒子灯光非常贵。 - 控制
Sub Emitters,不要一层套一层无限放大。 - 不需要的
Custom Data、Custom Vertex Streams不要开。
第四步,优化贴图和材质:
- 粒子贴图压缩,比如移动端用 ASTC/ETC2。
- 多个特效复用同一张图集。
- 减少材质数量,能共享材质就共享。
- 不要每个特效实例都生成独立材质。
- 透明特效还要看 Overdraw,虽然这是 GPU 问题,但经常和特效资源问题一起出现。
第五步,控制对象池:
- 池子必须有上限。
- 特效播放完要回收。
- 回收时调用
Stop和Clear。 - 切场景时清理临时特效池。
- 长时间不用的池要释放,而不是永久常驻。
辅助代码
c
using UnityEngine; // 引入 Unity 基础 API。
public sealed class ParticleEffectCleaner : MonoBehaviour // 定义粒子特效清理组件。
{ // 类开始。
private ParticleSystem[] particles; // 缓存当前特效下的所有粒子系统。
private void Awake() // 初始化时执行。
{ // 方法开始。
particles = GetComponentsInChildren<ParticleSystem>(true); // 获取子节点里的所有粒子系统。
} // 方法结束。
public void StopAndClear() // 停止并清空粒子。
{ // 方法开始。
foreach (ParticleSystem ps in particles) // 遍历所有粒子系统。
{ // 循环开始。
if (ps == null) continue; // 粒子系统为空就跳过。
ps.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear); // 停止发射并清空已存在粒子。
} // 循环结束。
gameObject.SetActive(false); // 清理完成后隐藏对象,方便对象池回收。
} // 方法结束。
} // 类结束。常见坑
对象池不是越大越好。很多项目优化特效时只做池化,但没有池上限,结果战斗打久了,池里常驻几百个特效 Prefab,内存反而越来越高。
还有一个坑是只关对象,不清粒子。SetActive(false) 不一定等于粒子数据、贴图、材质引用都释放。临时特效回池前,至少要 StopEmittingAndClear,并确保不会继续持有临时材质或 Addressables handle。
面试关键句
TIP
“粒子特效内存高,我会先用 Memory Profiler 区分是 ParticleSystem 本体、贴图材质、Trail/Collision/SubEmitter,还是对象池常驻。优化时先降 Max Particles、发射率和生命周期,再关重模块、压缩贴图、复用材质和图集,最后给对象池加上限和空闲释放。优化结果要用内存、GC、DrawCall、Overdraw 和真机帧时间一起验证。”
如何做自动化内存巡检?
标准答案
自动化内存巡检的核心是:把人工 Profiler 排查流程变成固定脚本、固定场景、固定指标、固定阈值、固定报告。它不是替代人工分析,而是提前发现“内存持续上涨、峰值过高、切场景不回落、资源未释放”这类问题。
怎么设计
我会分成 6 步:
- 固定测试路径:登录、进主城、开背包、进战斗、释放技能、切场景、返回主界面。
- 自动采集指标:
Managed、Native、Texture、Mesh、AudioClip、GC.Alloc、总内存、峰值内存。 - 做基线对比:当前版本和上一个稳定版本比。
- 设置阈值:比如内存峰值超过 10%,或切场景后不回落超过 50MB。
- 生成报告:CSV、HTML、曲线图、截图、设备信息、版本号。
- 接入 CI:每日长测,提交短测,严重超阈值直接告警或阻断合入。
重点判断规则
- 加载时升高,关闭或切场景后能回落:通常是正常峰值。
- 每轮流程后都比上一轮更高:可能泄漏。
- Texture、Mesh、AudioClip 数量持续增加:资源生命周期有问题。
- Managed Heap 持续涨:可能集合缓存、事件、闭包、字符串、对象池引用没清。
- Native Memory 涨但 Managed 不涨:重点查纹理、Mesh、Audio、RenderTexture、插件。
- Editor 正常但真机异常:以真机数据为准。
辅助代码
c
using System; // 引入 GC 内存统计 API。
using System.Collections; // 引入 IEnumerator 协程类型。
using System.IO; // 引入文件写入 API。
using System.Text; // 引入 StringBuilder 用于构建 CSV。
using UnityEngine; // 引入 Unity 基础 API。
using UnityEngine.Profiling; // 引入 Unity Profiler 内存 API。
public sealed class MemoryAuditSampler : MonoBehaviour // 定义自动化内存采样组件。
{ // 类开始。
[SerializeField] private float intervalSeconds = 5f; // 设置采样间隔。
[SerializeField] private int sampleCount = 120; // 设置采样次数,120 次就是 10 分钟。
private readonly StringBuilder csv = new StringBuilder(4096); // 复用 CSV 构建缓冲区。
private IEnumerator Start() // 启动后自动开始巡检采样。
{ // Start 方法开始。
csv.AppendLine("time,managed_mb,allocated_mb,reserved_mb,unused_mb"); // 写入 CSV 表头。
for (int i = 0; i < sampleCount; i++) // 按固定次数循环采样。
{ // 循环开始。
long managed = GC.GetTotalMemory(false); // 获取托管堆内存。
long allocated = Profiler.GetTotalAllocatedMemoryLong(); // 获取 Unity 已分配内存。
long reserved = Profiler.GetTotalReservedMemoryLong(); // 获取 Unity 保留内存。
long unused = Profiler.GetTotalUnusedReservedMemoryLong(); // 获取保留但未使用内存。
csv.Append(Time.realtimeSinceStartup.ToString("F1")); // 写入当前运行时间。
csv.Append(","); // 写入 CSV 分隔符。
csv.Append(ToMb(managed)); // 写入托管内存 MB。
csv.Append(","); // 写入 CSV 分隔符。
csv.Append(ToMb(allocated)); // 写入已分配内存 MB。
csv.Append(","); // 写入 CSV 分隔符。
csv.Append(ToMb(reserved)); // 写入保留内存 MB。
csv.Append(","); // 写入 CSV 分隔符。
csv.AppendLine(ToMb(unused).ToString()); // 写入未使用保留内存 MB。
yield return new WaitForSecondsRealtime(intervalSeconds); // 等待下一次采样。
} // 循环结束。
string path = Path.Combine(Application.persistentDataPath, "memory_audit.csv"); // 生成巡检报告路径。
File.WriteAllText(path, csv.ToString()); // 把采样结果写入 CSV 文件。
Debug.Log($"Memory audit saved: {path}"); // 输出报告保存位置。
} // Start 方法结束。
private static long ToMb(long bytes) // 定义字节转 MB 的工具方法。
{ // 方法开始。
return bytes / 1024L / 1024L; // 返回 MB 数值。
} // 方法结束。
} // 类结束。面试关键句
IMPORTANT
“自动化内存巡检的价值是把内存问题前置到 CI 和每日构建里。我会固定玩法路径,真机自动跑,用脚本采集内存曲线,再和基线版本做阈值对比。重点看峰值、回落率和重复流程后的增长趋势。发现异常后再用 Memory Profiler Snapshot Diff 查 Texture、Mesh、AudioClip、Managed 对象和引用链。”