Skip to content

内存排查

玩 30 分钟内存持续增长,怎么查?

unity-30min-memory-growth-diagnosis

标准答案

玩 30 分钟内存持续增长,我会先判断它是正常缓存增长还是真正泄漏。正常缓存通常是前几分钟上升,之后趋于稳定;真正泄漏是每隔一段时间继续上涨,切场景、关闭界面、战斗结束后也不回落。

排查顺序是:真机复现 → 记录内存曲线 → Memory Profiler 抓快照 → 对比 Snapshot → 查引用链 → 修复释放逻辑 → 30 分钟回归验证

底层原理

Unity 内存主要分几类:

  • Managed Heap:C# 对象,比如 ListDictionary、字符串、闭包、委托引用。
  • Native Memory:Unity 引擎底层对象,比如 Texture、Mesh、AudioClip、AnimationClip。
  • Asset Memory:资源加载后占用的内存,比如 Addressables、AssetBundle、实例化 Prefab。
  • GPU Memory:贴图、RenderTexture、Mesh Buffer 等显存资源。

GC 只能回收没有引用的托管对象。如果对象还被静态变量、事件、缓存、对象池、单例、闭包引用着,GC 不会回收。Native 资源也不是简单靠 GC 释放,通常要 DestroyAddressables.ReleaseAssetBundle.UnloadResources.UnloadUnusedAssets 配合正确引用生命周期。

怎么查

第一步,看趋势。每隔 1 分钟记录一次 ManagedNative、总内存、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 LoadAssetAsyncInstantiateAsync 后没有对应 ReleaseReleaseInstance,资源会一直被认为有人使用。

第三类是对象池只扩不收。对象池确实能减少 GC,但如果池子没有上限,或者归还时没有清理引用,长时间玩会变成“内存仓库”。

第四类是运行时隐式创建资源。例如频繁访问 renderer.material 会实例化材质;创建 new Texture2Dnew 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-scene-memory-not-drop-diagnosis

标准答案

切场景后内存不下降,我不会先判断成“泄漏”,而是先区分两件事:Unity 保留的内存没有还给系统,和旧场景对象或资源仍然被引用。前者可能是正常缓存或分配器行为;后者才是需要修的泄漏。

正确排查流程是:切场景前抓快照 → 切场景后等一帧 → 执行资源卸载 → 再抓快照 → 对比旧场景对象、Texture、Mesh、Material、Audio、GameObject 是否还存在 → 查引用链

底层原理

Unity 切场景时,会销毁普通场景里的 GameObject,但不代表所有资源立刻释放:

  • Destroy 通常是延迟到帧末执行。
  • DontDestroyOnLoad 标记的对象不会随场景销毁。
  • 被静态变量、单例、事件、对象池、缓存持有的对象不会释放。
  • Addressables 或 AssetBundle 的资源如果引用计数没降到 0,也不会卸载。
  • Resources.UnloadUnusedAssets() 只会卸载“没有任何引用”的资源。
  • Unity 的 Native Allocator 可能保留已申请内存,所以系统看到的总内存不一定马上下降。

所以面试里要强调:不是看 Total Memory 一项,而是看旧场景资源是否还活着,以及它被谁引用。

重点查什么

优先查这几类:

  • DontDestroyOnLoad:跨场景管理器、音乐对象、UIRoot 是否重复创建。
  • 静态变量:static Liststatic Dictionary 是否保存了旧场景对象。
  • 事件订阅:UI、角色、技能对象销毁前是否取消订阅。
  • 对象池:池子是否持有旧场景 Prefab、特效、子弹、UI Item。
  • Addressables:LoadAssetAsync 后是否 ReleaseInstantiateAsync 后是否 ReleaseInstance
  • 隐式资源:频繁访问 renderer.material 生成材质实例,RenderTexture 没释放。
  • AssetBundle:Bundle 卸了不代表已加载 Asset 一定没引用。

