Appearance
Unity / C# 拔高
IL2CPP 相比 Mono 的优势和限制是什么?
标准回答
Mono 和 IL2CPP 都是 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 平台为什么会有泛型裁剪问题?
标准答案
AOT 平台会有泛型裁剪问题,核心原因是:AOT 必须在构建期提前生成代码,运行时不能像 JIT 那样临时生成新的泛型实例;而 Unity/IL2CPP 的代码裁剪又依赖静态分析,反射、热更新、配置表、序列化这种动态调用可能分析不到。
所以在 Editor / Mono 下能跑的泛型代码,到了 iOS、IL2CPP、主机平台这类 AOT 环境,可能出现:
MissingMethodExceptionExecutionEngineException- 反射创建泛型类型失败
- 热更新调用泛型方法失败
- 配置表或序列化运行时才访问的类型找不到
底层原理
泛型本身像一个模板,例如:
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 是 C# 托管堆,放 C# 对象,由 GC 管,比如 class 实例、数组、字符串、委托、闭包、装箱对象、List<T> 的内部数组。
Native Heap 是 Unity 引擎原生堆,放 C++ 引擎对象和资源数据,比如 Texture、Mesh、AudioClip、AnimationClip、GameObject/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 关闭后静态变量有什么坑?
标准答案
关闭 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 大多只能在主线程调用,核心原因是:Unity 的场景对象、组件生命周期、Transform 层级、渲染提交、物理同步等核心状态都由主线程 Game Loop 统一驱动,UnityEngine.Object 大多不是线程安全的。
如果子线程随便改:
transform.positiongameObject.SetActiveInstantiateDestroyGetComponent- 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 提供的受控多线程系统,通常操作的是:
NativeArrayNativeListIJobIJobParallelFor- ECS 数据
- Burst 可编译的纯数据逻辑
它依然不允许你在 Job 里随便访问普通 GameObject、Transform、MonoBehaviour API。它本质是把多线程范围限制在安全的数据容器和调度系统里。
面试关键词
TIP
“Unity API 的主线程限制,本质是引擎对象有主线程亲和性。Unity 为了保证场景树、组件生命周期、渲染、物理和 UI 的状态一致性,没有把大多数 UnityEngine.Object 做成线程安全对象。后台线程可以做纯数据计算,但修改 Unity 世界必须回到主线程。”
Unity 序列化和 C# 原生序列化有什么区别?
标准答案
Unity 序列化和 C# 原生序列化的核心区别是:Unity 序列化是引擎和编辑器系统,用来保存场景、Prefab、ScriptableObject、Inspector 字段;C# 序列化更偏通用数据转换,用来把对象转成 JSON、XML、二进制、网络包或存档数据。
Unity 序列化关心的是:这个对象在场景里怎么还原、Inspector 怎么显示、Prefab 怎么保存引用。 C# 通用序列化关心的是:这个对象怎么变成可存储、可传输、可恢复的数据。
底层区别
Unity 序列化通常是字段驱动:
public字段默认可序列化;private字段加[SerializeField]可序列化;- 属性
Property默认不序列化; Dictionary默认不支持;- 委托、事件默认不支持;
- 多态引用需要
[SerializeReference]; UnityEngine.Object引用保存的是资源引用信息,比如GUID、fileID,不是把贴图、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 存档时,不要直接序列化整个
MonoBehaviour或GameObject,应该转成纯数据 DTO。
面试回答加分句
NOTE
“Unity 序列化不是普通 C# 对象持久化,它是 Unity 引擎为了编辑器、Prefab、Scene、Asset 和热重载服务的一套字段序列化系统。C# 通用序列化更适合业务数据的保存和传输。项目里我会把 Unity 对象和纯数据对象分开,Unity 对象负责表现和引用,存档、网络、配置则用纯 DTO 来序列化。”
ScriptableObject 做配置有什么优点和风险?
标准答案
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 的引用计数容易踩什么坑?
标准答案
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 重建如何沿层级传播?
标准答案
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.BuildBatch、Layout.Rebuild、Graphic.Rebuild。”
为什么 Camera.main 不建议频繁调用?
标准答案
Camera.main 不建议频繁调用,核心原因是:它不是一个普通字段,而是一个属性访问,会根据 MainCamera 标签查找主相机。
Unity 文档里说明,Camera.main 返回第一个启用且带 MainCamera 标签的 Camera;如果没有符合条件的相机,会返回 null。老版本文档说明它内部使用类似 FindGameObjectsWithTag 且不缓存结果;新版本文档说明 Unity 会缓存带 MainCamera 标签的对象,但访问仍有小 CPU 开销,性能敏感时仍建议缓存引用。
为什么不频繁调用
在 Update、LateUpdate、循环、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。”