做游戏这几年,我见过太多Unity新人被同一个问题折磨到崩溃——明明给物体挂了脚本,也赋值了引用,结果运行起来要么空引用、要么顺序错乱、要么在暂停状态下东西还在乱跑。追根溯源,八成都是没吃透MonoBehaviour脚本生命周期。这东西就像人的一天:早上起床、白天干活、晚上睡觉,顺序对了,一切顺理成章;顺序错了,全乱套。这篇文章直接把这套机制掰开揉碎,从初始化到销毁,从帧循环到协程,把每个方法的调用时机、背后的引擎逻辑,以及实战中容易踩的坑全部过一遍。无论你是刚入门的新手,还是被诡异Bug折磨的“老油条”,这篇都能给你派上用场。
1. 先把生命周期这盘棋摆明白
1.1 一张执行顺序图看清脚本的一生
MonoBehaviour的生命周期,说白了就是引擎在特定时机自动调用你脚本里那些约定俗成的方法。你不需要手动调用,引擎会在合适的节点替你调。搞懂这些方法的先后顺序,是排查一切时序问题的地基。
我用一个最简单的日志脚本演示一下,你在场景里随便挂到一个物体上,运行后看Console窗口就能直观看到顺序:
using UnityEngine; public class LifecycleDemo : MonoBehaviour { private void Awake() { Debug.Log("1. Awake"); } private void OnEnable() { Debug.Log("2. OnEnable"); } private void Start() { Debug.Log("3. Start"); } private void FixedUpdate() { Debug.Log("4. FixedUpdate"); } private void Update() { Debug.Log("5. Update"); } private void LateUpdate() { Debug.Log("6. LateUpdate"); } private void OnDisable() { Debug.Log("7. OnDisable"); } private void OnDestroy() { Debug.Log("8. OnDestroy"); } }运行起来,你会发现输出顺序基本是固定的:Awake先执行一次,紧接着OnEnable,再到Start,之后便是FixedUpdate、Update、LateUpdate这样循环往复。当物体被销毁或者场景切换时,OnDisable会先出现,随后才是OnDestroy。
这里我要特意强调一个容易误解的点:Awake、OnEnable、Start这三个初始化方法,顺序是铁律,但Start和OnEnable之间并不保证只差“一步”。比如脚本初始是禁用状态,OnEnable会延迟到脚本启用时才触发,而Start则延迟到第一次启用之前的帧里。下文的章节我会展开细讲。
1.2 为什么执行顺序比方法本身更值得关注
很多新手学生命周期,只关心“什么时候调用我”,却忽略了“我在哪个时机调别人”。这两个问题如果理不清,就会出现典型的时序Bug:脚本A在Awake里尝试读取脚本B的数据,但脚本B还没初始化完,于是拿到的是一堆默认值或空引用。
Unity引擎的脚本事件调用顺序虽然大致固定,但同一帧内不同脚本的调用顺序并不完全可控。具体来说,所有激活物体的Awake都会在场景加载时统一执行,但A物体和B物体的Awake谁先谁后,Unity并不保证——它是按内部加载顺序来的,而你无法直观感知这个顺序。
正因为如此,业内的通用做法是:不要在其他对象的Awake里依赖对方的状态。如果你想通过Find、GetComponent等方式建立跨对象引用,最好放到Start里做,因为Start执行时,所有对象的Awake都已经执行完了,字段至少完成了“自身初始化”,哪怕是默认值,也总比“还没出生”强。
这一节先把大局建立起来,接下来深入每个阶段的具体职责。
2. 初始化三兄弟:Awake、OnEnable、Start怎么分工
2.1 Awake:一生只喊一次
Awake是脚本出生后的第一个标记点,它在脚本实例被加载时触发,并且在整个生命周期里只执行一次。只要挂载脚本的GameObject当前处于激活状态,Awake就一定会执行,即使脚本本身的enabled为false,Awake依然会跑。注意,如果GameObject一开始是未激活的,Awake会被延迟到物体第一次被激活时才执行。
所以Awake适合做哪些事?适合做与外部对象无关的自身初始化:
- 缓存自身组件引用(例如GetComponent)
- 初始化私有字段的默认状态
- 设置单例引用
- 加载不依赖场景顺序的资源
一个很典型的例子是单例:
public class GameManager : MonoBehaviour { public static GameManager Instance { get; private set; } private void Awake() { if (Instance != null && Instance != this) { Destroy(gameObject); return; } Instance = this; DontDestroyOnLoad(gameObject); } }为什么单例初始化要放Awake而不是Start?因为其他脚本可能在Start甚至更早的Awake里就要访问GameManager.Instance,如果单例放到Start才赋值,那么那些在Awake里访问单例的脚本就会拿到空引用。把单例赋值放到Awake,就能保证同一批加载的物体在其他脚本执行Awake时,已经能看到这个单例。
这里有个细节:Awake里不要尝试读取其他物体的Awake里才赋值的字段。比如两个物体互相引用,你在Awake里设置自己的状态,然后在同一帧的另一个Awake里去读取对方,这个行为本质上是赌博,顺序对了万事大吉,顺序反了直接空引用。规范做法是在Start里做跨对象初始化。
2.2 OnEnable:激活即触发,反复执行
OnEnable的触发时机和Awake类似,但有一个显著区别:它是“可重复”的。每当挂载脚本的GameObject被激活,或者脚本自身的enabled从false变为true时,OnEnable就会执行一次。
既然OnEnable可能执行多次,那它就非常适合做“订阅”操作:
private void OnEnable() { EventManager.Instance.OnPlayerDied += HandlePlayerDied; } private void OnDisable() { EventManager.Instance.OnPlayerDied -= HandlePlayerDied; }这个模式非常经典:在OnEnable里订阅事件,在OnDisable里退订。这样做的好处是,当物体被隐藏、禁用或销毁时,事件源不会继续持有已经失效的委托引用,避免内存泄漏和“回调已销毁物体”的经典错误。
如果你在OnEnable里做了订阅,却不在OnDisable里退订,会怎么样?假设一个敌人死亡时播放声音的脚本,如果敌人被销毁了但没退订,那么全局事件系统里还残留着这个脚本的委托。等到下一次有人触发OnPlayerDied,Unity会尝试调用那个已经销毁的MonoBehaviour上的方法,轻则报MissingReferenceException,重则内存一直被拖着不释放。这个问题在稍后的章节会再展开。
由于OnEnable是可重复的,所以不适合在这里做“只执行一次”的初始化,比如向List里Add自身——如果你在OnEnable里Add,在OnDisable里不Remove,那么物体被禁用再启用一次,List里就出现两个重复引用。这类逻辑务必配上OnDisable中的对称清理。
2.3 Start:一切就绪后的正式开工
Start在所有Awake执行完毕之后、第一次Update之前触发,并且只执行一次。用一句话总结:Start是一切跨对象初始化的“安全窗口”。
因为到了Start阶段,场景内所有物体的Awake都已经执行完毕,每个组件至少完成了自我初始化。此时你去GetComponent、FindObjectOfType或访问其他物体的公开字段,都能拿到相对稳定的状态。
但如果脚本初始状态是enabled = false,Start不会被立即调用,而是要等到脚本被启用后的第一个Update之前才会执行。对于GameObject一开始就是未激活的情况,同理——整个脚本的所有生命周期方法都会顺延到物体被激活的那一刻。
这就意味着,如果你有一个物体从一开始就隐藏,直到某个时机才显示,那么它的Start会晚于场景内其他物体的Start。如果你在场景加载时就尝试读取这个物体的数据,拿到的将是未初始化的状态。这是一个很常见的坑:场景里隐藏了一个敌人,玩家走到某个地方才激活它,但你在游戏一开始就对所有敌人做了数据统计——这时隐藏敌人的Start还没执行,统计数据自然就错了。
Start最典型的用途就是缓存引用和初始化逻辑。一个经验法则是:
- 自身组件的缓存用Awake
- 跨对象引用、依赖其他物体状态的初始化用Start
- 动态启用/禁用时的行为用OnEnable/OnDisable
这样分工,能最大限度规避内存序和调用序带来的不确定性。
2.4 三者配合的实战顺序
为了更直观地理解三者的配合,看下面这个场景。假设场景里有两只怪物,A和B,它们都挂了如下脚本:
public class Monster : MonoBehaviour { private Animator _animator; private void Awake() { _animator = GetComponent<Animator>(); Debug.Log($"{name} Awake"); } private void OnEnable() { Debug.Log($"{name} OnEnable"); } private void Start() { Debug.Log($"{name} Start"); } }运行后,输出可能有以下两种模式:
A Awake B Awake A OnEnable B OnEnable A Start B Start也可能:
B Awake A Awake B OnEnable A OnEnable B Start A Start但有一种输出是绝对不可能出现的:A先Awake、再Start,然后B才Awake。因为Unity的规则是“所有Awake先扎堆执行,再统一执行OnEnable,最后统一执行Start”。这一点务必记住,它能解释绝大部分“为什么我在这里找不到别人”的困惑。
3. 更新循环三兄弟:FixedUpdate、Update、LateUpdate
3.1 Update:每帧都来找你
Update是游戏逻辑的主战场,几乎每一帧都会执行一次,频率完全取决于游戏帧率。在60FPS的机器上每秒执行约60次,在30FPS的机器上每秒执行约30次。帧率越高,时间间隔越短,这注定了不能在Update里依赖固定频率。
在Update里做任何操作都要乘以Time.deltaTime,这是为了把“每帧的增量”换算成“每秒的值”。比如移动物体:
private void Update() { float horizontal = Input.GetAxis("Horizontal"); float vertical = Input.GetAxis("Vertical"); Vector3 moveDir = new Vector3(horizontal, 0f, vertical).normalized; transform.position += moveDir * speed * Time.deltaTime; }如果没有乘以Time.deltaTime,同样的speed数值在60FPS和30FPS下表现出的速度完全不同,高刷屏上会飞一样地跑,低刷屏上却慢如蜗牛。Time.deltaTime拿到的就是“上一帧到这一帧所经历的真实时间”,所以用它来修正增量,可以保证每秒移动的绝对距离一致。
如果游戏运行在120Hz的显示器上,Update调用频率会随之升高,但Time.deltaTime会相应变小,最终位移结果还是稳定的。这也是为什么“帧率无关”一直是游戏开发的基本要求。
另外要记住:Input的读取放在Update里是没问题的,物理相关的操作则应避免在这里直接做。你可能会习惯性地在Update里给Rigidbody施加力,偶尔没问题,但在帧率不稳的平台上,物理表现会变得难以预测。正确的做法是丢给FixedUpdate。
3.2 FixedUpdate:物理世界的稳定节拍
FixedUpdate与Update最大的不同在于,它的调用频率是固定的,不受帧率影响。默认情况下,Unity每0.02秒调用一次FixedUpdate,也就是每秒50次。你可以通过Time.fixedDeltaTime来调整这个周期,但一般不建议乱改。
物理引擎的模拟步长正好就绑定在FixedUpdate上。这意味着,所有涉及Rigidbody的力、速度、扭矩,以及物理射线检测后的响应逻辑,都应该放在FixedUpdate里:
private void FixedUpdate() { Vector3 moveDir = new Vector3(Input.GetAxis("Horizontal"), 0f, Input.GetAxis("Vertical")); _rigidbody.AddForce(moveDir * moveForce); }把物理移动放进FixedUpdate,一个额外的好处是:帧率再低,物理模拟也不会跳步。当游戏掉帧时,Update的调用次数减少,但FixedUpdate依然按固定节奏执行,物理世界的步调始终稳定。
这里还有一个容易混淆的细节:在FixedUpdate里使用Time.deltaTime,拿到的并不是真实帧间隔,而是Time.fixedDeltaTime,也就是固定的0.02秒。所以你在FixedUpdate里写“乘Time.deltaTime”也不会出大错,因为它拿到的本来就是固定值。但为了语义清晰和可读性,建议在FixedUpdate里明确使用Time.fixedDeltaTime。
如果游戏逻辑混用这两个更新函数,务必想清楚各自职责:
| 方法 | 调用频率 | 推荐用途 |
|---|---|---|
| FixedUpdate | 固定频率(默认每秒50次) | 物理模拟、刚体驱动、定时步进逻辑 |
| Update | 每帧一次,频率不固定 | 输入处理、状态切换、非物理逻辑 |
| LateUpdate | 每帧一次,在Update之后 | 相机跟随、插值平滑、依赖最终状态的修正 |
3.3 LateUpdate:镜头和跟拍的黄金时机
LateUpdate和Update一样,每帧都会执行,但它在所有Update调用完毕之后才执行。这意味着,当LateUpdate运行的时候,所有脚本的Update逻辑都已经跑完了,物体的最终位置已经确定。
这个特性决定了LateUpdate是处理相机跟随的最佳位置。假如在Update里写相机跟随,可能发生这样的情况:物体A在Update里移动,相机在Update里跟随,但同一帧里其他脚本又修改了物体的位置,导致相机画面出现抖动或微小的滞后。而把相机逻辑放进LateUpdate,就能保证“先有物体最终姿态,再有镜头捕捉”。
private void LateUpdate() { // 平滑跟随目标,物体Update里已经移动完毕 Vector3 targetPos = _target.position + _offset; transform.position = Vector3.Lerp(transform.position, targetPos, _smoothTime); transform.LookAt(_target); }LateUpdate同样适合做“最终修正”,比如骨骼动画结束后调整手部位置、更新UI的血条位置、把世界坐标转换到屏幕坐标等。凡是希望在一帧结束时基于最新状态做处理的逻辑,都可以考虑放这里。
3.4 deltaTime的正确打开方式
围绕deltaTime的迷思其实不少。我见过有人把Time.deltaTime在Update里当成固定值来用,也见过有人为了“让物体匀速移动”在FixedUpdate里使用Time.deltaTime而沾沾自喜,实际上拿到的是fixedDeltaTime。
需要明确的核心概念是:Time.deltaTime的含义取决于你当前处于哪个生命周期回调里。在Update/LateUpdate中,它是上一帧耗时;在FixedUpdate中,它是固定步长;在MonoBehaviour外部,它默认就是上一帧耗时。理解这一点,就不会再把两者混为一谈。
不过要说实话,在绝大多数游戏项目中,Update配合Time.deltaTime做移动,FixedUpdate配合Time.fixedDeltaTime做物理,二者各管一摊,基本不会出问题。真正的坑反而是:在Update里调用Rigidbody.MovePosition,又在FixedUpdate里直接改Rigidbody.velocity,两者互相打架,最终表现飘忽不定。
4. 生命周期里的隐藏细节:销毁、禁用与异常
4.1 OnDisable与OnDestroy:体面离场
物体从场景中移除或应用退出时,Unity会依次调用OnDisable和OnDestroy。OnDisable在物体被禁用或销毁时触发,OnDestroy则在实际销毁内存对象之前触发。很多人以为只有“销毁”才会触发OnDisable,其实物体SetActive(false)也会触发OnDisable。
在OnDisable里适合做清理工作,比如停止协程、取消事件订阅、关闭定时器等。而OnDestroy则适合做更彻底的释放工作。
但要特别小心:OnDestroy的调用时机并不保证场景内的其他对象都还活着。在场景切换时,Unity会逐个销毁场景内的GameObject,有些对象的OnDestroy可能在你的OnDestroy执行前就已经跑完了。所以不要在OnDestroy里依赖其他场景对象的引用,也不要在OnDestroy里尝试访问其他物体的Transform。
我曾经排查过一个疑难Bug:某个玩家角色在死亡时播放动画,动画回调里引用了一个GlobalManager,结果玩家在场景切换的同时被销毁,GlobalManager已经先一步被销毁了,于是播放动画的回调报MissingReferenceException,而且这个异常不是每次都能复现,特别难查。后来把引用改成在Awake里缓存并做了空判断,问题才彻底消失。
4.2 协程与生命周期的纠缠
协程和MonoBehaviour生命周期之间有着天然的绑定关系。协程通过StartCoroutine启动,它依附于脚本所在的MonoBehaviour。一旦脚本被禁用(enabled设为false),协程并不会立刻停止,但如果你在OnDisable里没有显式停止,协程会继续运行——这是很多人忽视的细节。更严格地说,当GameObject被销毁时,该物体上所有协程都会随之终止,但脚本禁用时,协程行为则取决于是否依赖Update。
这里有几种常见场景:
- 脚本禁用后,协程若在等待帧或时间(yield return null、yield return new WaitForSeconds),它依然会继续执行,因为WaitForSeconds不受MonoBehaviour启停影响。
- 只有在脚本被销毁时,协程才会强制终止。
- 如果想让协程随脚本禁用而停止,必须在OnDisable里用StopAllCoroutines。
一个常见的设计模式是在OnEnable里启动协程,在OnDisable里停止协程,这样就能保证协程的生命周期和物体激活状态完全一致:
private void OnEnable() { _coroutine = StartCoroutine(KeepAlive()); } private void OnDisable() { if (_coroutine != null) { StopCoroutine(_coroutine); _coroutine = null; } } private IEnumerator KeepAlive() { while (true) { yield return new WaitForSeconds(1f); // 执行周期逻辑 } }还有一点容易被忽略:调用StopAllCoroutines会停止脚本中所有协程,但不会影响其他脚本启动的协程。如果你需要在多个协程之间精确控制某一个,最好单独保存Coroutine引用。
4.3 timeScale与冻结的边界
Time.timeScale是全局时间缩放,很多游戏用它实现暂停或子弹时间效果。但必须意识到,它影响的并不是所有生命周期方法。
当Time.timeScale = 0时:
- Update和LateUpdate依然每帧执行
- Time.deltaTime会变成0(准确地说,缩放后的deltaTime为0)
- FixedUpdate不再被调用(因为它基于物理时钟,而物理时钟被暂停了)
- WaitForSeconds协程会停止计时,因为它的底层依赖缩放后的时间
这个特性导致一个经典的暂停实现方案:把Rigidbody的模拟交给FixedUpdate,暂停时物理自动冻结,而UI和菜单逻辑放在Update里,依然可以响应操作。但这也带来一个隐患:如果你的“暂停”只是把timeScale设为0,那么Update里的动画Tick、插值、延迟逻辑都需要自己额外判断是否应该跳过。
更好的实践是:游戏暂停时,让UI管理类监听事件,自己实现一个“游戏暂停”的布尔标记,然后在所有需要暂停的逻辑里统一判断。毕竟imeScale=0时虽然物理停了,但很多自定义的计时逻辑还会照跑,只靠timeScale并不能解决一切。
4.4 被禁用的脚本到底还有哪些方法在执行
这个问题值得单独拎出来说。脚本的enabled=false,本质上只是绕过了Update、FixedUpdate、LateUpdate这些更新回调,但Awake和OnEnable都已经执行过了(在启用期间)。而OnGUI这类绘制方法不会因为enabled=false而被禁止,它依然会被引擎调用。
GameObject.SetActive(false)的粒度比enabled=false更大。一旦物体被SetActive(false),不仅其上所有脚本的Update不执行,整个物体及所有子物体的渲染、碰撞、物理都会被停掉。此时OnDisable会被执行,但OnDestroy不会,因为物体只是被禁用,并没有被销毁。
两者的区别用一句话总结:enabled=false是让脚本沉睡,SetActive(false)是让整个物体连同子物体一起隐形停机。
实战里最常见的场景是:一个怪物死亡的逻辑,如果你把它“禁用”而不是“销毁”,它的更新停止、碰撞失效、渲染消失,但它的内存依然被场景持有。如果你不断生成怪物又不销毁,场景里累积的GameObject会越来越多,最终造成内存膨胀。所以在“禁用”和“销毁”之间做选择时,一定要想清楚你到底是想要临时隐藏,还是彻底清理。
5. 生命周期引发的经典Bug与排查思路
5.1 空引用:时序之坑
空引用是Unity开发中最常见的异常之一,而它的根源常常就是生命周期时序问题。我举一个最典型的例子:
脚本A挂在主角身上,Awake里调用了一个全局管理器的方法;但全局管理器的单例是在它自己的Awake里赋值的。Unity不保证脚本A的Awake一定晚于全局管理器的Awake,一旦脚本A先执行,它拿到的单例就是null,然后就报空引用。这种Bug的可怕之处在于,它不一定会100%复现,因为每次进入场景时脚本的加载顺序可能略有不同。
排查办法很简单:把所有初始化日志打印出来,看清到底是哪个方法先执行。如果确认是顺序问题,解决方案通常有两个方向:
- 把跨对象引用的获取改到Start里
- 或用事件机制,让全局管理器初始化完成后广播消息,其他脚本再接消息执行
千万不要靠“改脚本加载顺序”或者“在Inspector里手动调整脚本优先级”来碰运气,那只是治标不治本。
5.2 单例与销毁顺序的噩梦
DontDestroyOnLoad这个函数,让一个物体在场景切换时得以保留,表面上很方便,但它引入了一个隐蔽的坑:如果场景里有两个“GameManager”,一个已经在DontDestroyOnLoad里活了很久,另一个是场景里手动挂上去的,那么你可能会在Awake里把自己重复的实例销毁掉,但销毁时机和场景切换顺序可能造成混乱。
我处理这种问题的方法是在Awake里做如下判断:
if (_instance != null && _instance != this) { Destroy(gameObject); return; } _instance = this; DontDestroyOnLoad(gameObject);这样能避免重复单例,但仍然存在一个隐患:如果某个场景中的物体在OnDestroy里访问了这个单例,而场景切换顺序是先销毁场景物体、后保留DontDestroyOnLoad的物体,那单例依然活着,没问题;可如果切换过程中引擎先销毁了单例所在物体,那么场景物体继续访问单例就会遇到MissingReferenceException。
所以我的建议是:在OnDestroy里访问单例时,一定要加上null判断或使用TryGetComponent之类的方式,而不是假设单例永远存在。一旦缺失,就静默处理或者记录下来,至少不要让异常炸出来。
5.3 重复订阅与内存泄漏
前面提到过OnEnable里订阅、OnDisable里退订的标准模式,这里再展开讲一下为什么它会引发内存泄漏。
事件系统本质上是委托的持有链:事件源持有一个委托列表,当它触发事件时,会遍历列表并调用每个委托。如果你的脚本在OnEnable里订阅了事件,但在OnDisable里没有退订,那么当物体被隐藏或销毁时,事件源依然持有这个脚本实例的委托引用。如果物体已经销毁,脚本实例变成了Unity里的“空壳”,但托管引用仍然存在,GC无法回收——这在Unity里被通俗地称为“内存泄漏”。
实际上,它比传统意义上的内存泄漏更隐蔽:因为有“伪引用”的存在,物体虽然销毁了,但你的游戏逻辑还会试图调用它,于是每隔一段时间就会跳出一个MissingReferenceException。
对比一下正确与错误的做法:
// 错误:只订阅不退订 private void OnEnable() { EventManager.Instance.OnEnemyDied += Die; } // 正确:成对出现 private void OnEnable() { EventManager.Instance.OnEnemyDied += Die; } private void OnDisable() { EventManager.Instance.OnEnemyDied -= Die; }如果你实在记不住成对订阅,还有一个替代方案:在事件回调里判断this == null或使用UnityEvent的容错机制,但这终归是补救措施,推荐的做法永远是退订。
5.4 排查利器:日志与执行顺序验证
排查生命周期问题最直接的手段,就是像文章开头那样打印日志。但你不用每条都打,我通常会在初始化三个方法和消失两个方法里打印,然后观察几帧:
- Awake是否按预期触发
- OnEnable是否在每次SetActive时正常触发
- Start是否在某次禁用后延迟触发
- OnDisable是否在销毁前触发
- OnDestroy是否在场景切换时触发
另一个非常实用的技巧是:在Console窗口里勾选“Collapse”和“Stack Trace”,把日志分组并显示调用栈,这样你能看出这个方法是谁在哪个生命周期回调里触发的。很多诡异的问题,其实只要看到调用栈,原因就一目了然了。
另外,不要忽略Unity的断点调试。你可以在某个生命周期方法的第一行打断点,然后逐帧执行,观察变量的变化。对于时序类Bug,这种调试方式比日志更快。
如果你已经用日志确认了顺序无误,但问题依旧,那就该怀疑是不是同一个物体的多个脚本之间存在隐式依赖。这时可以打开Inspector面板,检查脚本执行顺序(Script Execution Order),但不到万不得已,不建议依赖它来解决逻辑问题。脚本顺序设置得越复杂,项目越难以维护。
结尾
说实话,生命周期这套机制,我陆陆续续看了很多遍,每次觉得“我懂了”,总会在下一个项目里再踩一次新坑。后来我养成了一个雷打不动的习惯:每写一个新的MonoBehaviour组件,先随手把生命周期相关的方法打印出来看一眼执行序列,确认过再继续写业务逻辑。这个习惯帮我省下的排查时间,真的不是一点半点。
最后再分享一个小技巧:如果你需要在某个物体上挂多个脚本,而这些脚本之间有先后依赖,不妨在主脚本的Start里去做依赖注入和注册,而不是让每个脚本各自在Awake里跑去拿权限。把初始化的主动权集中到一处,时序问题往往能大幅减少。
这玩意儿不难,但很磨人。希望这篇能帮你少走几个弯路。