Unity 面试题
共 61 题,按主题分组。点开看答案,自己标掌握度 —— 进度存在你自己的浏览器里,不上传、不需要登录。
这里的答案是怎么来的(以及哪些没收录)
每一条里的数字、异常消息、行为差异,都是在本机 Unity 2022.3.62f2c1 上 跑出来的 —— EditMode 探针或 PlayMode 测试,不是凭记忆写的。 所以你会看到「实测 xx ms」「抛 UnityException: … can only be called from the main thread.」 这种具体到消息原文的描述。
写的过程中被自己的测量推翻过好几次,比如Camera.main 早就不再按 tag 查找、单个 IJob 比主线程直接跑还慢。 这些都是先测出来、再回头改结论的。
没有收录的:IL2CPP 与真机打包(AOT 泛型、代码剥离、link.xml 这些)。 2026-08-31 补了 macOS IL2CPP 的打包与运行环境,Mono / IL2CPP × 剥离开关 四种组合都跑起来了,也确认剥离确实生效(UnityLinker 步骤从 5 处涨到 376 处、 包体小 5 MB)。但五轮探针、约十种触发路径下,IL2CPP 与 Mono 的输出逐字一致, 一次差异都没测出来。
而「测不出差异」不等于「没有差异」:我拿不出一个能复现经典 AOT 失效的正对照, 就无法区分「这些问题在 2022.3 / macOS 上确实不出现」和「探针没打中」。 ⭐ 所以既不写「有这些坑」(没测到),也不写「没有这些坑」(证据不足)—— 这一块继续空着。变化的是理由:从「没有验证条件」变成「有条件、试了五轮、还没做出有检出能力的探针」。
⚠️ 另外:所有数字都取自编辑器。打包后(尤其 IL2CPP)绝对值会不同, 可靠的是数量级关系和方向,不是具体的纳秒数。
生命周期与执行顺序
回调顺序不是「看起来那样」。这一章每条结论都在 2022.3 里跑过——包括「未激活对象的 Awake 根本不执行」和「一帧里 FixedUpdate 可以跑 6 次」。
基础Awake、OnEnable、Start 的调用时机有什么区别?为什么在 Awake 里拿别的对象的引用可能拿到 null?
Awake 在对象实例化后立刻调用,OnEnable 在对象每次被启用时调用(可能多次),Start 在第一次 Update 之前调用一次。
关键在于跨对象的顺序。2022.3 实测,三个对象同时激活后同一帧内的回调序列:
Awake:o1 OnEnable:o1 Awake:o2 OnEnable:o2 Awake:o3 OnEnable:o3 Start:o1 Start:o2 Start:o3
⭐ 两条都要看清:
1. 所有对象的 Awake 都跑完,才开始跑第一个 Start。 所以在自己的 Awake 里访问另一个对象在它自己 Awake 里才赋值的字段,就可能读到 null——那个对象的 Awake 还没轮到。 2. OnEnable 紧跟在「自己那个」Awake 后面,不是等所有 Awake 都跑完。常见的误解是把 OnEnable 也想成分批的。
结论:自身的初始化放 Awake,依赖别人的初始化放 Start。
OnEnable 会随 SetActive 反复触发,所以事件注册放 OnEnable、注销放 OnDisable 才配对。写在 Awake/OnDestroy 里,对象被禁用再启用就会漏注册。
进阶一个 GameObject 处于未激活状态,它挂的脚本的 Awake 会执行吗?
不会。一次都不会。
2022.3 实测:创建一个 SetActive(false) 的对象并挂上脚本,跑过整帧后回调记录是空的——Awake、OnEnable、Start 一个都没有。直到调用 SetActive(true) 的那一刻,三个才依次补上:
保持未激活,跑过一帧 → [] ← 什么都没有 SetActive(true) 之后 → Awake → OnEnable → Start → Update
⭐ 所以 Awake 的语义不是「对象创建时」,而是「对象第一次变为激活时」。
这直接影响一类常见写法:把对象做成 prefab、以未激活状态放在场景里当对象池,然后指望它们的 Awake 已经跑过、字段已经初始化好了——没有。它们的初始化会在你第一次 SetActive(true) 时才发生,而那时可能已经在游戏中途了。
var go = new GameObject("pooled");
go.SetActive(false);
go.AddComponent<MyScript>(); // Awake 不会执行
// …若干帧之后…
go.SetActive(true); // 这一刻才 Awake → OnEnable → Start🚨 反过来也要记:Awake 只执行一次,OnEnable 每次启用都执行。所以对象池取出/放回时,只有 OnEnable/OnDisable 会反复触发,Awake 里的一次性初始化不会重来——把「每次取出都该重置的状态」写在 Awake 里,第二次取出就带着上次的残留。
基础Update、FixedUpdate、LateUpdate 分别在什么时候调用?为什么物理相关的逻辑要放 FixedUpdate,相机跟随要放 LateUpdate?
Update 每帧调用一次,间隔随帧率浮动。FixedUpdate 按固定时间步长调用(默认 0.02 秒),一帧内可能调用零次、一次或多次。LateUpdate 在所有 Update 执行完之后调用。
「零次或多次」不是理论上的可能,是常态。2022.3 实测两个极端:
帧极快(batchmode,1 秒跑了 82566 帧) → 1 秒内 FixedUpdate 只有 50 次;82516 帧是 0 次,50 帧是 1 次
帧极慢(人为把每帧拖到 ~120ms) → 每帧 FixedUpdate 跑 6 次(120ms ÷ 20ms = 6)
⭐ 结论很干净:FixedUpdate 的总次数由墙钟时间决定,与帧率无关;帧率只决定它们怎么分摊到各帧里。1 秒就是 50 次,跑 8 万帧也是 50 次。
物理放 FixedUpdate:物理引擎按固定步长积分,在 Update 里改 Rigidbody 会因为帧率波动导致力的效果不一致,帧率越高推得越远。
相机跟随放 LateUpdate:要等目标这一帧的移动全部算完再更新相机,否则相机用的是上一帧的位置,画面会抖。
void FixedUpdate() {
// 用 AddForce / MovePosition,而不是直接改 transform
rb.AddForce(dir * force, ForceMode.Force);
}
void LateUpdate() {
// 目标已经移动完毕,这时候跟随才不会滞后一帧
transform.position = target.position + offset;
}⚠️ 一帧内的 FixedUpdate 次数有上限,来自 Time.maximumDeltaTime(实测默认 0.3333333)。卡顿超过这个值时,Unity 会丢弃多出来的物理时间而不是全部补上——这就是「卡一下之后物理表现对不上」的来源。它是刻意的:不这么做的话一次长卡顿会引发追赶式的连锁模拟,把帧拖得更长。
进阶在 FixedUpdate 里读 Time.deltaTime,拿到的是什么?
拿到的是 Time.fixedDeltaTime,不是这一帧的实际间隔。
2022.3 实测:
Time.fixedDeltaTime = 0.02 FixedUpdate 里的 Time.deltaTime = 0.02 ← 相等 Update 里的 Time.deltaTime = 0.000278… ← 这一帧的真实间隔
⭐ 也就是说 Time.deltaTime 是上下文相关的:它返回「当前这个更新阶段的步长」。所以在 FixedUpdate 里写 Time.deltaTime 是对的,不必特意换成 Time.fixedDeltaTime,两者同值。
真正要小心的是反过来:在 Update 里用 Time.fixedDeltaTime 做积分是错的,那会把一个固定值当成可变的帧间隔用。
📌 面试里这条常被问成「Time.deltaTime 和 Time.fixedDeltaTime 有什么区别」。能答出「在 FixedUpdate 里它俩相等」比背定义更能说明你真写过。
深入在 Awake 里 Instantiate 一个对象,新对象的 Awake 什么时候执行?
同步地、就地执行——嵌套在你当前这个 Awake 的中间。
2022.3 实测,在 spawner 的 Awake 里 Instantiate 一个激活对象,回调序列是:
Awake:spawner-进入 Awake:child ← 夹在中间 OnEnable:child Awake:spawner-离开
⭐ 也就是说 Instantiate 不是「排进队列、下一帧再初始化」,而是当场把整套 Awake/OnEnable 跑完才返回。
两个后果:
1. 在 Awake 里 Instantiate 会让初始化链递归加深。如果新对象的 Awake 里又 Instantiate,栈会一路叠下去,而这段时间里你自己的 Awake 还没执行完、字段还是半初始化状态。 2. 新对象的 Awake 里如果回头访问 spawner,拿到的是尚未完成初始化的 spawner——它的 Awake 才跑到一半。
void Awake() {
field = 1;
var go = Instantiate(prefab); // ← child 的 Awake 在这一行内部同步跑完
field = 2; // child 的 Awake 看到的 field 是 1,不是 2
}🚨 这就是「在 Awake 里生成对象」很容易出诡异 bug 的原因:出问题时的现象是「字段值不对」,而不是「顺序不对」,很难联想到是嵌套初始化。要生成对象,放 Start 比放 Awake 安全得多。
进阶同一帧里多个对象的 Update 谁先执行?这个顺序可靠吗?
默认不可靠。Unity 不保证不同脚本之间 Update 的先后,实际顺序与对象的加载顺序等内部因素相关,换个场景、改个层级就可能变。
需要确定顺序时有三条路:一是 Project Settings 里的 Script Execution Order 显式指定;二是把有依赖关系的逻辑合并到同一个脚本里按语句顺序执行;三是改用事件驱动,由上游主动通知下游,而不是靠「猜它已经跑完了」。
靠顺序巧合跑通的代码是定时炸弹:它在开发机上一直是对的,直到某次场景重排或打包后才错,而且错得没有任何报错。
进阶为什么不建议在 OnDestroy 里访问其它对象?
因为销毁顺序同样不确定。场景卸载或退出游戏时,你引用的那个对象可能已经先被销毁,此时访问它会抛 MissingReferenceException,或者拿到一个「非空但已失效」的引用。
更稳的做法是让下游在 OnDisable/OnDestroy 里只做「注销自己」这类不依赖别人的事,跨对象的清理由明确的管理者按自己的顺序驱动。
⚠️ 「非空但已失效」是 Unity 独有的伪 null,判空要用 == null 而不是 is null——细节见「伪 null 与对象生命周期」那一章。
协程
协程不是线程,是编译器生成的迭代器状态机。坑集中在「它什么时候会停」——而停的方式有好几种,其中一种连 finally 都不执行。
基础协程是多线程吗?它到底是什么?
不是。 协程完全跑在主线程上,只是把一个方法切成若干段,分到不同帧执行。
它的本体就是 C# 的迭代器。2022.3 实测:一个 IEnumerator 协程方法,返回值的实际类型是编译器生成的 <Simple>d__4,基类是 System.Object —— 和 Unity 一点关系都没有,是纯 C# 的状态机。
Unity 做的事只有一件:每帧对这些迭代器调一次 MoveNext(),并根据 Current 的值决定下次什么时候再调。
所以协程不能用来做耗时计算。在协程里写一个跑 5 秒的循环,主线程一样卡 5 秒 —— 它只帮你分帧,不帮你并行。真要并行得用 Job System 或 Task。
🚨 由此推出一个高频事故:只调用协程方法而忘了 StartCoroutine,方法体一行都不会执行。实测调用后 MoveNext 之前,日志里一条记录都没有。而且不会报错、不会警告 —— 看起来就像那段逻辑「没生效」。
进阶yield return null、WaitForSeconds、WaitForSecondsRealtime 有什么区别?
yield return null 就是字面意义的「Current 为 null」,Unity 约定它表示等一帧。
另外两个的基类不一样,这决定了它们走的是两套机制。2022.3 实测:
WaitForSeconds : YieldInstruction ← 引擎内部处理 WaitForSecondsRealtime : CustomYieldInstruction ← 托管层轮询 keepWaiting WaitForFixedUpdate : YieldInstruction WaitForEndOfFrame : YieldInstruction
最要紧的区别是受不受 timeScale 影响。PlayMode 实测,把 Time.timeScale 设为 0:
WaitForSeconds(0.1) → 1 秒实时内都没完成,被冻住 WaitForSecondsRealtime(0.1) → 正常完成
所以暂停菜单里的动画、倒计时这类东西必须用 Realtime 版本,否则游戏一暂停它们就一起停了。
// 游戏内逻辑(希望跟着暂停)
yield return new WaitForSeconds(1f);
// 暂停菜单里的 UI 动画(不该被暂停影响)
yield return new WaitForSecondsRealtime(1f);📌 WaitForSeconds 是普通对象,每次 new 都会分配。固定间隔的循环里可以提到循环外复用 —— PlayMode 实测同一个 WaitForSeconds(0.05f) 实例连续 yield 三次,完成时刻是 0.10 / 0.15 / 0.20 秒,间隔正确,复用没有副作用。
var wait = new WaitForSeconds(0.5f);
while (true) { yield return wait; Tick(); }深入StopCoroutine 之后,协程里 try/finally 的 finally 块会执行吗?
不会。 这是协程最容易造成资源泄漏的地方。
PlayMode 实测:协程执行到第二个 yield 之后调用 StopCoroutine,finally 里的日志没有出现。销毁宿主 GameObject 同样不执行 finally。
正常跑完 → body | body2 | body3 | FINALLY StopCoroutine → body | body2 ← finally 缺席 销毁宿主对象 → body | body2 ← 同上
原因:协程被停止时,Unity 只是不再对那个迭代器调 MoveNext(),迭代器就永远停在 yield 那一行。而 C# 的 finally 要靠正常执行完或者显式调用 Dispose() 才会触发。
(对照:EditMode 里手动 MoveNext() 一次后调 (iter as IDisposable).Dispose(),finally 确实会跑 —— 说明机制上是可以触发的,只是 StopCoroutine 没走这条路。)
结论:别指望用 finally 做协程的清理。要清理就在停止协程的那个地方显式做。
// ❌ 以为 finally 一定会跑
IEnumerator Download() {
var handle = Acquire();
try {
yield return request.SendWebRequest();
Use(handle);
} finally {
Release(handle); // StopCoroutine 时不会执行 → 泄漏
}
}
// ✅ 谁停它,谁负责清理
void Cancel() {
StopCoroutine(running);
Release(handle);
}🚨 同一个陷阱在「场景切换时统一 StopAllCoroutines」的写法里最常见:以为把协程停掉就干净了,其实所有 finally 里的解锁、归还对象池、关闭句柄全都被跳过。
进阶把宿主 GameObject 设为 SetActive(false),和把脚本组件 enabled = false,协程分别会怎样?
两者行为完全不同,这是最容易记反的一组。PlayMode 实测:
gameObject.SetActive(false) → 协程立即停止 再 SetActive(true) → ❌ 不会恢复,协程已被永久终止 component.enabled = false → ✅ 协程继续跑,直到跑完
记忆点:enabled 只控制 Update 这类消息回调,管不到协程;而 SetActive(false) 是把整个对象从活动状态里摘出去,协程随之被销毁 —— 注意是销毁不是暂停,重新激活也回不来。
所以想暂时关掉一个脚本又不影响它的协程,用 enabled = false;想彻底停掉,用 SetActive(false) 或显式 StopCoroutine。
⚠️ 「SetActive(false) 再 SetActive(true) 协程就没了」这个行为经常伪装成别的 bug:对象池里回收(SetActive false)再取出(true)的物体,身上的循环协程全部失效,表现是「第二次拿出来的敌人不会动了」。对象池的 OnEnable 里重新 StartCoroutine 是标准做法。
基础在一个未激活的 GameObject 上调用 StartCoroutine 会发生什么?
不抛异常,只在控制台记一条 error,协程静默地不启动。 2022.3 实测,Unity 打出的原话是:
Coroutine couldn't be started because the the game object 'host' is inactive!
(「the the」是 Unity 自己的笔误,一直没改。)
这个组合很常见:Instantiate 出一个默认未激活的 prefab,在 Awake 里就想启动协程 —— 对象还没激活,协程直接被丢弃,而代码看起来完全正常。
稳妥的做法是把 StartCoroutine 放在 OnEnable 里:它保证在对象已激活时才被调用,而且对象池反复启停时也能自动重新启动(见上一题)。
// ⚠️ 对象可能还没激活
void Awake() { StartCoroutine(Tick()); }
// ✅ OnEnable 一定在已激活状态下调用,且每次重新激活都会再跑
void OnEnable() { StartCoroutine(Tick()); }
void OnDisable() { StopAllCoroutines(); } // 配对,避免重复启动📌 OnEnable 里 Start、OnDisable 里 Stop 是配对写法。只 Start 不 Stop 的话,一个对象反复启停 N 次就会有 N 个同样的协程在跑 —— 表现是「越用越快」「伤害翻倍」这类越玩越离谱的 bug。
深入StopCoroutine 传方法名字符串、传 IEnumerator、传 Coroutine 句柄,效果一样吗?
不一样,而且传错了会静默失败。
- 传
Coroutine句柄(StartCoroutine的返回值):最精确,只停这一个。 - 传字符串方法名:停掉所有用同名字符串启动的协程;但只对用
StartCoroutine("Name")字符串形式启动的有效。 - 传
IEnumerator引用:必须是当初传给StartCoroutine的同一个对象引用。
最常见的错法是:用 StartCoroutine(Tick()) 启动,然后用 StopCoroutine(Tick()) 停止 —— 两次调用 Tick() 生成的是两个不同的迭代器对象,第二个从来没被启动过,于是什么都没停掉,也没有任何报错。
PlayMode 实测这五种组合:
StopCoroutine(句柄) → 停掉了
StopCoroutine(同一个 IEnumerator 引用) → 停掉了
StartCoroutine(Tick()) + StopCoroutine(Tick()) → 🚨 没停掉,一路跑完
StartCoroutine(Tick()) + StopCoroutine("Tick") → 🚨 没停掉
StartCoroutine("Tick") + StopCoroutine("Tick") → 停掉了注意第四行:字符串形式的 Stop 对非字符串启动的协程无效,反之亦然。两种启动方式不能混着停。
// ❌ 两个 Tick() 是两个不同的迭代器,停不掉
StartCoroutine(Tick());
StopCoroutine(Tick());
// ✅ 存句柄
private Coroutine running;
running = StartCoroutine(Tick());
if (running != null) { StopCoroutine(running); running = null; }⚠️ 字符串形式 StartCoroutine("Tick") 内部靠反射,比直接传 IEnumerator 慢,而且方法改名时编译期不会报错。除非确实需要按名字批量停止,否则一律用句柄。
多线程与 async/await
Unity 的 API 几乎全部只能在主线程调用,但哪些属于「几乎」之外并不直观——Debug.Log 可以,Time.deltaTime 不行。async/await 在 Unity 里还有个和纯 C# 不同的行为。
进阶在后台线程里调用 Unity API 会发生什么?哪些 API 是例外?
抛 UnityException,消息格式统一是 「某某 can only be called from the main thread.」。PlayMode 实测,逐个试的结果:
✅ 能用(纯托管代码,不碰引擎)
Vector3 / Quaternion 的数学运算
Mathf.Sqrt 之类
Debug.Log ← 唯一一个「看着像引擎 API 却能用」的❌ 抛 UnityException
读或写 transform.position get_transform can only be called from the main thread.
读 gameObject.name GetName ...
new GameObject Internal_CreateGameObject ...
GetComponent GetComponentFastPath ...
Time.deltaTime get_deltaTime ...
Application.isPlaying get_isPlaying ...
UnityEngine.Random.value get_value ...⭐ 分界线是「它需不需要访问引擎的原生对象或引擎状态」,而不是「它看起来复不复杂」。
最容易误判的就是最后三个:Time.deltaTime、Application.isPlaying、Random.value 看上去只是读个字段,其实都要过引擎。
Debug.Log 能用是因为 Unity 专门给它做了线程安全处理 —— 这也让它成为后台线程里唯一能用的调试手段。
🚨 UnityEngine.Random 不能跨线程,但 System.Random 可以(它是纯托管的)。后台线程里要随机数就用后者,并且每个线程一个实例 —— System.Random 本身不是线程安全的。
深入在 Unity 里 await 一个 Task 之后,代码回到哪个线程?和纯 C# 控制台程序一样吗?
回到主线程 —— 这一点和默认的控制台程序不一样。
PlayMode 实测:
async 方法开头 → 主线程 await Task.Delay(50) 之后 → 主线程 ✅ 可以直接调 Unity API await Task.Run(...) 之后 → 主线程 ✅ SynchronizationContext.Current → UnitySynchronizationContext
原因就是最后那一行:Unity 装了自己的 SynchronizationContext,await 默认会把后续代码 post 回它,也就是回到主线程的消息循环。控制台程序里 SynchronizationContext.Current 是 null,续体就跑在线程池线程上 —— 同一段代码在两种环境下行为不同。
所以在 Unity 里写 await 之后直接操作 Transform 是安全的。但要清楚这个安全来自同步上下文,不是来自语言。
async Task Load() {
var data = await Task.Run(() => ParseBigFile()); // 这段在后台线程
transform.position = data.spawnPoint; // ✅ 已经回到主线程
}⚠️ 一旦写了 .ConfigureAwait(false),就放弃了回到主线程这件事 —— 续体会留在线程池线程上,后面再碰 Unity API 就抛异常。库代码里 ConfigureAwait(false) 是推荐写法,搬到 Unity 业务代码里就是个陷阱。
进阶Task.Run 里能调用 Unity API 吗?后台算完之后怎么把结果交回主线程?
不能。 实测 Task.Run(() => new GameObject(...)) 抛
UnityException: Internal_CreateGameObject can only be called from the main thread.
正确的分工是:后台只做纯计算,结果通过 await 带回主线程再用。因为 await 之后自动回到主线程(见上一题),这个模式写起来很自然。
如果不用 async/await(比如在协程里),就得自己把结果放进一个字段,主线程轮询它。
// ✅ 推荐:后台算,await 回来后再碰引擎
async Task Build() {
var mesh = await Task.Run(() => GenerateMeshData()); // 纯数据
GetComponent<MeshFilter>().mesh = ToMesh(mesh); // 回到主线程了
}
// ✅ 协程版:自己轮询
IEnumerator BuildCo() {
MeshData data = null;
var t = Task.Run(() => { data = GenerateMeshData(); });
while (!t.IsCompleted) yield return null;
if (t.Exception != null) { Debug.LogException(t.Exception); yield break; }
GetComponent<MeshFilter>().mesh = ToMesh(data);
}🚨 协程版里那行 if (t.Exception != null) 不能省。实测确认:Task 里抛的异常不去看就不会冒到日志,它安静地留在 Task 对象里,表现是「后台算的东西没生效,也没报错」。
深入async void 有什么问题?为什么说它的异常「消失了」?
async void 的方法没有返回 Task,调用方拿不到任何句柄 —— 既不能 await 它,也接不到它的异常。
PlayMode 实测:在 try { Boom(); } catch { ... } 里调用一个会在 await 之后抛异常的 async void 方法,catch 块没有进入。因为 Boom() 在第一个 await 处就返回了,异常发生在之后的续体里,那时候 try 块早就出栈了。
那异常去哪了?用 LogAssert 精确验证的结果是:它最终会以「未处理异常」的形式出现在 Unity 日志里,但时机不受你控制 —— 实测等了 60 帧,测试用例内部的断言窗口都没等到,它是在用例结束之后才冒出来的。
对照 async Task 版本,行为完全不同:
async void → 调用处 catch 不到;异常延迟冒到全局日志
async Task → t.IsFaulted = True,t.Exception.InnerException 拿得到
但**不去看它就不会冒到日志**,异常安静地留在 Task 里
对已 faulted 的 Task 取结果(GetAwaiter().GetResult())才会抛出来⭐ 所以两者是两种不同的失败方式:async void 是异常跑到你接不住的地方,
async Task 是异常被 Task 攥着、你不问就不说。两种都会让一段逻辑安静地半途而废。
唯一该用 async void 的场合是事件处理器(签名要求 void),而且那时也必须在方法内部自己 try/catch 兜住全部异常。
// ❌ 异常无处可去
async void Start() { await LoadAsync(); }
// ✅ 返回 Task,让调用方能 await、能接异常
async Task StartAsync() { await LoadAsync(); }
// ✅ 万不得已用 async void(事件回调),自己兜底
async void OnButtonClick() {
try { await LoadAsync(); }
catch (Exception e) { Debug.LogException(e); }
}⚠️ 还有一个 Unity 特有的问题:Task 不随播放模式停止而取消。退出播放模式后,还在跑的 async 方法可能继续执行并访问已销毁的对象。要么自己带 CancellationToken 在 OnDestroy 里取消,要么用 UniTask 这类专为 Unity 做了生命周期绑定的库。
基础协程能用来做耗时计算吗?在协程里 Thread.Sleep 会怎样?
不能,协程完全在主线程上。 PlayMode 实测:协程里写 Thread.Sleep(200),主线程实实在在卡了 202 ms,期间帧数只前进了 1 帧。
这直观地说明了协程和线程的区别:协程只是把方法切成几段分到不同帧,每一段仍然是主线程在跑。段内做多少事,主线程就卡多久。
所以:
- 要分帧、要按时间等待 → 协程(
yield return null/WaitForSeconds) - 要真正并行的计算 →
Task.Run、Job System、或自己开线程 - 协程里绝对不要写
Thread.Sleep—— 想等就yield return new WaitForSeconds(...),它是让出而不是阻塞
如果一段计算本身很重又必须在主线程(比如要碰 Transform),只能自己拆:每处理 N 个就 yield return null 让出一帧。
// ❌ 卡死主线程
IEnumerator Bad() { Thread.Sleep(200); yield return null; }
// ✅ 让出,不阻塞
IEnumerator Good() { yield return new WaitForSeconds(0.2f); }
// ✅ 重计算必须在主线程时,自己分帧
IEnumerator Process(List<Item> items) {
for (int i = 0; i < items.Count; i++) {
Handle(items[i]);
if (i % 100 == 99) yield return null; // 每 100 个让出一帧
}
}📌 「每 N 个让出一帧」里的 N 要按耗时定而不是拍脑袋。目标是单帧新增耗时控制在预算内(60fps 下整帧只有 16.7 ms),拿 Profiler 量一下单个 Handle 的耗时再倒推 N。
Job System 与 NativeArray
实测下来最反直觉的一条:单个 IJob 比主线程直接跑还慢。Job System 的收益来自并行,不来自「挪到别的线程」。
基础用 Job System 需要装什么包?它和自己开线程有什么区别?
核心部分不用装任何包。 实测 Unity.Jobs.IJob 和 Unity.Collections.NativeArray<T> 都在 UnityEngine.CoreModule 里,开箱可用。要装包的是 Burst 编译器和 Unity.Collections 扩展(NativeList、NativeHashMap 这些)。
和自己 new Thread 的区别有三条:
1. 线程是复用的。Unity 维护一个 worker 线程池,不为每个任务新建线程。实测这台机器 JobsUtility.JobWorkerCount = 17,SystemInfo.processorCount = 18 —— worker 数 = 核数 - 1,留一个给主线程。
2. 有安全检查。同一份数据被两个 job 同时写、或者主线程在 job 跑着时去读,都会被拦下并抛异常(下一题)。自己开线程没人管你。
3. 数据必须用 NativeArray 这类非托管容器。因为 job 结构体要被复制到 worker 线程,托管对象过不去。
⭐ 所以 Job System 的定位是「受管控的数据并行」,不是通用的后台任务。跑网络请求、读文件这类事仍然该用 Task。
📌 JobWorkerCount 是可写的。测试或者需要给别的进程让出 CPU 时可以调小,但一般不用动 —— 默认值已经按核数留好了主线程的余量。
进阶Schedule() 之后立刻读结果数组会怎样?
抛 InvalidOperationException,被安全系统拦下。 PlayMode 实测,异常消息直接点名是谁:
The previously scheduled job JobTests:AddJob writes to the Unity.Collections.NativeArray`1 ...
同样被拦的还有「job 没 Complete 就 Dispose 数组」和「同一个数组上再 Schedule 一个写 job」——三种都是同一条规则:有未完成的写入者时,谁都不能碰这块数据。
这是 Job System 相对自己开线程最大的价值:数据竞争在第一次运行时就报错,而不是变成偶发的错误结果。
正确顺序永远是 Schedule → (做点别的)→ Complete → 读结果。
var h = new AddJob { data = arr, delta = 10f }.Schedule();
// ❌ 立刻读 → InvalidOperationException
// var x = arr[0];
DoSomethingElseOnMainThread(); // ⭐ 这段时间才是 Job System 的收益所在
h.Complete(); // 等它做完
var x = arr[0]; // ✅ 现在可以读
arr.Dispose(); // ✅ 也可以释放了⚠️ 安全检查只在编辑器和开发版构建里开着。Release 构建里这些检查会被剥掉 —— 也就是说,本来会抛异常的代码在正式包里变成了静默的数据竞争。所以别把「编辑器里没报错」当成没问题,要在开发版里跑过。
深入Job 到底跑在哪个线程?Schedule() 之后就一定在 worker 上跑吗?
不一定。 PlayMode 实测,同一个 job 两种调用方式结果不同:
Schedule() 后立刻 Complete() → Execute 在**主线程**(id 1) Schedule() 后隔 3 帧再 Complete() → Execute 在 **worker**(id 6)
原因是 Complete() 发现这个 job 还没被 worker 领走时,会直接在调用线程上执行它 —— 与其空等,不如自己干。
⭐ 所以「Schedule 之后立刻 Complete」这个写法,等于在主线程上同步跑了一遍,一点并行都没有。它是新手最常见的用法,也是最没收益的用法。
IJobParallelFor 则真的散开了:实测 2048 个元素、批大小 32,一共用到 18 个不同线程,其中包含主线程 —— 17 个 worker 加上主线程,主线程也会参与干活。
// ❌ 等于同步执行,没有任何并行收益
var h = job.Schedule();
h.Complete();
// ✅ 让出时间给它:这一帧安排,下一帧取结果
void Update() { handle = job.Schedule(); }
void LateUpdate() { handle.Complete(); UseResult(); }📌 .Run() 是显式的同步执行(实测确实在主线程),用于调试时排除并行因素。发现结果不对时,把 Schedule().Complete() 换成 Run() 看还错不错,能快速区分「逻辑错」和「并发错」。
深入把一个循环改成 Job 就一定更快吗?
不一定,单个 IJob 实测比主线程直接跑还慢。
同一段计算(200 万个 Mathf.Sqrt(x) * 2),三种写法实测:
主线程 for 循环 64.1 ms IJob(单个 job) 89.1 ms ← 比主线程还慢 IJobParallelFor 5.5 ms ← 相对主线程 11.7×
中间那行是关键:把循环原样搬进一个 IJob 不会变快,反而多了调度和内存屏障的开销。Job System 的收益来自并行,不来自「挪到别的线程」。
⭐ 判断该不该上 Job:
- 循环体之间互不依赖、能拆成 N 份 → IJobParallelFor,可能有数量级收益
- 逻辑本身串行(比如要按顺序累加) → 上了也白上
- 只是想「不卡主线程」→ 单个 IJob 确实能把耗时挪走,但总耗时更长;
而且要等结果时还是得 Complete
⚠️ 上面的数字没有启用 Burst(要额外装包)。Burst 通常能在此基础上再快几倍, 但那是另一层优化,别把两者的收益混为一谈。
🚨 IJobParallelFor 的批大小(Schedule(n, batch) 的第二个参数)要按单项开销定:太小的话调度开销盖过计算,太大的话最后一批拖尾。实测这组用的是 1024。没有万能值,量出来的才算。
进阶NativeArray 的三种 Allocator 怎么选?忘了 Dispose 会怎样?
三种的差别是生命周期,不是性能等级:
Allocator.Temp 1 帧内有效,分配最快,不能跨 job Allocator.TempJob 4 帧内有效,超时会有警告,给 job 用的默认选择 Allocator.Persistent 手动释放,分配最慢,用于长期存在的数据
实测的几个行为:
分配后 IsCreated → True Dispose 后 IsCreated → False 重复 Dispose → 抛 ObjectDisposedException 默认分配的内容 → 已清零(p[0] == 0) NativeArrayOptions.UninitializedMemory → 是脏数据(实测拿到 4466)
最后两行值得注意:默认会清零,这本身是有成本的。确定自己会写满整个数组时,传 UninitializedMemory 可以省掉这一步 —— 但从此就不能假设里面是 0 了。
// 用 using 保证释放,比手写 Dispose 可靠
using (var tmp = new NativeArray<float>(n, Allocator.TempJob)) {
new MyJob { data = tmp }.Schedule().Complete();
Use(tmp);
} // 自动 Dispose
// 会写满全部元素时可以省掉清零
var buf = new NativeArray<float>(n, Allocator.Persistent,
NativeArrayOptions.UninitializedMemory);⚠️ NativeArray 是非托管内存,GC 管不着它。忘了 Dispose 就是真的泄漏,而且编辑器里只会在控制台给一条 leak 警告(还不一定当场出现)—— 比托管对象的泄漏更难发现。能用 using 就用 using。
伪 null 与对象生命周期
Unity 重载了 ==,于是「已销毁的对象」既等于 null 又不是 null。C# 6 之后的 ?. 和 ?? 全都绕过这个重载——这是 Unity 独有、且现代 C# 语法糖会踩中的坑。
进阶已经被 Destroy 的 GameObject,obj == null 和 obj is null 的结果一样吗?
不一样。 UnityEngine.Object 重载了 == 运算符:对象在 C# 侧的托管引用还在,但它对应的原生(C++)对象已经销毁时,重载的 == 会返回 true,故意让它「看起来像 null」。
而 is null 是模式匹配,编译成引用比较,不走任何运算符重载。托管引用还在,所以是 false。
2022.3 编辑器实测:
dead == null → True dead is null → False ReferenceEquals(dead, null) → False
这就是俗称的「伪 null」(fake null)。
var dead = new GameObject("dead");
Object.DestroyImmediate(dead);
if (dead == null) { /* 会进来 */ }
if (dead is null) { /* 不会进来 */ }🚨 平时写 == null 是对的,但一旦用了 is null、is not null、模式匹配 switch,判断就悄悄变了含义 —— 而且不会有任何警告。团队里如果有人习惯用 is null(在纯 C# 项目里这是被推荐的写法),这个坑迟早会踩。
深入obj?.name 和 obj ?? fallback 在对象已销毁时会怎样?
两个都绕过 Unity 的 == 重载,因为 ?. 和 ?? 的判空在语言层面就是引用比较,编译器不会去调用你重载的运算符。
2022.3 编辑器实测:
dead?.name → 抛 MissingReferenceException (dead ?? alive) → 结果是 dead 本身,不是 alive
?. 因为引用非空,就真的去访问了 .name,于是撞上原生对象已销毁而抛异常 —— 它不但没有保护你,还制造了一个「看起来不可能发生」的崩溃:明明写了 ?.。
?? 更隐蔽:它安静地保留了那个已销毁的对象,fallback 根本没生效,错误被推迟到后面某一行才爆。
// ❌ 这两种写法在 UnityEngine.Object 上都是错的
var n = target?.name; // 不会短路,会抛异常
var t = target ?? defaultTarget; // 不会 fallback
// ✅ 老实写
var n = target != null ? target.name : null;
var t = target != null ? target : defaultTarget;⚠️ 这一条是 Unity 独有的:在任何普通 C# 项目里 ?. 和 ?? 都是被鼓励的写法,代码检查工具甚至会主动建议你把三元改成 ??。所以它特别容易被「顺手优化」引入。
深入在泛型方法里写 where T : class 然后 t == null,对 UnityEngine.Object 有效吗?
无效。 泛型代码在编译时并不知道 T 是 UnityEngine.Object,t == null 编译成的是 object 层面的引用比较,不会分派到 Unity 的重载。
2022.3 编辑器实测:传入一个已销毁的 GameObject,泛型方法里的 t == null 得到 false。
要在泛型里正确判断,得把约束收紧到 where T : UnityEngine.Object,这样编译器才会选中那个重载。
// ❌ 对已销毁的对象返回 false
static bool IsNull<T>(T t) where T : class => t == null;
// ✅ 约束到 UnityEngine.Object,重载才会被选中
static bool IsNull<T>(T t) where T : UnityEngine.Object => t == null;同样的道理适用于任何「把对象塞进泛型容器再判空」的工具方法。写通用工具类时,Unity 对象往往是那个唯一的例外。
基础Destroy 和 DestroyImmediate 有什么区别?为什么运行时不该用后者?
Destroy 是延迟销毁:它只是打个标记,对象在当前帧的所有 Update 跑完之后才真正被销毁。DestroyImmediate 是立刻销毁,函数返回时对象已经没了。
运行时用 DestroyImmediate 的问题在于,你可能正在遍历一个集合、或者正处在某个回调里,对象在脚下被抽走会让同一帧内其它还没执行的逻辑拿到失效引用。Unity 的文档也明确说它主要给编辑器脚本用。
反过来,编辑器脚本里必须用 DestroyImmediate —— 非运行状态下 Destroy 的延迟销毁没有帧可以等,对象不会真的消失。
⚠️ Destroy 的延迟性有个直接后果:Destroy(go); if (go == null) 在同一帧内是 false,对象还在。想立刻把引用置空得自己写 go = null。
进阶一个字段在 Inspector 里从来没赋值,和它引用的对象被销毁了,== null 能区分吗?
不能。 两种情况 == null 都是 true,但底层完全不同:
- 从没赋值:托管引用真的是 null
- 已被销毁:托管引用非 null,只是重载的
==让它「装成」null
要区分只能绕过重载:ReferenceEquals(obj, null) 为 true 才是真的没赋值。
这个区分在排查问题时有用:「忘了在 Inspector 里拖引用」和「引用的对象被别的代码销毁了」是两类完全不同的 bug,但报错信息长得一样。
if (ReferenceEquals(target, null))
Debug.LogError("字段从未赋值 —— 检查 Inspector");
else if (target == null)
Debug.LogError("对象曾经存在,已被销毁 —— 检查谁调了 Destroy");异常类型也能帮你区分:访问真 null 抛 NullReferenceException,访问已销毁的对象抛 MissingReferenceException。看到后者就不用去 Inspector 里找了,直接去查谁销毁了它。
Transform 与旋转
eulerAngles 写进去的值和读出来的往往不一样。这不是 bug,是四元数没法反推出唯一的欧拉角——但靠它做累加就一定会出事。
进阶给 transform.eulerAngles 赋值 (190, 0, 0),再读出来还是 (190, 0, 0) 吗?
不是。 2022.3 编辑器实测:
写入 (0, 0, 270) → 读回 (0, 0, 270) 一样 写入 (45, 45, 45) → 读回 (45, 45, 45) 一样 写入 (0, 0, 360) → 读回 (0, 0, 0) 归一化了 写入 (0, 0, -90) → 读回 (0, 0, 270) 负角变正角 写入 (190, 0, 0) → 读回 (350, 180, 180) ← 面目全非 写入 (120, 0, 0) → 读回 (60, 180, 180) ← 同上
原因:Transform 内部只存四元数,eulerAngles 是每次读取时现算出来的。而同一个朝向对应无穷多组欧拉角,Unity 只能按固定规则挑一组返回 —— 挑出来的那组在数学上等价,但数字完全可以不一样。
注意最后两行:(190,0,0) 和 (350,180,180) 描述的是同一个朝向,验证方式是比较四元数而不是比较三个数字。
🚨 由此推出一条硬规则:不要把 eulerAngles 当成状态存。想记住「当前转了多少度」,自己维护一个 float 字段,需要时再赋给 transform;反过来去读 eulerAngles 累加,迟早读到一组你没写过的数。
深入transform.eulerAngles += new Vector3(40, 0, 0) 和 transform.Rotate(40, 0, 0) 等价吗?
物体没有其它旋转时看起来等价,一旦有就完全不同。
2022.3 编辑器实测。初始朝向 (0, 0, 30),各执行三次:
eulerAngles += (40,0,0) 三次 → (60.00, 180.00, 210.00) Rotate(40,0,0) 三次 → (48.59, 139.11, 130.89) 两者朝向相差 → 51.81 度
差了 51.81 度 —— 不是舍入误差,是两个不同的操作。
eulerAngles +=是「读回一组欧拉角 → 加 40 → 再转回四元数」。第一步读回来的就可能不是你写进去的那组(见上一题),加完再转回去,方向就漂了。Rotate是「在当前朝向上再叠加一个旋转」,全程用四元数相乘,不经过欧拉角这一层。
⭐ 所以想「转一点」就用 Rotate,想「设成某个朝向」就整体赋值 eulerAngles,不要读-改-写。
// ❌ 读-改-写:经过一次欧拉角往返,会漂
transform.eulerAngles += new Vector3(speed * Time.deltaTime, 0, 0);
// ✅ 增量旋转用 Rotate(四元数相乘)
transform.Rotate(speed * Time.deltaTime, 0, 0);
// ✅ 或者自己维护角度,只做「写」不做「读」
pitch += speed * Time.deltaTime;
transform.eulerAngles = new Vector3(pitch, 0, 0);⚠️ 这个 bug 在测试时特别容易漏:初始旋转是 (0,0,0) 的话两种写法结果一模一样。等到美术把模型摆了个角度、或者物体挂到一个有旋转的父物体下,才开始「转着转着就歪了」。
基础position 和 localPosition 有什么区别?父物体缩放会影响子物体的世界坐标吗?
localPosition 是相对父物体坐标系的位置,position 是世界坐标。没有父物体时两者相同。
父物体的缩放会影响子物体的世界位置,这一点常被忽略。2022.3 实测:
父在 (10,0,0),子 localPosition = (1,0,0)
→ 子的 world position = (11, 0, 0)父再设 localScale = (2,2,2)
→ 子的 world position = (12, 0, 0) ← 偏移量被放大了因为子物体的局部偏移是在父的坐标系里度量的,父缩放 2 倍,那个 (1,0,0) 的偏移在世界里就变成了 2 个单位。
🚨 非等比缩放(比如 (2,1,1))加上旋转,会让子物体产生倾斜(shear),Transform 存不下这种变换,表现是子物体形状被拉歪且无法通过调 scale 修回来。UI 和骨骼上尤其常见。规避办法是:需要缩放的节点上不要再叠加旋转,或者干脆把缩放放到最末端的渲染物体上。
进阶两个 Vector3 用 == 比较可靠吗?和逐个分量比 float 有什么不同?
Vector3 的 == 不是逐分量精确相等,它比较的是两个向量差的平方长度是否小于一个很小的阈值(约 1e-10)。所以它天然容忍浮点误差。
2022.3 实测这组对照:
float 累加 0.1f 十次 == 1f → False (实际值 1.00000012) Mathf.Approximately(上面那个, 1f) → True Vector3 累加 (0.1,0,0) 十次 == (1,0,0) → True ← 因为 == 带容差
顺带澄清一个常被拿来举例但其实不成立的说法:
0.1f * 3 == 0.3f → True 0.1f + 0.2f == 0.3f → True
在 float(单精度)下这两个都是 true —— 那个经典的「0.1+0.2 != 0.3」是 double 的例子,直接搬到 float 上会举错例。float 的问题要多次累加才显出来。
// Vector3 的 == 自带容差,可以直接用
if (transform.position == target) { }
// 单个 float 不要用 ==
if (Mathf.Approximately(a, b)) { }
// 或者按自己的精度要求
if (Mathf.Abs(a - b) < 0.001f) { }⚠️ 反过来也要小心:Vector3 的 == 容差是绝对值,在坐标数量级很大时(比如物体跑到 10000 单位外)这个容差相对就变得极小,两个视觉上重合的点也会判不相等。开放世界里的浮点精度问题基本都源于此。
深入为什么推荐用 Quaternion 而不是欧拉角做旋转插值?Lerp 和 Slerp 怎么选?
欧拉角插值有两个硬伤:
1. 绕不过 360/0 的边界。从 350 度插值到 10 度,按数值走会倒着转 340 度,而不是正着转 20 度。 2. 万向锁。某个轴转到 ±90 度时,另外两个轴的旋转会退化到同一个平面,插值路径变得不可预测。
Quaternion 没有这两个问题:Quaternion.Slerp 总是走两个朝向之间的最短弧。
Lerp 与 Slerp 的区别:Slerp 沿球面等角速度插值,路径和速度都均匀;Lerp 是线性插值再归一化,中间段角速度会偏快。小角度(几度以内)两者几乎无差别,Lerp 更便宜;大角度或者要求匀速转动就用 Slerp。
// ❌ 欧拉角插值:跨 0 度时会绕远路
float a = Mathf.Lerp(350f, 10f, t); // 从 350 一路降到 10
// ✅ 要插值角度标量,用 LerpAngle(它知道 350→10 只有 20 度)
float a = Mathf.LerpAngle(350f, 10f, t);
// ✅ 插值朝向用 Slerp
transform.rotation = Quaternion.Slerp(from, to, t);📌 Quaternion.Slerp(a, b, t) 里的 t 每帧传 Time.deltaTime * speed 是常见写法,但它不是匀速转向 —— 因为起点每帧都在变,实际是指数逼近(越接近越慢,永远到不了)。要真正匀速请用 Quaternion.RotateTowards,它按「每帧最多转多少度」推进,且会精确到达。
物理、射线与层
LayerMask 收的是掩码不是层号,传错了不报错、只是安静地射不中。射线相关的坑几乎都属于这一类——没有异常,只有「怎么没反应」。
进阶Physics.Raycast 的 layerMask 参数传 LayerMask.NameToLayer("Water") 对吗?
不对,而且不会报错。 NameToLayer 返回的是层号(0~31 的序号),而 layerMask 要的是位掩码(第 n 位为 1 表示包含第 n 层)。
2022.3 实测:
LayerMask.NameToLayer("Water") → 4 ← 层号
LayerMask.GetMask("Water") → 16 ← 掩码,即 1<<4
把 4 当掩码传,实际筛选的是哪些层 → 第 2 层(因为 4 的二进制是 100)于是「物体在 Water 层、你想只打 Water」这个意图,变成了「只打第 2 层」,射线安安静静地什么都打不中。实测这一句返回 False。
正确写法二选一:LayerMask.GetMask("Water"),或者 1 << LayerMask.NameToLayer("Water")。
int layer = LayerMask.NameToLayer("Water"); // 4,是层号
// ❌ 层号当掩码
Physics.Raycast(o, d, out hit, 100f, layer); // 实际筛第 2 层
// ✅ 两种正确写法
Physics.Raycast(o, d, out hit, 100f, 1 << layer);
Physics.Raycast(o, d, out hit, 100f, LayerMask.GetMask("Water"));
// 排除某一层用取反
Physics.Raycast(o, d, out hit, 100f, ~(1 << layer));🚨 NameToLayer 拼错名字时返回 -1,而 1 << -1 在 C# 里等于 1 << 31(移位数按 32 取模)—— 又是一个不报错但结果全错的组合。层名建议定义成常量或用 GetMask,别在调用处写字符串字面量。
进阶射线的起点在一个 Collider 内部,往外打能打中这个 Collider 吗?
打不中。 2022.3 实测:把起点放在立方体中心,沿 forward 打 100 米,Physics.Raycast 返回 False。
原因是物理引擎只检测射线进入碰撞体的那一次相交(正面),从内部出发时射线只会从背面穿出,不算命中。
这解释了一类很常见的现象:角色脚下打射线检测地面,射线起点设在角色中心(在自身 Collider 内),结果自己的 Collider 打不中——看起来「刚好符合预期」,但换个碰撞体形状或起点稍微移出去一点,就会突然开始打中自己。别依赖这个行为来过滤自身,用 layerMask 或者 hit.collider != myCollider 显式排除。
⚠️ 这个行为在不同碰撞体类型上并不完全一致(凹面 MeshCollider 的表现与凸体不同),所以它更不该被当作「自动忽略自己」的机制来依赖。
基础Raycast 默认会打中 isTrigger 的碰撞体吗?
会。 Physics.queriesHitTriggers 的默认值是 true(2022.3 实测),所以射线、OverlapSphere 这类查询默认都把 Trigger 当作可命中的对象。
实测对照:
queriesHitTriggers = true(默认) → 能打中 Trigger queriesHitTriggers = false → 打不中
这经常出乎意料:明明把某个区域做成了 Trigger(本意是「可以穿过去」),射线检测却被它挡住了,表现为「隔着空气打不中后面的东西」。
三种处理办法:全局改 Physics.queriesHitTriggers(影响所有查询,慎用);调用时传 QueryTriggerInteraction.Ignore 参数(推荐,只影响这一次);或者把 Trigger 放到单独的层再用 layerMask 排除。
// 只对这次查询忽略 Trigger —— 比改全局开关安全
Physics.Raycast(origin, dir, out hit, 100f,
layerMask, QueryTriggerInteraction.Ignore);📌 顺带记 Trigger 的触发条件:两个物体要产生 OnTriggerEnter,至少有一个必须带 Rigidbody(可以是 isKinematic 的)。两个都是纯 Collider 的话什么都不会发生 —— 这是「碰撞回调不触发」最常见的原因。
进阶RaycastAll 和 RaycastNonAlloc 有什么区别?后者返回值是什么意思?
RaycastAll 每次调用新建一个数组返回,是每帧调用时的 GC 大户。RaycastNonAlloc 写进你传进去的缓冲区,不分配。
关键差别在返回值:NonAlloc 返回的是实际写入的命中数量,而不是缓冲区长度。所以遍历时必须用返回值当上界,不能用 buffer.Length——缓冲区后半段是上一次调用留下的旧数据。
🚨 还有一个更隐蔽的:缓冲区满了不会报错,多余的命中被直接丢弃。实测缓冲区只给 1 个格子时,返回值就是 1,你根本不知道其实还有别的东西被射中了。所以缓冲区要按最坏情况开,或者检查「返回值 == 缓冲区长度」时告警。
private RaycastHit[] hits = new RaycastHit[16]; // 复用,不要每帧 new
void Check() {
int n = Physics.RaycastNonAlloc(origin, dir, hits, 100f);
if (n == hits.Length)
Debug.LogWarning("缓冲区可能不够,有命中被丢弃");
for (int i = 0; i < n; i++) { // 🚨 用 n,不是 hits.Length
Use(hits[i]);
}
}⚠️ RaycastAll / RaycastNonAlloc 返回的命中没有按距离排序,文档明确说明顺序不保证。要最近的那个得自己比 hit.distance,或者干脆用单个 Physics.Raycast(它给的就是最近命中)。
基础为什么物理逻辑要写在 FixedUpdate 里?默认的物理步长是多少?
2022.3 默认值实测:
Time.fixedDeltaTime 0.02 秒 → 每秒 50 次 FixedUpdate Physics.gravity (0, -9.81, 0) Rigidbody.mass 1 Rigidbody.drag 0 Rigidbody.useGravity true Rigidbody.isKinematic false collisionDetectionMode Discrete interpolation None
物理引擎按固定步长积分,所以施加的力必须在固定步长里施加才有一致的效果。写在 Update 里的话,帧率越高、Update 调用越多、力施加的次数越多,同一段代码在 144Hz 的机器上就会比 60Hz 推得更远。
注意 50 次/秒低于常见的 60 帧渲染:60fps 下平均每帧只有 0.83 次 FixedUpdate,也就是有些帧一次都不执行。所以在 FixedUpdate 里读输入会丢事件(输入按渲染帧刷新),输入要在 Update 里读、存下来,到 FixedUpdate 里用。
private bool jumpQueued;
void Update() {
// 输入在 Update 里读 —— FixedUpdate 可能整帧不执行,会漏按键
if (Input.GetButtonDown("Jump")) jumpQueued = true;
}
void FixedUpdate() {
if (jumpQueued) {
rb.AddForce(Vector3.up * jumpForce, ForceMode.Impulse);
jumpQueued = false;
}
}🚨 默认的 interpolation = None 意味着物体位置每秒只更新 50 次,而画面每秒渲染 60+ 次 —— 快速移动的物体看起来会轻微抖动。给玩家能看到的 Rigidbody 打开 Interpolate 通常能直接解决「动起来有点卡」的观感问题,而这一项默认是关的。
资源、材质与查找
renderer.material 这一行读操作会偷偷克隆一份材质,而且销毁物体时不回收。Unity 里「读一个属性就产生了新资源」的地方不止一处。
进阶renderer.material 和 renderer.sharedMaterial 有什么区别?为什么说前者会泄漏?
sharedMaterial 返回的是项目里那个材质资源本身,所有用它的对象共享同一个实例。
material 是一个看起来像读、实际会写的属性:第一次访问时,Unity 会克隆一份材质、把克隆体装回这个 Renderer,然后返回克隆体。
PlayMode 实测:
两个 Cube 的 sharedMaterial 是同一个对象 → True(名字 Default-Material) 读一次 renderer.material 后场景里的 Material 数量 → 8 → 9 克隆体的名字 → "Default-Material (Instance)" 它和 sharedMaterial 还是同一个吗 → False 再读一次 .material → 不会再克隆,返回同一个克隆
泄漏在于:这个克隆体不属于任何资源文件,销毁宿主 GameObject 也不会回收它。实测销毁物体前后 Material 数量都是 9,一个都没少。
每有一个物体读过 .material,内存里就多一份材质,直到显式 Destroy 它或者场景卸载。批量给敌人改颜色时,这就是「打一会儿内存涨一截」的来源。
// 想改这一个物体的外观 → 用 .material,但要负责销毁
void OnDestroy() {
if (rend != null && rend.material != null)
Destroy(rend.material);
}
// 只是读参数、不想克隆 → 用 sharedMaterial
var c = rend.sharedMaterial.color;
// ⭐ 更好的方案:MaterialPropertyBlock,既不克隆也不破坏合批
var mpb = new MaterialPropertyBlock();
rend.GetPropertyBlock(mpb);
mpb.SetColor("_Color", Color.red);
rend.SetPropertyBlock(mpb);🚨 反过来的坑更严重:在编辑器里改 sharedMaterial 会直接改到项目资源文件上,退出播放模式也不会还原。实测把一个 Cube 的 sharedMaterial.color 改成红色后,之后新建的每个 Cube 都是红的 —— 改的是 Default-Material 本身。这种修改会真的写进版本库。
进阶MeshFilter.mesh 有没有同样的问题?
有,而且更隐蔽。 PlayMode 实测,读一次 meshFilter.mesh,场景里的 Mesh 数量从 2 变成 3,克隆体名字是 Sphere Instance。
比材质更容易骗人的一点:克隆之后 sharedMesh 返回的也是那个克隆体了(实测两者名字都是 Sphere Instance)—— 因为 .mesh 把克隆体装回了 MeshFilter。所以你没法靠「再读一次 sharedMesh 看看变没变」来自查。
同一套规则适用于一批「单数 / shared 前缀」的成对属性:
renderer.material / renderer.sharedMaterial renderer.materials / renderer.sharedMaterials meshFilter.mesh / meshFilter.sharedMesh collider.material / collider.sharedMaterial (物理材质)
⭐ 记法很简单:带 shared 的是「大家共用的那份」,不带的是「我自己的那份」,而「我自己的那份」是被这次访问创建出来的。
⚠️ renderer.materials(复数)克隆的是整个数组里的每一个材质,一次能造出好几份。多材质模型上误用它,泄漏量翻倍。
基础Destroy(go) 之后,紧接着的那一行里 go == null 是什么结果?
同一帧内是 false,下一帧才变 true。 PlayMode 实测:
Destroy(go); 同一帧内检查 go == null → False 下一帧检查 → True
因为 Destroy 是延迟销毁:它只打个标记,真正的销毁发生在当前帧所有 Update 跑完之后。
这带来两类问题:
1. 以为销毁了就不会再被用到。同一帧内其它脚本仍然能拿到它、调用它的方法,逻辑照常执行。
2. 想立刻判断「我销毁过它了吗」判断不出来。要立刻切断引用,得自己写 go = null。
Destroy(enemy);
enemy = null; // ⭐ 自己置空,别等下一帧
// 从集合里移除也要立刻做,否则这一帧的遍历还会遇到它
enemies.Remove(enemy);📌 编辑器脚本里必须用 DestroyImmediate —— 非播放状态没有「下一帧」,Destroy 的延迟销毁永远等不到,对象不会消失。反过来,运行时不要用 DestroyImmediate:对象在遍历或回调中途被抽走,同帧内其它逻辑会拿到失效引用。
进阶FindObjectsOfType 和 GameObject.Find 能找到未激活的对象吗?各自多贵?
默认都找不到未激活的对象,而且 GameObject.Find 根本没有包含未激活对象的选项。
PlayMode 实测(场景里 205 个带 BoxCollider 的对象,其中 1 个未激活):
FindObjectsOfType<BoxCollider>() → 204 ← 漏掉未激活的那个
FindObjectsOfType<BoxCollider>(true) → 205 ← 传 true 才包含
GameObject.Find("hidden")(未激活) → 找不到,返回 nullFindObjectsOfType 200 次 → 9.6 微秒/次 GameObject.Find 200 次 → 1.2 微秒/次
「找不到未激活对象」是排查这类问题时最容易忽略的一条:对象池里回收的物体、开局默认隐藏的 UI,都不在默认查找结果里,表现是「明明场景里有,就是找不着」。
开销上,两者都是遍历式的,代价随场景规模增长。上面的微秒数只在这个 205 对象的小场景成立,真实项目里会大得多 —— 结论是别在 Update 里调用它们,启动时找一次缓存起来,或者由对象自己在 OnEnable 里向管理器注册。
// ❌ 每帧全场景遍历
void Update() { var all = FindObjectsOfType<Enemy>(); }
// ✅ 让对象自己登记,管理器只维护一个列表
void OnEnable() => EnemyManager.Register(this);
void OnDisable() => EnemyManager.Unregister(this);⚠️ GameObject.Find 用名字查找,重名时返回哪一个不保证;而且名字是运行时可改的字符串,改个名字就静默失效、编译期毫无提示。能用引用就别用名字查找。
深入Instantiate 出来的对象,和原型共享资源吗?哪些是复制、哪些是共享?
组件和 GameObject 层级是复制的,引用到的资源是共享的。 PlayMode 实测:
Instantiate 出来的名字 → "Cube(Clone)" 克隆体和原体是同一对象吗 → False(新对象) 克隆体的 sharedMaterial 与原体共享吗 → True(同一个材质资源)
也就是说,Instantiate 复制的是场景里的那部分(GameObject、Transform、组件及其序列化字段的值),而字段里指向的资源(Material、Mesh、Texture、ScriptableObject、AudioClip)只是把引用复制了一份,指向的还是同一个对象。
两个直接后果:
1. 改克隆体的 sharedMaterial 会影响所有实例,包括原型。想单独改就得走 .material 或 MaterialPropertyBlock(见第一题)。
2. 克隆体引用的 ScriptableObject 是同一个实例。ScriptableObject 常被当成「配置」用,如果在运行时往里写状态,所有实例会互相串 —— 而且在编辑器里这个修改还会持久化下来。
📌 Instantiate 的重载里有一个 Instantiate(original, parent, worldPositionStays)。默认的 worldPositionStays = true 会保持世界坐标,也就是把 localPosition 反算一遍 —— 生成 UI 元素时几乎总是想要 false(保持局部坐标),否则新建的 UI 会跑到奇怪的位置。
UI 与 Canvas
RectTransform 的 sizeDelta 在拉伸锚点下不是「尺寸」而是「相对父边的增量」——这一条不理解,UI 布局就永远是靠拖试出来的。
深入sizeDelta 到底是什么?为什么改它有时候管用、有时候完全不起作用?
sizeDelta 不是尺寸,是「本元素的矩形,相对锚点框各边多出多少」。 只有当锚点重合成一个点时,它才恰好等于尺寸。
PlayMode 实测(父 Canvas 800×600):
anchorMin = anchorMax = (0.5, 0.5) 点锚点
sizeDelta = (200, 100) → rect = (200, 100) ← 就是尺寸anchorMin = (0,0), anchorMax = (1,1) 拉伸到父的四边
sizeDelta = (200, 100) → rect = (1000, 700) ← 父尺寸 + sizeDelta
sizeDelta = (0, 0) → rect = (800, 600) ← 就是父尺寸看懂第二组就明白为什么「改 sizeDelta 没反应」:拉伸锚点下它是增量, 你把它设成 200 得到的是「比父宽 200」,不是「宽 200」。
拉伸模式下应该改的是 offsetMin / offsetMax(到父四边的边距):
offsetMin = (20, 20), offsetMax = (-20, -20) 四边各内缩 20
→ rect = (760, 560)
→ 此时 sizeDelta 自动变成 (-40, -40)⭐ 记法:anchorMin == anchorMax 时 sizeDelta 是尺寸;不相等时它是「比锚点框大多少」。
🚨 Inspector 里这个字段会随锚点设置改名:点锚点时显示 Width/Height,拉伸时显示 Left/Right/Top/Bottom。代码里却始终叫 sizeDelta,看不到这层提示 —— 这就是「Inspector 里拖着好好的,代码里设不对」的根源。
进阶改了布局之后立刻读 rect 或 sizeDelta,拿到的是新值吗?
拿到的是旧值。 uGUI 的布局在帧末统一重建,不是改一下算一下。
PlayMode 实测:给一个 panel 加上 VerticalLayoutGroup + ContentSizeFitter,塞进 3 个高 30、间距 10 的子项(预期高度 3×30 + 2×10 = 110):
刚加完立刻读 sizeDelta → (100, 100) ← 还是默认值 等一帧后 → (100, 110) ✅ ForceRebuildLayoutImmediate 后 → (100, 110)
所以「动态生成一批 UI,紧接着读高度去做定位」必然读到旧值。两种解法:
1. yield return null 等一帧(协程里最自然)
2. LayoutRebuilder.ForceRebuildLayoutImmediate(rt) 立刻重建(要立即拿到值时用)
// ❌ 立刻读,拿到旧值
foreach (var d in data) Instantiate(itemPrefab, content);
float h = content.rect.height; // 还是加之前的高度
// ✅ 要立刻用就强制重建
LayoutRebuilder.ForceRebuildLayoutImmediate(content);
float h = content.rect.height;
// ✅ 不急的话等一帧
yield return null;
float h = content.rect.height;⚠️ ForceRebuildLayoutImmediate 是同步全量重建这棵子树,在深层嵌套的布局上很贵。它是「拿到正确值」的手段,不是可以每帧调用的东西。真的每帧都要读,说明布局结构该简化了。
进阶CanvasGroup.alpha = 0 之后,这块 UI 还能被点到吗?
能。 PlayMode 实测:把 alpha 设为 0 之后,blocksRaycasts 仍然是 true。
CanvasGroup 的三个字段各管各的,互不联动:
alpha → 只管透明度(默认 1) blocksRaycasts → 只管挡不挡射线(默认 true) interactable → 只管子元素能不能交互(默认 true)
所以「用 alpha=0 隐藏一个面板」会留下一块看不见但照样挡点击的区域 —— 玩家点了没反应,而且这个 bug 在屏幕上完全没有线索。
隐藏 UI 时要三个一起改,或者干脆用 SetActive(false)。
// ❌ 看不见但还挡着
group.alpha = 0;
// ✅ 三个一起
void SetVisible(CanvasGroup g, bool on) {
g.alpha = on ? 1 : 0;
g.blocksRaycasts = on;
g.interactable = on;
}📌 反过来说,这个「不联动」也有用:alpha = 0.5 + interactable = false 就是标准的「置灰不可点」效果,一个组件搞定,不用去改每个子元素。
进阶隐藏一批 UI 元素,用 SetActive(false) 还是改 CanvasGroup.alpha?
频繁开关就用 CanvasGroup,实测快得多。
PlayMode 实测:一个 VerticalLayoutGroup 下 50 个带 Image 的元素
逐个 SetActive 切换 50 个 × 200 轮 → 43 ms 改一次 CanvasGroup.alpha × 200 轮 → 1.5 ms ← 28 倍
差距来自 SetActive 的连锁反应:它会触发 OnEnable/OnDisable、让所在 Canvas 标脏重建、还会让布局组重新计算子项。而改 alpha 只是改一个顶点色系数。
选择标准:
- 频繁开关、元素数量多 → CanvasGroup(记得三个字段一起改,见上一题)
- 长时间不用、想省内存和 Update 开销 → SetActive(false),它是真的停掉
- 一次性的显示/隐藏 → 都行,别为这个纠结
🚨 SetActive(false) 还有个副作用容易忘:它会终止对象上正在跑的协程,且重新激活也不恢复(见「协程」那一章)。用它做 UI 开关时,面板上的动画协程会静默失效。
深入为什么建议把频繁变化的 UI 拆到单独的 Canvas 里?嵌套 Canvas 是怎么回事?
Canvas 是重建的最小单位:它内部任何一个 Graphic 变脏,整个 Canvas 的网格都要重新合并。所以一个每帧跳动的血条,会拖着同一 Canvas 下几百个静态元素一起重建。
拆分办法就是给那部分加一个子 Canvas —— 子 Canvas 有自己的重建边界,父级不受影响。
PlayMode 实测嵌套 Canvas 的默认行为:
子 Canvas 的 renderMode → 跟随父级(ScreenSpaceOverlay) 子 Canvas 的 isRootCanvas → False 子 Canvas 的 rootCanvas → 指向父 Canvas 子 Canvas 的 overrideSorting → False(默认不覆盖排序)
注意最后一行:加子 Canvas 默认不改变渲染顺序,只是切开了重建边界。要单独控制层级才需要打开 overrideSorting 并设 sortingOrder。
⚠️ 但别过度拆:每个 Canvas 是一个独立的合批单位,拆得太碎会让 Draw Call 上升。原则是按「变化频率」拆,不是按「模块」拆 —— 静态的放一起,动态的各自独立。
📌 ScreenSpaceOverlay 模式下 Canvas.worldCamera 可以为 null(实测确认),这也是它最省事的原因 —— 不依赖相机,不参与相机的渲染流程。改成 ScreenSpaceCamera 或 WorldSpace 就必须正确设置相机,忘了设的表现是 UI 整个不显示。
基础raycastTarget 默认是什么?为什么说它是 UI 性能的低垂果实?
默认是 true —— 实测 Image 和 Text 都是。
也就是说,每一张背景图、每一段文字,默认都参与点击检测。一次点击要遍历这些元素做命中测试,而其中绝大多数根本不需要响应点击。
关掉纯装饰元素的 raycastTarget 是收益明确、风险为零的优化:背景图、图标、说明文字、进度条填充 —— 这些都不该接收射线。真正需要的只有按钮、输入框和可拖拽的东西。
// 批量关掉不需要交互的 Graphic
foreach (var g in panel.GetComponentsInChildren<Graphic>(true)) {
if (g.GetComponent<Selectable>() == null) g.raycastTarget = false;
}🚨 有个常见误用:拿一张 alpha = 0 的 Image 当「透明点击区」。这没问题,但要知道它默认就是可点的;反过来,想让一张图只做视觉不挡点击,必须显式关掉 raycastTarget —— 图片透明不等于射线穿过去。
序列化与 Prefab
这一类问题的共同特征是「不报错」——数据悄悄丢了,等到打开场景才发现,而版本控制里看不出改了什么。哪些字段会被序列化、改名会不会丢,这一章都用 2022.3 实跑验过。
进阶哪些字段会被 Unity 序列化?public 就一定会吗,private 就一定不会吗?
判据既不是 public 也不是 private,而是一串具体条件。2022.3 实测(同一个类里放七种字段,看序列化输出里剩下谁):
public int pub ✅ 会
[SerializeField] private int priv ✅ 会
private int plain ❌ 不会(没有 [SerializeField])
public int Prop { get; set; } ❌ 不会(属性一律不序列化,自动属性也不行)
[NonSerialized] public int marked ❌ 不会
public static int stat ❌ 不会(静态不属于实例)
public readonly int ro ❌ 不会(readonly 不序列化)序列化输出:{"pub":11,"priv":2}
容器和嵌套类型另有一套规则,同样实测:
Dictionary<string,int> ❌ 不会 ← 最常被踩 List<List<int>> ❌ 不会(嵌套容器不行) int[,] 二维数组 ❌ 不会(交错数组 int[][] 同样不行) 自定义类,没有 [Serializable] ❌ 不会 自定义类,有 [Serializable] ✅ 会
序列化输出:{"marked":{"v":9}} ← 五个字段只活下来一个
⭐ 最值得记的是 Dictionary 不被序列化。它不报错、不警告,Inspector 里也不显示——你以为配置好了,运行起来是空的。
// ❌ 存不下来
public Dictionary<string, int> table;
// ✅ 常见变通:两个平行 List,或一个 [Serializable] 的键值对 List
[System.Serializable]
public struct Entry { public string key; public int value; }
public List<Entry> entries;🚨 readonly 那条最隐蔽:加 readonly 是个看起来纯粹变严格、只有好处的改动,却会让这个字段从此不再被序列化,Inspector 里填的值全部失效——而且和改名一样,没有任何报错。
深入把一个 [SerializeField] 字段改名之后,prefab 和场景里已经填好的值会怎样?
会丢失,而且没有任何报错。
Unity 的序列化是按字段名存取的:prefab / 场景文件里记录的是「字段名 → 值」。改了字段名,反序列化时找不到对应的键,该字段就退回默认值(引用变 null,数值变 0),旧数据仍然躺在 asset 文件里但再也没人读它。
2022.3 实测:手写一个 asset,里面存的是旧键 hp: 42,然后用改过名的两种类型分别加载——
改名后**不加** [FormerlySerializedAs] → maxHealth = 0 ← 数据丢了 改名后**加了** [FormerlySerializedAs] → maxHealth = 42 ← 救回来了
所以正确做法是加 [FormerlySerializedAs("旧名字")],让 Unity 知道这两个名字指的是同一个字段;待所有 asset 都被重新保存过之后,再把这个特性删掉。
using UnityEngine.Serialization;
// 原来叫 hp,改名为 maxHealth
[FormerlySerializedAs("hp")]
[SerializeField] private int maxHealth;这个坑最贵的地方在于它看起来是纯代码改动。只改了 .cs 文件、编译通过、git diff 里干干净净,而丢的是 prefab 里美术和策划填了很久的数值——那些数据不在你改的文件里,所以 review 也看不出来。 判断一次改名是否安全,判据不是「只动了 .cs」,而是「这个字段会不会被 Unity 序列化引用」。
⚠️ 验证时踩到一个反直觉的点:JsonUtility 不认 [FormerlySerializedAs]。同样的类型,走 asset 反序列化能救回 42,走 JsonUtility.FromJsonOverwrite 仍然是 0。所以拿 JsonUtility 去检验这个特性会得出「它没用」的错误结论——验证时用的路径必须和真实场景是同一条。
深入一个 [Serializable] 类的字段,代码里设成 null,存盘再读回来还是 null 吗?
不是。会变成一个各字段取默认值的实例。
2022.3 实测:把一个 [Serializable] 类字段置 null 再序列化,输出是
{"marked":{"v":0}} ← null 被写成了一个默认对象
读回来之后 marked == null 是 false,拿到的是 v = 0 的实例。
⭐ 原因是 Unity 的序列化没有「空引用」这个概念(只有 UnityEngine.Object 引用才有)。普通的 [Serializable] 类字段一律按值展开,null 在写盘时就被替换成默认值。
后果:用 if (config == null) 来判断「策划还没配这一项」是永远不成立的——它总是非空。要表达「没配」,得自己加一个显式的布尔字段或用 enabled 之类的标志。
[System.Serializable] public class Extra { public int v; }
[SerializeField] private Extra extra; // 即使没在 Inspector 里填
void Start() {
if (extra == null) { /* ❌ 永远进不来 */ }
if (!extra.enabled) { /* ✅ 用显式标志 */ }
}📌 这也解释了一个常见现象:写了一个自引用的 [Serializable] 类(比如树节点里放 List<Node> children),Unity 会在深度 7 层左右停止展开并报警告——因为它没法用 null 表示「到此为止」,只能靠深度上限强行截断。
进阶重命名或移动一个脚本文件时,为什么必须连 .meta 文件一起处理?
因为 Unity 靠 .meta 里的 GUID 建立引用,prefab 和场景引用的是 GUID 而不是文件路径。
只移动 .cs 而丢掉对应的 .meta,Unity 会给这个文件重新生成一个新 GUID,于是所有引用它的 prefab / 场景都会变成 Missing Script —— 挂在上面的组件连带它的序列化数据一起失效。
用 Unity 编辑器内的重命名/拖动是安全的(它会同步处理 .meta);在编辑器外用命令行或 IDE 操作时,必须把 .cs 与 .cs.meta 当成一个整体。
危险的是 git status 未必提醒你:如果 .meta 没被改动,它就不会出现在变更列表里,看起来「只改了一个文件」,而引用已经断了。
进阶为什么 ScriptableObject 或 MonoBehaviour 的类名必须和文件名一致?
因为 Unity 是靠文件名去找那个类的:每个 .cs 文件对应一个 MonoScript 资源,而 MonoScript 只会解析出与文件名同名的那个类。类名和文件名不一致时,MonoScript 的 GetClass() 返回 null,asset 里记的 GUID 指过来找不到类型,结果就是 Missing Script。
验证这条的过程本身就是个例子:写序列化测试时,我把两个 ScriptableObject 类和测试代码放在同一个文件里,结果 AssetDatabase.FindAssets 根本查不到它们的 GUID,手写的 asset 加载出来是空的。拆成同名文件后立刻正常。
⭐ 所以这不是「代码规范建议」,是运行时的硬约束。同一个文件里可以放别的辅助类(普通 class、struct、enum),但继承自 MonoBehaviour / ScriptableObject 的那个必须与文件同名。
⚠️ 这条在改名时和 .meta 那条会叠加:把 Player.cs 里的类改名成 Hero 而不改文件名,编译能过、编辑器不报错,直到你打开挂着它的 prefab 才看到 Missing Script。
进阶[SerializeField] private 和 public 字段都能被序列化,为什么推荐前者?
两者对 Unity 序列化是等价的(上面那条实测里 pub 和 priv 都在输出中),区别在于对外部代码的可见性。public 同时向 Inspector 和所有其它类开放了写权限,等于把「可以在编辑器里调」和「可以被任何人在运行时改」这两件事绑在一起。
用 [SerializeField] private 能只开放前者:Inspector 可编辑,代码层面仍然受封装保护,需要暴露时再单独提供只读属性。
[SerializeField] private int maxHealth; // Inspector 可调,外部改不了
public int MaxHealth => maxHealth; // 只读暴露深入运行时修改 ScriptableObject 的字段,在编辑器里和打包后的行为一样吗?
不一样。在编辑器里,ScriptableObject 是磁盘上的 asset,运行时的修改会写回内存中的 asset 实例,退出播放模式后可能被保留下来(进而被误提交进版本库)。打包之后 asset 是只读资源,修改只存在于本次运行的内存里,重启即丢。
这个差异会造成典型的「编辑器里正常、真机上不对」:开发时靠运行时写入 SO 存了状态,打包后状态不再持久化。
所以 ScriptableObject 应当承担配置职责,运行时可变的状态另外存(存档文件、PlayerPrefs 或纯内存对象)。
⚠️ 这一条是本章唯一没有在本机实跑验证的:区分「编辑器里保留 / 打包后丢失」必须真打一个包再跑起来,而本站声明过不收录未验证的打包期结论。上面的说法来自官方文档与普遍实践,采信时请自己在目标平台上确认一次。
性能与 GC
面试里问「怎么优化」大多得到泛泛的答案;能说清「哪一行代码在分配内存」才是分界线。这一章把常见写法逐条测了一遍——包括测量手段本身怎么校准。
进阶下面这些写法,哪些真的会产生 GC 分配:foreach 遍历 List、foreach 遍历 IEnumerable、字符串拼接、lambda?
2022.3 编辑器(Mono)实测,每种写法跑 10 万次,看期间是否触发 GC:
foreach (var x in list) List<int> ❌ 不分配
foreach (var x in enumerable) IEnumerable ✅ **分配**
"a" + i 字符串拼接 ✅ 分配
$"a{i}" 字符串插值 ✅ 分配
Func<int> f = () => local; 捕获外部变量 ✅ 分配
Func<int> f = () => 1; 不捕获 ❌ 不分配
new Vector3(1,2,3) 结构体 ❌ 不分配⭐ 第一、二行的对比是重点:同一个 List,直接 foreach 不分配,通过 IEnumerable<T> 接口 foreach 就分配。
原因是 List<T> 的 GetEnumerator() 返回的是结构体枚举器,直接 foreach 时编译器按值使用它,全程在栈上;一旦把它当成 IEnumerable<T> 来遍历,就必须走接口调用,结构体枚举器被装箱到堆上。
后果很实际:一个返回 IEnumerable<T> 的属性或方法,调用方每帧 foreach 一次就是每帧一次分配。把返回类型从 IEnumerable<T> 改成 List<T>(或加一个返回结构体枚举器的重载)就能消掉。
「不捕获的 lambda 不分配」也值得记:编译器会把它缓存成一个静态委托实例,只在第一次创建。一旦捕获了任何外部变量(包括 this),缓存失效,每次都新建闭包对象。
// ❌ 调用方 foreach 时会装箱枚举器
public IEnumerable<Enemy> GetEnemies() => enemies;
// ✅ 返回具体类型,foreach 走结构体枚举器
public List<Enemy> GetEnemies() => enemies;
// ❌ 捕获了 threshold,每次调用都新建闭包
void Update() { list.RemoveAll(e => e.hp < threshold); }
// ✅ 不捕获,委托被缓存
static bool IsDead(Enemy e) => e.hp <= 0;
void Update() { list.RemoveAll(IsDead); }🚨 测量手段本身要先校准,否则会得出相反的结论。
GC.GetAllocatedBytesForCurrentThread() 在 Mono 下恒返回 0——装箱、字符串拼接这些必定分配的操作也报 0,是个看起来很合理的假结果。改用 GC.GetTotalMemory 的差值后,一开始跑 1000 次,装箱这个已知会分配的正对照读数仍然是 0,说明测量下限高于 24KB。这时若照着读数写文章,就会把「低于下限」误当成「不分配」。
提到 10 万次后正对照终于触发了 GC,负对照(new Vector3)依然是 0——两个方向都校准住了,读数才可信。而 foreach IEnumerable 恰好就是在 1000 次时读 0、10 万次时才暴露的那一项。
进阶Unity 里有哪些容易被忽略的隐式内存分配(GC Alloc)?
上一条测过的几类之外,还有:把值类型赋给 object 或接口参数会装箱;一些返回数组的 API(如 GetComponents、Physics.RaycastAll、Mesh.vertices)每次调用都新建数组——这类通常有 NonAlloc 版本或接收 List<T> 缓冲区的重载,改用它们就能消掉。
判断方法不是靠背清单,是用 Profiler 看 GC Alloc 那一列,按帧定位到具体调用栈。清单只用来解释 Profiler 指给你的那一行。
// ❌ 每次调用都新建数组
var hits = Physics.RaycastAll(ray);
var cols = GetComponents<Collider>();
// ✅ 复用缓冲区
private readonly RaycastHit[] buf = new RaycastHit[16];
private readonly List<Collider> colBuf = new List<Collider>();
int n = Physics.RaycastNonAlloc(ray, buf);
GetComponents(colBuf);调试用的 Debug.Log 字符串拼接即使在 Release 里被条件编译掉,参数的拼接本身仍然会执行,除非整句都包在条件编译里。
深入Draw Call 和 SetPass Call 有什么区别?哪些机制能减少它们?
Draw Call 是一次绘制命令;SetPass Call 是切换渲染状态(材质、Shader Pass)。真正贵的往往是后者 —— 状态切换会打断 GPU 流水线,所以「合并材质」通常比「合并网格」收益更大。
减少手段各有前提:静态批处理要求对象标记 Static 且共享材质,代价是内存中会保留合并后的网格;动态批处理有顶点数上限且只对小网格生效;GPU Instancing 要求同网格同材质,靠 per-instance 数据区分;SRP Batcher 不合并 Draw Call,而是把材质属性放进常量缓冲以减少状态切换,因此它对「同 Shader 变体、不同材质参数」的场景收益最大。
面试时如果只答「合并材质、用图集」,追问「SRP Batcher 是怎么起效的」就会露底 —— 它减少的不是 Draw Call 数量,很多人把这两件事混为一谈。
⚠️ 这一条没有在本机实测:批处理的实际效果要看具体渲染管线、平台与场景构成,在 batchmode 无图形环境里量不出有意义的数字。这里的说法来自官方文档,采信前请在自己的项目里用 Frame Debugger 确认。
进阶对象池为什么比反复 Instantiate/Destroy 快?只是省了内存分配吗?
不只是分配。Instantiate 要做的事包括:分配对象、反序列化 prefab 数据、创建组件、调用每个组件的 Awake 与 OnEnable。Destroy 则要走销毁回调并把内存交给 GC。
对象池省掉的是「分配 + 反序列化 + 构造」这一整段,只保留启用/禁用的开销。所以池化的收益在组件多、层级深的 prefab 上最明显。
代价是状态残留:从池里取出的对象带着上次使用的状态,必须显式重置。这是池化最容易出 bug 的地方,而不是性能本身。
⭐ 而「必须显式重置」有个容易被忽略的原因:Awake 只在对象第一次激活时执行一次,池化复用时不会再跑(见「生命周期与执行顺序」那一章的实测)。所以把重置逻辑写在 Awake 里,第二次取出就带着上次的残留;写在 OnEnable 里才会每次都执行。
// 取出时重置,不要指望 Awake 能覆盖——它只跑一次
var go = pool.Get();
go.transform.SetPositionAndRotation(pos, rot);
go.GetComponent<Bullet>().ResetState(); // 速度、生命周期、拖尾都要清
// 或者把重置放进 OnEnable,它每次启用都执行
void OnEnable() { speed = 0; lifetime = maxLifetime; trail.Clear(); }基础为什么不建议在 Update 里调用 Camera.main 和 GetComponent?
这条建议要分两半看,其中关于 Camera.main 的那一半已经过时了。
Camera.main 在 2020.2 之前确实是每次按 tag 现查,所以「别在 Update 里用」是对的。但从 2020.2 起 Unity 在内部加了缓存,它不再是查找操作。2022.3 编辑器实测(每项取 5 次中位数):
读缓存字段 6.7 ns
Camera.main 19.8 ns ← 只比读字段慢 3 倍
FindWithTag("MainCamera") 45.3 ns ← 老实现的等价物,反而更慢
transform.position 读 52 ns ← 比 Camera.main 还贵所以现在 Camera.main 的开销比读一次 transform.position 还小。缓存它仍然不亏,但它已经不是那个「一用就出事」的调用了,把它当成性能问题的首要嫌疑人会找错方向。
GetComponent 那一半仍然成立,而且有个更值得记的点:找不到时比找得到时贵得多。同一台机器实测,命中约 57 ns,而组件不存在时约 1027 ns —— 差 18 倍。所以「每帧 GetComponent 一个可能不存在的组件」是最坏的写法。
结论没变(在 Awake/Start 里取一次缓存起来),但理由变了:现在主要是为 GetComponent,不是为 Camera.main。
private Camera cam;
private Rigidbody rb;
void Awake() {
cam = Camera.main; // 仍然值得缓存,但不再是性能急救
rb = GetComponent<Rigidbody>(); // 这个才是重点
}
// 🚨 最坏的写法:每帧去找一个可能不存在的组件
void Update() {
var c = GetComponent<SomeRareComponent>(); // 找不到时 ~1027 ns
if (c != null) { /* ... */ }
}⚠️ 上面的数字是 2022.3 编辑器里测的,打包后(尤其 IL2CPP)绝对值会不同。可靠的是数量级关系和「找不到比找得到贵得多」这个方向,不是具体的纳秒数。自己的项目要下结论,还是用 Profiler 测自己的场景。