Skip to content

Unity / C# 拔高

IL2CPP 相比 Mono 的优势和限制是什么?

unity-il2cpp-vs-mono-advantages-limits

标准回答

MonoIL2CPP 都是 Unity 的脚本后端。区别在于:Mono 更偏运行时执行 IL,开发期迭代和调试方便;IL2CPP 会把 C# 编译出的 IL 转成 C++,再由平台编译器生成本机代码,更适合正式发布,尤其是移动端和 AOT 平台。

底层链路

Mono 大概是:

c
C# -> IL -> Mono Runtime -> JIT/解释执行

IL2CPP 大概是:

c
C# -> IL -> C++ -> Native Binary

所以 IL2CPP 的核心不是“直接把 C# 变机器码”,而是先把 IL 转成 C++,再走平台原生编译器。

IL2CPP 的优势

第一,平台兼容更好。像 iOS 这类不允许运行时 JIT 的平台,更适合 AOT 编译,IL2CPP 就是 Unity 移动端正式包常用方案。

第二,发布性能更稳定。因为很多工作在构建期完成,运行时不需要 JIT,启动和运行时行为更可控。部分场景下 IL2CPP 性能会比 Mono 更好,但不能绝对说所有代码都更快,仍然要看具体逻辑和平台。

第三,代码保护略好。Mono 下 IL 比较容易反编译,IL2CPP 生成 native 后逆向成本更高。但这不是绝对安全,只是提高逆向门槛。

第四,平台原生优化更多。最终由 C++ 编译器生成目标平台机器码,可以利用平台编译链优化。

IL2CPP 的限制

第一,构建更慢。因为多了一步 IL 转 C++ 和 native 编译,正式包构建时间通常明显长于 Mono。

第二,调试更麻烦。Mono 在编辑器和开发期调试更方便,IL2CPP 出问题时堆栈、符号、native 崩溃定位成本更高。

第三,包体可能变大。IL2CPP 会生成 native 代码,加上平台库和符号处理,包体和构建产物可能更大。

第四,AOT 限制明显。运行时动态生成代码、某些反射调用、动态泛型实例化都可能出问题。比如你只通过反射使用某个类型,代码裁剪可能认为它没用,把它裁掉。项目里需要 link.xml[Preserve] 或显式引用来保留类型和方法。

面试重点

NOTE

如果面试官追问“IL2CPP 后还能不能反射”,答案是:能反射,但有限制。反射本身不是完全不能用,问题在于 AOT 和代码裁剪。被裁掉的类型或方法反射不到;没有提前生成的泛型实例,也可能在 AOT 平台出问题。

如果追问“为什么移动端常用 IL2CPP”,可以答:因为移动端正式发布更看重 AOT 平台兼容、运行稳定性、性能和逆向门槛,而开发期可以用 Mono 提高迭代效率。

一句话总结:开发期 Mono 更方便,发布期 IL2CPP 更适合;IL2CPP 的收益是 AOT、native、性能和保护,代价是构建慢、调试难、包体和反射泛型限制。

AOT 平台为什么会有泛型裁剪问题?

unity-aot-generic-stripping

标准答案

AOT 平台会有泛型裁剪问题,核心原因是:AOT 必须在构建期提前生成代码,运行时不能像 JIT 那样临时生成新的泛型实例;而 Unity/IL2CPP 的代码裁剪又依赖静态分析,反射、热更新、配置表、序列化这种动态调用可能分析不到。

所以在 Editor / Mono 下能跑的泛型代码,到了 iOS、IL2CPP、主机平台这类 AOT 环境,可能出现:

  • MissingMethodException
  • ExecutionEngineException
  • 反射创建泛型类型失败
  • 热更新调用泛型方法失败
  • 配置表或序列化运行时才访问的类型找不到

底层原理

泛型本身像一个模板,例如:

c
using UnityEngine.Scripting; // 引入 Preserve,告诉 Unity 尽量保留被标记的代码

public static class AotGenericRef // 定义一个 AOT 泛型保留辅助类
{ // 类开始

    [Preserve] // 防止这个方法被 Unity 裁剪
    public static void Touch() // 主动触达运行时可能用到的泛型实例
    { // 方法开始
        _ = new Box<int>(); // 显式触达 Box<int>,让 AOT 生成 int 版本代码
        _ = new Box<float>(); // 显式触达 Box<float>,让 AOT 生成 float 版本代码
        _ = new Box<string>(); // 显式触达 Box<string>,让 AOT 保留 string 版本使用路径
    } // 方法结束

} // 类结束

