资讯详情

资讯详情

Unity MonoBehaviour生命周期详解:从Awake到OnDestroy的调用顺序与实战避坑

做游戏这几年我见过太多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为falseAwake依然会跑。注意如果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里退订会怎么样假设一个敌人死亡时播放声音的脚本如果敌人被销毁了但没退订那么全局事件系统里还残留着这个脚本的委托。等到下一次有人触发OnPlayerDiedUnity会尝试调用那个已经销毁的MonoBehaviour上的方法轻则报MissingReferenceException重则内存一直被拖着不释放。这个问题在稍后的章节会再展开。由于OnEnable是可重复的所以不适合在这里做“只执行一次”的初始化比如向List里Add自身——如果你在OnEnable里Add在OnDisable里不Remove那么物体被禁用再启用一次List里就出现两个重复引用。这类逻辑务必配上OnDisable中的对称清理。2.3 Start一切就绪后的正式开工Start在所有Awake执行完毕之后、第一次Update之前触发并且只执行一次。用一句话总结Start是一切跨对象初始化的“安全窗口”。因为到了Start阶段场景内所有物体的Awake都已经执行完毕每个组件至少完成了自我初始化。此时你去GetComponent、FindObjectOfType或访问其他物体的公开字段都能拿到相对稳定的状态。但如果脚本初始状态是enabled falseStart不会被立即调用而是要等到脚本被启用后的第一个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 GetComponentAnimator(); 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、LateUpdate3.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为0FixedUpdate不再被调用因为它基于物理时钟而物理时钟被暂停了WaitForSeconds协程会停止计时因为它的底层依赖缩放后的时间这个特性导致一个经典的暂停实现方案把Rigidbody的模拟交给FixedUpdate暂停时物理自动冻结而UI和菜单逻辑放在Update里依然可以响应操作。但这也带来一个隐患如果你的“暂停”只是把timeScale设为0那么Update里的动画Tick、插值、延迟逻辑都需要自己额外判断是否应该跳过。更好的实践是游戏暂停时让UI管理类监听事件自己实现一个“游戏暂停”的布尔标记然后在所有需要暂停的逻辑里统一判断。毕竟imeScale0时虽然物理停了但很多自定义的计时逻辑还会照跑只靠timeScale并不能解决一切。4.4 被禁用的脚本到底还有哪些方法在执行这个问题值得单独拎出来说。脚本的enabledfalse本质上只是绕过了Update、FixedUpdate、LateUpdate这些更新回调但Awake和OnEnable都已经执行过了在启用期间。而OnGUI这类绘制方法不会因为enabledfalse而被禁止它依然会被引擎调用。GameObject.SetActive(false)的粒度比enabledfalse更大。一旦物体被SetActive(false)不仅其上所有脚本的Update不执行整个物体及所有子物体的渲染、碰撞、物理都会被停掉。此时OnDisable会被执行但OnDestroy不会因为物体只是被禁用并没有被销毁。两者的区别用一句话总结enabledfalse是让脚本沉睡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里跑去拿权限。把初始化的主动权集中到一处时序问题往往能大幅减少。这玩意儿不难但很磨人。希望这篇能帮你少走几个弯路。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →