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")(未激活)        →  找不到,返回 null
FindObjectsOfType  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 测自己的场景。