public class Box<T> // 定义泛型类型 Box<T>
{ // 类开始
    public T Value; // 保存泛型值
} // 类结束

JIT 平台可以在运行时发现 Box<int> 没有机器码,然后临时编译一份。

AOT 平台不行。它必须在打包时就知道要生成哪些泛型实例。 如果 Box<MyType> 只通过反射、Lua 热更、配置表字符串、JSON 反序列化动态出现,静态分析可能看不到,于是它可能:

  • 没生成对应泛型代码;
  • 没保留方法;
  • 没保留元数据;
  • 被 IL2CPP / Managed Stripping 裁掉。

Unity 工程处理

常见解决方式:

  • 对运行时会用到的泛型做显式引用或 warm-up。
  • 对反射访问的类、方法、字段加 [Preserve]
  • link.xml 保留动态访问的类型和成员。
  • 热更新方案里注意补充 AOT 泛型元数据。
  • 不要只在 Editor / Mono 测试,必须用 IL2CPP 真机包验证。

面试关键词

CAUTION

“泛型裁剪不是泛型语法问题,而是 AOT 代码生成和静态裁剪共同导致的运行时缺代码或缺元数据问题。解决思路是让构建期看见它,并用 Preserve、link.xml 或 AOT 泛型补充机制保住它。”

Unity 的 Managed Heap 和 Native Heap 如何理解?

unity-managed-heap-native-heap

标准答案

Unity 里可以把内存分成两类理解:

Managed Heap 是 C# 托管堆,放 C# 对象,由 GC 管,比如 class 实例、数组、字符串、委托、闭包、装箱对象、List<T> 的内部数组。

Native Heap 是 Unity 引擎原生堆,放 C++ 引擎对象和资源数据,比如 TextureMeshAudioClipAnimationClipGameObject/Component 的 native 部分、渲染和物理相关内存。

底层原理

Unity 里很多对象不是纯 C# 对象。比如:

c
Texture2D tex;

这个 tex 表面上是 C# 引用,但实际通常是:

C# 托管引用 → Managed Wrapper → Native Texture 对象 → 纹理数据 / 显存资源

所以 tex = null 只是让 C# 不再持有这个引用,不等于纹理资源立刻释放。GC 主要回收 managed wrapper,不会直接帮你释放 native texture 的大块内存。

Unity 工程里怎么用

Managed Heap 重点看:

  • 每帧 new
  • 字符串拼接
  • 装箱
  • LINQ
  • 闭包
  • 临时数组
  • 事件未取消订阅
  • 静态引用长期持有对象

Native Heap 重点看:

  • Texture 是否过大
  • Mesh 是否重复加载
  • AudioClip 是否常驻
  • Addressables 是否忘记 Release
  • AssetBundle 是否没卸载
  • Destroy 后资源是否还有引用
  • Resources.UnloadUnusedAssets() 是否必要且时机合适

面试里容易加分的说法

WARNING

“Managed Heap 和 Native Heap 最大区别不是名字,而是生命周期管理方式不同。Managed Heap 由 C# GC 扫描和回收;Native Heap 由 Unity 引擎、资源系统、对象生命周期和引用计数控制。UnityEngine.Object 往往只是 C# 包装层,真正占内存的大资源在 native 侧,所以只看 GC Alloc 不够,还要用 Memory Profiler 看 Native Objects、Texture、Mesh、Audio 等分类。”

常见坑

  • GC.Collect() 只能处理托管堆,不能直接释放贴图、网格、音频。
  • Destroy(gameObject) 不一定马上释放所有资源,通常到帧末处理。
  • Addressables.LoadAssetAsync 后忘记 Addressables.Release,native 资源会常驻。
  • 静态字段、事件、对象池如果一直持有引用,GC 也回收不了。
  • Resources.UnloadUnusedAssets() 可以释放未引用资源,但代价高,可能卡顿,不能随便每帧调用。

Domain Reload 关闭后静态变量有什么坑?

unity-domain-reload-static-pitfalls

标准答案

关闭 Domain Reload 后,最大的坑是:静态变量不会在每次进入 Play Mode 时自动重置

正常情况下,Unity 进入 Play Mode 会重新加载 C# AppDomain,静态字段会重新初始化,静态构造函数会重新执行,静态事件也会被清掉。 但关闭 Domain Reload 后,C# 域不重建,所以上一次运行留下来的 static 数据会继续存在。

底层原理

Domain Reload 本质上是把当前 C# 运行域卸掉,再重新创建一个新的运行域。