排查步骤

  1. 真机或目标平台复现,不只看 Editor。
  2. 切场景前用 Memory Profiler 抓 Snapshot。
  3. 切场景后等一帧,因为 Destroy 不是立刻完成。
  4. 在加载界面或低频时机调用 Resources.UnloadUnusedAssets()
  5. 再抓 Snapshot,做 Diff。
  6. 如果旧场景对象还在,点开引用链,看是静态字段、事件、池子、Addressables handle 还是常驻对象持有。
  7. 修复后反复切场景 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 关闭后贴图还在,怎么查?

unity-ui-close-texture-still-alive-diagnosis

标准答案

UI 关闭后贴图还在,我会先判断:这个 UI 是隐藏了,还是真的销毁并释放资源了SetActive(false) 只是让界面不可见,Image.spriteSpriteTexture2D、加载句柄、对象池缓存仍然可能继续持有贴图,所以贴图不下降不一定是异常。

真正要查的是:这张 Texture2D 现在还被谁引用。

底层原理

UGUI 的引用链通常是:

c
UI Panel -> Image -> Sprite -> Texture2D

只要链上任何对象还活着,贴图就不会被卸载。常见持有者有:

  • UI 没销毁,只是 SetActive(false)
  • UI 被对象池保存,Image.sprite 没清空。
  • 静态缓存保存了 SpriteTexture2D
  • 事件系统引用着 UI,导致 UI 不能释放。
  • Addressables 加载后没有 Release
  • SpriteAtlas 图集仍被其他 Sprite 使用。
  • 公共 UI 图集本来就是常驻资源。
  • TMP 字体图集、公共图标图集被其他界面共用。

所以不能只看“Texture 还在”,要看它是不是应该常驻,以及它的引用链是否合理

怎么查

  1. 用 Memory Profiler 抓关闭 UI 前后的 Snapshot。
  2. 在快照里搜索 Texture2D 或对应贴图名。
  3. 点开贴图,看 References,确认是谁持有它。
  4. 如果引用链指向旧 UI 的 Image,说明 UI 或对象池没清理。
  5. 如果引用链指向 SpriteAtlas,要确认这个图集是不是公共常驻图集。
  6. 如果引用链指向 Addressables handle,要检查是否调用了 Addressables.Release
  7. 如果引用链指向静态字段、事件、单例缓存,就查对应模块的生命周期。
  8. 修复后反复打开关闭 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 卸载后内存没释放,可能为什么?

unity-assetbundle-unload-memory-not-free-causes

标准答案

AssetBundle 卸载后内存没释放,最常见原因是:你卸掉的是 Bundle 本体,不一定卸掉了已经加载出来的 Asset,更不一定销毁了实例化对象。

我会先区分四层东西:

  • AssetBundle:包对象和包内索引、压缩数据。
  • Asset:从包里加载出来的 PrefabTextureMaterialAudioClip
  • Instance:实例化到场景里的 GameObject
  • Dependency:这个 Bundle 依赖的材质包、贴图包、Shader 包。

只卸载其中一层,其他层仍然可能占内存。

底层原理

bundle.Unload(false) 的含义是:卸载 AssetBundle 本体,但已经 Load 出来的资源还活着。所以你会看到 Bundle 卸了,但 Texture2DMeshMaterial 还在内存里。

bundle.Unload(true) 的含义是:连从这个 Bundle 加载出来的 Asset 也一起卸载。但它比较危险,如果场景里还有对象正在用这些资源,可能出现材质丢失、贴图丢失、引用异常,所以项目里一般不会随便对还在使用的资源直接 Unload(true)

另外,即使资源真的没引用了,Unity 的底层分配器也可能保留一部分内存,不一定立刻还给操作系统。所以不能只看系统总内存,要用 Memory Profiler 看对象数量和引用链。

可能原因

  1. 你调用的是 Unload(false),所以 Asset 还在。
  2. Prefab 已经实例化,场景对象还没 Destroy
  3. 资源被静态变量、单例、缓存、对象池持有。
  4. UI、角色、特效还引用着 Texture、Material、Mesh。
  5. Addressables 的 handle 没有 Release
  6. 依赖 Bundle 没卸载,比如主包卸了,贴图包还在。
  7. renderer.material 运行时生成了材质实例,没有清理。
  8. Resources.UnloadUnusedAssets() 没执行,未引用资源还没被扫描卸载。
  9. 异步销毁、异步卸载还没完成,立刻看内存会误判。
  10. 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,怎么优化?