开启时:

c
退出 Play -> 卸载旧 AppDomain -> static 清空 -> 重新初始化 -> 干净启动

关闭时:

c
退出 Play -> AppDomain 保留 -> static 保留 -> 下次 Play 继续用旧状态

所以它不是运行时报错那么简单,而是会出现“状态污染”。

常见坑

静态变量残留:

c
public static int Score;

第一次 Play 把 Score 改成 100,退出再进 Play,可能还是 100。

静态事件残留:

c
public static event Action OnDamage;

如果旧对象订阅了静态事件,没有取消订阅,下次 Play 可能重复触发,甚至回调到已经销毁的对象。

单例残留:

c
public static GameManager Instance;

如果 Instance 指向的是场景里的对象,退出 Play 后场景对象可能被销毁,但 Instance 还保留旧引用。下次进 Play 就可能出现假 null、MissingReference、逻辑不执行。

静态缓存残留:

c
public static Dictionary<int, Config> Cache;

缓存、对象池、计时器、任务队列、事件总线、资源引用表都可能保留旧数据,导致测试结果不稳定。

推荐做法

关闭 Domain Reload 后,要自己提供静态重置入口。常用写法是:

c
using UnityEngine; // 引入 Unity 的基础 API

public static class GameRuntimeStatics // 定义一个集中管理静态状态的类
{ // 类开始
    public static int Score; // 保存游戏分数的静态变量

    public static PlayerController CurrentPlayer; // 保存当前玩家引用的静态变量

    public static event System.Action OnGameOver; // 保存游戏结束回调的静态事件

    [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] // 在运行时子系统注册阶段执行,适合重置静态数据
    private static void ResetStatics() // 定义静态重置方法
    { // 方法开始
        Score = 0; // 重置分数,避免上一次 Play 的值残留

        CurrentPlayer = null; // 清空玩家引用,避免指向旧场景对象

        OnGameOver = null; // 清空静态事件,避免重复订阅和旧对象回调
    } // 方法结束
} // 类结束

Unity 工程里怎么答更稳

IMPORTANT

“Domain Reload 关闭是为了加快进入 Play Mode,但代价是 Unity 不再帮我们清理静态状态。项目里如果用了静态单例、事件总线、配置缓存、对象池、资源管理器,就必须在进入 Play 前手动 Reset。否则 Editor 里会出现很隐蔽的脏状态问题,而打包后可能复现不了,排查会很麻烦。”

注意区分

Domain Reload 关闭:影响 C# 静态变量、静态事件、静态构造初始化。

Scene Reload 关闭:影响场景对象是否重新加载。

如果两个都关,问题更明显:不只是静态变量残留,场景对象状态也可能残留。

为什么 Unity API 大多只能在主线程调用?

unity-api-main-thread

标准答案

Unity API 大多只能在主线程调用,核心原因是:Unity 的场景对象、组件生命周期、Transform 层级、渲染提交、物理同步等核心状态都由主线程 Game Loop 统一驱动,UnityEngine.Object 大多不是线程安全的。

如果子线程随便改:

  • transform.position
  • gameObject.SetActive
  • Instantiate
  • Destroy
  • GetComponent
  • UI 更新
  • Animator、Physics、Renderer 相关对象

就可能破坏引擎内部状态一致性,或者直接报“只能在主线程调用”的异常。

底层原理

Unity 主线程每帧大概会做这些事:

输入 -> 脚本 Update -> 动画 -> 物理同步 -> 渲染提交 -> UI 更新

这些系统之间有顺序依赖。比如你改了 Transform,后面渲染、物理、动画、UI 都可能依赖这个结果。

如果多个线程同时改场景树,Unity 要么给大量对象加锁,要么做复杂同步。这样会带来:

  • 锁开销高;
  • 顺序不可控;
  • 容易死锁;
  • 调试困难;
  • 引擎内部状态可能一半新一半旧。

所以 Unity 的设计选择是:主线程负责修改 Unity 世界,子线程负责计算纯数据。

子线程能做什么

子线程适合做:

  • 寻路计算;
  • AI 决策;
  • 网络收包和解包;
  • JSON / protobuf 解析;
  • 文件 IO;
  • 压缩和解压;
  • 资源下载;
  • 纯数学计算;
  • 不访问 UnityEngine.Object 的数据处理。

但子线程算完后,不要直接改 Unity 对象,而是把结果投递回主线程。

典型写法

c
using System; // 引入 Action,用来保存要回主线程执行的任务
using System.Collections.Generic; // 引入 Queue,用来保存任务队列
using UnityEngine; // 引入 MonoBehaviour 和 Unity API
public sealed class MainThreadDispatcher : MonoBehaviour // 定义一个主线程派发器组件
{ // 类开始
    private static readonly Queue<Action> Queue = new Queue<Action>(); // 创建静态任务队列,保存子线程投递的任务
    private static readonly object Locker = new object(); // 创建锁对象,保护多线程访问队列
    public static void Post(Action action) // 提供给子线程调用的投递方法
    { // 方法开始
        if (action == null) return; // 如果任务为空,直接返回,避免空引用
        lock (Locker) // 加锁,避免多个线程同时修改队列
        { // 加锁代码块开始
            Queue.Enqueue(action); // 把任务加入队列,等待主线程执行
        } // 加锁代码块结束
    } // 方法结束
    private void Update() // Unity 主线程每帧调用 Update
    { // 方法开始
        while (true) // 循环取出当前已投递的任务
        { // 循环开始
            Action action; // 声明一个即将执行的任务变量
            lock (Locker) // 加锁,安全访问任务队列
            { // 加锁代码块开始
                if (Queue.Count == 0) break; // 如果队列为空,结束循环
                action = Queue.Dequeue(); // 从队列取出一个任务
            } // 加锁代码块结束
            action.Invoke(); // 在主线程执行任务,此处可以安全调用 Unity API
        } // 循环结束
    } // 方法结束
} // 类结束

Unity 工程里怎么说

实际项目里我会这么处理:

子线程只产出普通数据,比如位置、路径点、伤害结果、配置数据。 主线程在 Update 或专门的调度器里取结果,然后调用 Unity API 更新对象。

比如:

  • 子线程算 A* 路径;
  • 主线程把路径赋给角色移动组件;
  • 子线程解析网络包;
  • 主线程根据消息创建 UI 或更新角色状态;
  • 子线程下载资源;
  • 主线程实例化 Prefab。

Job System 是不是例外

Job System 不是“随便在线程里调 Unity API”。

它是 Unity 提供的受控多线程系统,通常操作的是:

  • NativeArray
  • NativeList
  • IJob
  • IJobParallelFor
  • ECS 数据
  • Burst 可编译的纯数据逻辑

它依然不允许你在 Job 里随便访问普通 GameObjectTransformMonoBehaviour API。它本质是把多线程范围限制在安全的数据容器和调度系统里。

面试关键词

TIP

“Unity API 的主线程限制,本质是引擎对象有主线程亲和性。Unity 为了保证场景树、组件生命周期、渲染、物理和 UI 的状态一致性,没有把大多数 UnityEngine.Object 做成线程安全对象。后台线程可以做纯数据计算,但修改 Unity 世界必须回到主线程。”

Unity 序列化和 C# 原生序列化有什么区别?

unity-serialization-vs-csharp-serialization

标准答案

Unity 序列化和 C# 原生序列化的核心区别是:Unity 序列化是引擎和编辑器系统,用来保存场景、Prefab、ScriptableObject、Inspector 字段;C# 序列化更偏通用数据转换,用来把对象转成 JSON、XML、二进制、网络包或存档数据。

Unity 序列化关心的是:这个对象在场景里怎么还原、Inspector 怎么显示、Prefab 怎么保存引用。 C# 通用序列化关心的是:这个对象怎么变成可存储、可传输、可恢复的数据。

底层区别

Unity 序列化通常是字段驱动

  • public 字段默认可序列化;
  • private 字段加 [SerializeField] 可序列化;
  • 属性 Property 默认不序列化;
  • Dictionary 默认不支持;
  • 委托、事件默认不支持;
  • 多态引用需要 [SerializeReference]
  • UnityEngine.Object 引用保存的是资源引用信息,比如 GUIDfileID,不是把贴图、Prefab、Mesh 整个拷进去。

C# 通用序列化则取决于具体库:

  • System.Text.Json 偏 JSON 数据;
  • XmlSerializer 偏 XML;
  • DataContractSerializer 偏数据契约;
  • protobuf / MessagePack 偏高性能网络或存档;
  • BinaryFormatter 已经过时且不安全,项目里不建议用。

代码例子

c
using UnityEngine; // 引入 UnityEngine,使用 MonoBehaviour 和 SerializeField