unity-string-gc-optimization

标准答案

大量字符串导致 GC,我会先用 Profiler 定位 GC.Alloc 的调用栈,再优化高频路径里的字符串创建。重点不是“项目里不能用字符串”,而是不要在每帧、每个怪物、每个 UI Item、每条日志里反复创建临时字符串

常见优化方向:

  • 高频 UI 文本只在数值变化时刷新。
  • 避免 Update 里频繁 +、插值 $""string.FormatToString()
  • 多段拼接用 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 内存占用过高,怎么处理?

unity-texture-memory-high-optimization

标准答案

Texture 内存占用过高,我会先做量化,不会一上来就盲目压缩。排查顺序是:找最大纹理和重复纹理 → 算内存 → 看导入设置 → 改尺寸和压缩格式 → 处理 MipMap、Read/Write、图集、分包和卸载策略 → 真机验证画质与内存。

底层原理

纹理内存主要由三个因素决定:尺寸、像素格式、MipMap

例如未压缩 RGBA32

c
2048 * 2048 * 4 byte = 16 MB

如果开启 MipMap,通常还会额外增加约三分之一内存。也就是说一张 2048 的 RGBA32 贴图,可能接近 21 MB。如果项目里有几十张这种贴图,内存会很快爆。

常见高内存原因:

  • 贴图尺寸过大,实际显示很小。
  • 使用 RGBA32ARGB32 这类未压缩格式。
  • UI 贴图开启了不必要的 MipMap
  • Read/Write Enabled 开着,可能保留 CPU 侧副本。
  • 同一贴图被多个 Bundle 重复打包。
  • 图集过大,某个小图标引用导致整张图集常驻。
  • 临时 UI、头像、活动图加载后没有释放引用。
  • 低端机和高端机使用同一套高清资源。
  • RenderTexture 分辨率过高,也会占显存。

Unity 工程处理

我会优先处理收益最大的项:

  1. Memory ProfilerTexture2D 大小排序。
  2. 检查 Max Size,2048 能不能降到 1024 或 512。
  3. 检查平台压缩格式,移动端优先考虑 ASTCETC2PVRTC
  4. UI 贴图通常关闭 MipMap
  5. 运行时不需要读像素的贴图关闭 Read/Write Enabled
  6. 法线贴图使用正确的 Normal Map 导入类型。
  7. 查 Addressables 或 AssetBundle 是否重复打包。
  8. 公共图集和临时图集分开,避免临时资源被公共引用拉住。
  9. 大世界或大场景考虑 Texture Streaming。
  10. 做高中低画质分档,低端机用更小 Max Size。

平台格式选择

  • Android:常用 ASTCETC2
  • 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 内存占用过高,怎么处理?

unity-audioclip-memory-high-optimization

标准答案

AudioClip 内存占用过高,核心处理思路是:先按用途分类,再调整加载类型、压缩格式、采样率、声道和生命周期。短音效、背景音乐、语音不能用同一套导入设置。

我会先用 Memory Profiler 找出最大的 AudioClip,再看它们的 Load TypeCompression FormatQualitySample RateForce To MonoPreload 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 LoadADPCM。如果数量很多,要控制常驻集合,只保留高频基础音效。

背景音乐 BGM: 时间长,不适合全量解压常驻。通常用 StreamingCompressed 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 内存过高,怎么处理?

unity-mesh-memory-high-optimization

标准答案

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 提交效率。

怎么处理