[System.Serializable] // 让普通 C# 类可以被 Unity 嵌套序列化
public class ItemData // 定义一个可被 Unity 序列化的普通数据类
{ // 类开始
    public int id; // public 字段默认会被 Unity 序列化并显示在 Inspector
    public string itemName; // public 字符串字段也会被 Unity 序列化
    [SerializeField] private int count; // private 字段加 SerializeField 后也会被 Unity 序列化
    public int Count => count; // 属性默认不会被 Unity 序列化,只是给代码读取用
} // 类结束

public class PlayerBag : MonoBehaviour // 定义一个挂在 GameObject 上的背包组件
{ // 类开始
    [SerializeField] private ItemData startItem; // Unity 会保存这个字段到场景或 Prefab 中
    public int RuntimeGold { get; set; } // Unity 默认不会序列化这个属性
} // 类结束

Unity 工程里怎么理解

如果你在 Inspector 里拖了一个 Prefab、Texture、AudioClip,Unity 保存的不是完整对象数据,而是保存资源引用。下次打开场景或实例化 Prefab 时,Unity 根据这些引用把对象关系还原出来。

所以 Unity 序列化主要服务这些场景:

  • Inspector 显示字段;
  • Scene 保存对象状态;
  • Prefab 保存默认值和引用;
  • ScriptableObject 保存配置;
  • Domain Reload / 热重载时恢复字段;
  • Awake 前恢复序列化数据。

C# 通用序列化更适合:

  • 玩家存档;
  • 网络协议;
  • 配置表;
  • 日志上传;
  • 本地 JSON;
  • 服务端数据交换。

常见坑

  • [Serializable] 不等于一定能显示在 Inspector,还要字段类型和字段本身符合 Unity 规则。
  • Unity 默认序列化字段,不序列化属性。
  • private 字段不加 [SerializeField] 不会显示和保存。
  • Dictionary<TKey, TValue> 默认不能直接被 Unity 序列化。
  • UnityEngine.Object 引用不能当普通 JSON 对象直接序列化。
  • 用 JSON 存档时,不要直接序列化整个 MonoBehaviourGameObject,应该转成纯数据 DTO。

面试回答加分句

NOTE

“Unity 序列化不是普通 C# 对象持久化,它是 Unity 引擎为了编辑器、Prefab、Scene、Asset 和热重载服务的一套字段序列化系统。C# 通用序列化更适合业务数据的保存和传输。项目里我会把 Unity 对象和纯数据对象分开,Unity 对象负责表现和引用,存档、网络、配置则用纯 DTO 来序列化。”

ScriptableObject 做配置有什么优点和风险?

unity-scriptableobject-config-pros-risks

标准答案

CAUTION

ScriptableObject 做配置的优点是:它是 Unity 原生资产,能在 Inspector 里可视化编辑,能被 Prefab、场景、Addressables、AssetBundle 引用,适合做技能、道具、怪物、关卡、音效、行为参数这类编辑器内配置。

但风险是:它不是纯数据表,而是 Unity 资源对象。运行时如果直接修改它,可能污染共享配置;如果大量数据都用它做,也会带来版本管理、热更新、批量校验、查找效率和资源依赖问题。

底层理解

ScriptableObject 本质上继承自 UnityEngine.Object,可以保存成 .asset 文件。 它和 MonoBehaviour 的区别是:

  • MonoBehaviour 要挂在 GameObject 上;
  • ScriptableObject 是独立资源资产;
  • 它可以被多个对象引用;
  • 它适合保存“配置”,不适合保存“运行时状态”。

比如一个技能配置:

c
using UnityEngine; // 引入 UnityEngine,使用 ScriptableObject 和 CreateAssetMenu

[CreateAssetMenu(menuName = "Config/SkillConfig")] // 让 Unity 菜单可以创建这个配置资产
public class SkillConfig : ScriptableObject // 定义一个技能配置 ScriptableObject
{ // 类开始
    public int id; // 技能唯一 ID,用来做索引和查找
    public string skillName; // 技能名称,给编辑器和 UI 使用
    public float cooldown; // 技能冷却时间,属于配置数据
    public int damage; // 技能基础伤害,属于配置数据
    public GameObject effectPrefab; // 技能特效 Prefab 引用,方便美术和策划拖拽
} // 类结束

public class SkillRuntime // 定义运行时技能状态类
{ // 类开始
    public SkillConfig config; // 引用只读的技能配置
    public float remainCooldown; // 保存运行时剩余冷却时间
} // 类结束

重点是:cooldown 是配置,remainCooldown 是运行时状态。 不要把 remainCooldown 写回 SkillConfig,否则多个角色共享同一个技能配置时会互相污染。

优点

可视化编辑:策划或程序可以直接在 Inspector 调参数,不用每次改代码。

能拖资源引用:比如技能特效、图标、音效、Prefab,可以直接在配置里引用。

复用方便:多个角色、多个关卡可以引用同一个配置资产。

和 Unity 资源系统融合好:可以走 Addressables、AssetBundle、Prefab 引用链。

适合小中规模配置:例如角色基础参数、技能配置、Buff 模板、音效表、关卡波次配置。

类型安全:相比字符串表,字段是强类型,改字段名时 IDE 更容易发现问题。

风险

运行时修改污染配置:ScriptableObject 是共享资产,一个对象改了,其他引用者也会看到变化。

编辑器下更危险:Play Mode 里改了 SO 字段,有时可能影响资产本身,造成“怎么下次运行值变了”的问题。

不适合大量数据表:几千上万条配置用大量 .asset 管理,查找、版本 diff、批量修改、导出都不如表格体系清晰。

热更新不如文本或二进制表灵活:如果配置要频繁热更,用 CSV、JSON、二进制表、远端配置更好控制版本。

资源依赖容易变重:SO 里直接引用 Prefab、Texture、Audio,可能把依赖链带进包里,导致包体或内存膨胀。

多人协作冲突:大量字段都在 .asset 里,合并冲突可能不如纯文本表直观。

数据校验要补工具:ID 重复、空引用、数值范围错误,不能只靠人工看 Inspector。

工程建议

配置只读,运行状态单独建类。

编辑器阶段可以用 ScriptableObject 做配置入口,但发布前最好做校验。

如果项目配置量大,可以用 ScriptableObject 做“配置索引”或“资源引用壳”,核心数值仍来自表格。

资源引用要注意 Addressables 分组和依赖分析,避免一个配置把一堆大资源提前加载。

对关键字段写 OnValidate 或编辑器检查工具,防止空引用、非法 ID、重复 ID。

面试加分说法

WARNING

“ScriptableObject 很适合 Unity 内部的编辑器配置,尤其是需要拖资源引用、可视化调参、多人复用的配置。但我不会把它当万能数据表。我的原则是配置和运行状态分离,SO 只保存模板数据,运行时实例数据单独存;如果数据量大或需要热更新,就考虑导出成 JSON、二进制表或配置数据库,再由资源管理系统加载。”

Addressables 的引用计数容易踩什么坑?

unity-addressables-reference-count-pitfalls

标准答案

Addressables 引用计数最容易踩的坑是:以为 Destroy 了对象就等于释放了 Addressables 资源,或者以为 Release 一次就能卸掉所有依赖。

实际上 Addressables 管的是:

  • AsyncOperationHandle
  • Asset 引用计数
  • 依赖 Bundle 引用计数
  • 实例化对象的 Addressables 追踪关系

不是简单看场景里还有没有 GameObject。

底层原理

每次调用:

c
Addressables.LoadAssetAsync<T>(key)

Addressables 会创建一个 AsyncOperationHandle,并让目标资源和依赖资源的引用计数增加。

释放时必须对应调用:

c
Addressables.Release(handle)

当引用计数降到 0 后,Addressables 才有机会释放对应资源和依赖 Bundle。 注意是“有机会释放”,不一定等于内存立刻下降,因为 Unity 原生资源、Bundle、GC、UnloadUnusedAssets 还有自己的释放时机。

最常见的坑

1. Load 了但没有保存 Handle

很多人只保存 handle.Result,不保存 handle。 后面资源不用了,不知道该释放哪个句柄。

正确原则是:谁加载,谁持有 handle,谁负责释放。

2. Load 几次,只 Release 一次

同一个资源被 UI、战斗、预加载系统分别加载三次,引用计数可能是 3。 只 Release 一次,计数还剩 2,资源不会卸载。

3. 重复 Release

同一个 AsyncOperationHandle 释放两次,会造成计数错误、异常或资源状态混乱。 所以资源管理器里通常要做幂等保护。

4. InstantiateAsync 后只 Destroy

如果用:

c
Addressables.InstantiateAsync(key)

释放时应该用:

c
Addressables.ReleaseInstance(instance)

Destroy(instance) 可能销毁了场景对象,但 Addressables 的实例计数没有正确减少。

5. LoadAssetAsync 后手动 Instantiate,提前 Release

如果你先加载 Prefab,再手动实例化:

c
var prefab = handle.Result;
var go = Object.Instantiate(prefab);