我会按收益从大到小处理:

  1. 用 Memory Profiler 找最大的 MeshSkinnedMeshRenderer
  2. 检查模型顶点数、三角形数是否明显超标。
  3. 美术侧减面,删除不可见面、内部面、重复点。
  4. 远距离模型使用 LOD Group,更远用 Billboard 或 Impostor。
  5. 删除不用的顶点通道,比如不用顶点色就不要 Color,不用法线贴图就不要 Tangent。
  6. 顶点数小于 65535 的 Mesh 尽量用 16 位索引。
  7. 不需要运行时读写 Mesh 的资源关闭 Read/Write Enabled
  8. 谨慎使用 Mesh Compression,它能省内存,但可能带来精度问题。
  9. 角色模型重点查骨骼数量、骨骼权重、BlendShape 数量。
  10. 检查 Static Batching 是否让 Mesh 内存上涨过多。
  11. 大量相同模型优先考虑 GPU Instancing,而不是复制很多 Mesh。
  12. 切场景或资源卸载时确认 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 成本,最后用真机数据验证。”

粒子特效内存过高,怎么处理?

unity-particle-vfx-memory-high-optimization

标准答案

粒子特效内存过高,我会先拆开看:粒子系统本体内存、贴图材质内存、Trail/Collision/SubEmitter 模块内存、对象池常驻内存、资源引用未释放。不能只说“粒子太多”,因为很多时候真正的大头是贴图、材质、Trail、对象池或 Addressables 引用。

底层原理

粒子特效内存主要来自这些部分:

  • ParticleSystem 内部会为粒子数据分配缓冲,Max Particles 越大,上限越高。
  • 每个粒子可能保存位置、速度、颜色、大小、旋转、生命周期等数据。
  • Trail 会额外保存轨迹点。
  • Collision 会增加碰撞相关数据和计算。
  • SubEmitter 可能让一个粒子继续生成更多粒子。
  • 粒子材质用的 TextureShaderMaterial 也会常驻。
  • 对象池虽然减少创建销毁,但池子太大也会让内存长期占着。
  • 如果特效是 Addressables/AssetBundle 加载的,句柄不释放,资源也不会卸。

怎么处理

第一步,用 Memory Profiler 找大户:看 ParticleSystemTexture2DMaterialMeshGameObject 数量和引用链。

第二步,降粒子预算:

  • 降低 Max Particles
  • 降低 Emission Rate
  • 减少 Burst 数量。
  • 缩短 Start Lifetime
  • 减少持续播放的循环特效。
  • 屏外或远距离特效暂停、降级或不播放。

第三步,精简模块:

  • 少用 Trails,轨迹数据很容易膨胀。
  • 少用 Collision,移动端尤其要谨慎。
  • 少用 Lights,粒子灯光非常贵。
  • 控制 Sub Emitters,不要一层套一层无限放大。
  • 不需要的 Custom DataCustom Vertex Streams 不要开。

第四步,优化贴图和材质:

  • 粒子贴图压缩,比如移动端用 ASTC/ETC2。
  • 多个特效复用同一张图集。
  • 减少材质数量,能共享材质就共享。
  • 不要每个特效实例都生成独立材质。
  • 透明特效还要看 Overdraw,虽然这是 GPU 问题,但经常和特效资源问题一起出现。

第五步,控制对象池:

  • 池子必须有上限。
  • 特效播放完要回收。
  • 回收时调用 StopClear
  • 切场景时清理临时特效池。
  • 长时间不用的池要释放,而不是永久常驻。

辅助代码

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 和真机帧时间一起验证。”

如何做自动化内存巡检?

unity-automated-memory-audit

标准答案

自动化内存巡检的核心是:把人工 Profiler 排查流程变成固定脚本、固定场景、固定指标、固定阈值、固定报告。它不是替代人工分析,而是提前发现“内存持续上涨、峰值过高、切场景不回落、资源未释放”这类问题。

怎么设计

我会分成 6 步:

  1. 固定测试路径:登录、进主城、开背包、进战斗、释放技能、切场景、返回主界面。
  2. 自动采集指标:ManagedNativeTextureMeshAudioClipGC.Alloc、总内存、峰值内存。
  3. 做基线对比:当前版本和上一个稳定版本比。
  4. 设置阈值:比如内存峰值超过 10%,或切场景后不回落超过 50MB。
  5. 生成报告:CSV、HTML、曲线图、截图、设备信息、版本号。
  6. 接入 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 对象和引用链。”

文章评价

读完这篇,留下你的看法

暂无审核通过的评价。

登录账号后才能评价。

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