这时 Addressables 只知道你加载了 Prefab,不知道你手动克隆出来的实例生命周期。 如果实例还活着,你就把加载 handle Release 了,依赖资源可能被错误卸载或进入不可控状态。

工程上要么:

  • InstantiateAsync / ReleaseInstance
  • 要么自己管理实例数量,所有实例销毁后再 Release 原始 Prefab handle。

6. Addressables 场景不用 Addressables 卸载

如果用 Addressables 加载场景:

c
Addressables.LoadSceneAsync(key)

卸载时应该走:

c
Addressables.UnloadSceneAsync(handle)

不要只用 SceneManager.UnloadSceneAsync,否则 Addressables 的计数链路可能没有正确归还。

7. Release 了但内存没马上下降

这不是一定泄漏。原因可能是:

  • 还有别的资源引用同一个依赖 Bundle;
  • Unity native object 还没真正释放;
  • GC 还没跑;
  • Resources.UnloadUnusedAssets() 还没执行;
  • 内存分配器没有立刻把内存还给系统。

所以排查时不能只看“我 Release 了为什么内存没掉”,要看引用链和 Memory Profiler。

推荐封装方式

c
using UnityEngine; // 引入 Unity 基础 API
using UnityEngine.AddressableAssets; // 引入 Addressables API
using UnityEngine.ResourceManagement.AsyncOperations; // 引入 AsyncOperationHandle 类型

public sealed class AssetHandle<T> where T : Object // 定义一个资源句柄包装类,T 必须是 Unity 对象
{ // 类开始
    private AsyncOperationHandle<T> handle; // 保存 Addressables 返回的句柄
    private bool released; // 记录是否已经释放,防止重复 Release

    public AssetHandle(AsyncOperationHandle<T> handle) // 构造函数接收加载得到的句柄
    { // 构造函数开始
        this.handle = handle; // 保存句柄,后续释放必须用它
        released = false; // 初始状态标记为未释放
    } // 构造函数结束

    public T Asset => handle.Result; // 对外提供资源对象,只读使用

    public void Release() // 释放资源句柄
    { // 方法开始
        if (released) return; // 如果已经释放过,直接返回,保证幂等
        released = true; // 标记为已释放,避免重复释放
        Addressables.Release(handle); // 调用 Addressables.Release 归还引用计数
    } // 方法结束
} // 类结束

面试加分说法

IMPORTANT

“Addressables 的引用计数要按 handle 和依赖链理解,不是按 GameObject 是否存在理解。我的原则是 LoadAssetAsync 对 Release,InstantiateAsync 对 ReleaseInstance,LoadSceneAsync 对 UnloadSceneAsync。资源管理器里要保存 handle,做引用计数和重复释放保护,并用 Memory Profiler 验证依赖是否真的释放。”

Canvas 重建如何沿层级传播?

unity-canvas-rebuild-hierarchy-propagation

标准答案

Canvas 重建不是“子节点一变,整棵 UI 树全部重建”这么简单。UGUI 主要分两条链:

Layout 重建:尺寸、位置、文本内容影响布局时,会从当前节点往父级找布局根,然后重算布局。 Graphic/Batch 重建:图片、文字、颜色、材质、顶点变化时,会把对应 Graphic 放进重建队列,最后影响所在 Canvas 的绘制批次。

所以传播方向可以理解为:

c
子节点变脏 -> 标记 Dirty -> 进入 CanvasUpdateRegistry -> Layout / Graphic 分阶段重建

底层原理

UGUI 里常见 Dirty 有几类:

  • SetLayoutDirty:布局脏,影响 RectTransform 尺寸、位置、LayoutGroup。
  • SetVerticesDirty:顶点脏,影响 Text、Image 网格。
  • SetMaterialDirty:材质脏,影响材质、贴图、渲染状态。

如果一个 Text 内容变了,它可能同时触发:

  • 文字网格重建;
  • preferredWidth / preferredHeight 改变;
  • 父级 LayoutGroup 重新计算;
  • Canvas 重新合批。

Layout 的传播通常是: 从当前 RectTransform 往父级查找,找到需要负责布局的根节点,然后重新计算子节点尺寸和位置。

Graphic 的传播通常是: 当前 Graphic 入队,之后在 Canvas 的 PreRender 阶段更新网格和材质,最后 Canvas 重新构建绘制批次。

为什么会卡

如果一个大 Canvas 里有很多 UI,某个频繁变化的元素导致 Canvas Rebuild,那么同一个 Canvas 下的批次可能被重新计算。

高频问题包括:

  • 每帧改 Text;
  • ScrollView 大量 Item 刷新;
  • 多层 LayoutGroup 嵌套;
  • ContentSizeFitter 和 LayoutGroup 互相驱动;
  • 动态 UI 和静态 UI 放在同一个大 Canvas;
  • 频繁启用禁用 UI 节点;
  • 图片材质或 Mask 变化导致批次重建。

优化思路

把动态 UI 和静态 UI 拆到不同 Canvas。 例如血条、倒计时、滚动列表、弹窗数字变化,尽量放单独 Canvas,避免污染主界面 Canvas。

减少 LayoutGroup 嵌套。 能固定尺寸就固定尺寸,能手动布局就不要层层自动布局。

避免重复设置相同文本。 即使内容一样,反复赋值也可能触发 Dirty。

c
using UnityEngine; // 引入 Unity 基础 API
using UnityEngine.UI; // 引入 UGUI Text 组件

public sealed class SafeScoreText : MonoBehaviour // 定义一个安全刷新分数文本的组件
{ // 类开始
    [SerializeField] private Text scoreText; // 在 Inspector 绑定需要刷新的 Text
    private int lastScore = int.MinValue; // 记录上一次显示的分数,避免重复刷新

    public void SetScore(int score) // 对外提供设置分数的方法
    { // 方法开始
        if (score == lastScore) return; // 如果分数没变,直接返回,避免触发 UI Dirty
        lastScore = score; // 更新缓存的分数
        scoreText.text = score.ToString(); // 只有内容真的变化时才修改 Text
    } // 方法结束
} // 类结束

面试加分说法

TIP

“Canvas 重建要分 Layout 和 Graphic 两条链。Layout 脏会向父级寻找布局根,再向下重新排布;Graphic 脏会进入 CanvasUpdateRegistry,在渲染前更新网格、材质和批次。优化时我不会只说减少 UI,而是会把动态区拆 Canvas、减少自动布局嵌套、避免重复赋值,并用 Profiler 看 Canvas.BuildBatchLayout.RebuildGraphic.Rebuild。”

为什么 Camera.main 不建议频繁调用?

unity-camera-main-cache

标准答案

Camera.main 不建议频繁调用,核心原因是:它不是一个普通字段,而是一个属性访问,会根据 MainCamera 标签查找主相机。

Unity 文档里说明,Camera.main 返回第一个启用且带 MainCamera 标签的 Camera;如果没有符合条件的相机,会返回 null。老版本文档说明它内部使用类似 FindGameObjectsWithTag 且不缓存结果;新版本文档说明 Unity 会缓存带 MainCamera 标签的对象,但访问仍有小 CPU 开销,性能敏感时仍建议缓存引用。

Unity 2019.4Unity 2021.2

为什么不频繁调用

UpdateLateUpdate、循环、UI 坐标转换、射线检测里反复写:

c
Camera.main.WorldToScreenPoint(pos);

问题有几个:

  • 有隐藏查找或缓存访问成本;
  • 每帧多次调用会把小开销放大;
  • 没有 MainCamera 标签时会返回 null
  • 多相机项目里语义不清;
  • 切场景或切相机时,缓存策略要统一管理。

推荐写法

c
using UnityEngine; // 引入 Unity 基础 API

public sealed class CameraUser : MonoBehaviour // 定义一个需要使用相机的组件
{ // 类开始
    [SerializeField] private Camera mainCamera; // 优先在 Inspector 手动绑定相机引用

    private void Awake() // Unity 初始化阶段调用
    { // 方法开始
        if (mainCamera == null) // 如果没有在 Inspector 绑定相机
        { // 判断开始
            mainCamera = Camera.main; // 只获取一次 Camera.main,并缓存起来
        } // 判断结束
    } // 方法结束

    private void LateUpdate() // 每帧后期更新,常用于相机或 UI 跟随
    { // 方法开始
        if (mainCamera == null) return; // 如果相机不存在,直接返回,避免空引用
        Vector3 screenPos = mainCamera.WorldToScreenPoint(transform.position); // 使用缓存相机做坐标转换
    } // 方法结束
} // 类结束

工程回答

NOTE

Camera.main 可以偶尔用,但不应该在热点路径里频繁用。项目里我会优先 Inspector 拖引用,或者在 Awake/Start 缓存。如果项目有多相机、Cinemachine、战斗相机、UI 相机,我会用 CameraManager 统一管理当前主相机,而不是到处调用 Camera.main。”

文章评价

读完这篇,留下你的看法

暂无审核通过的评价。

登录账号后才能评价。

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