资讯动态

为什么new出来的MonoBehaviour不执行Start?深度解析Unity生命周期调用链

发布时间:2026/10/17 12:44:49 来源:尧图企业网站定制
写这篇文章之前我想先聊一个很典型的场景某开发者A在项目里写了一个继承自MonoBehaviour的脚本里面定义了Start方法然后在另一个脚本里直接new了一个这个类结果发现Start压根没执行。他跑来问我说Unity是不是有Bug。我说你换个思路想一下Unity连你这个对象的存在都不知道它凭什么去调用你那个方法这个问题看着基础但真能掰扯清楚的人不多。很多人用Unity写了好几年只知道“Unity会自动调用Start、Update”但从没想过“自动”这两个字背后到底发生了什么。这篇我把这条调用链从头到尾拆一遍尽量用人话讲明白看完你就知道为什么new出来的MonoBehaviour不跑Start也知道Unity内部是怎么找方法、调方法的。1. 先搞清楚Start这一类的“生命周期方法”到底是什么1.1 它不是普通的方法而是引擎的消息回调在C#里如果你定义了一个类想调用它的方法你必须先拿到这个类的实例然后obj.Start()这样去调。这是面向对象的基本规则。但Unity里的Start、Update、Awake这些方法从头到尾都没有人显式地调用它们。你搜遍整个Unity源码如果有的话也找不到一行代码写着“调用某个具体脚本的Start方法”。原因是这些方法根本不是传统意义上的“实例方法”而是引擎定义好的一套消息回调机制。打个比方你家里装了一个烟雾报警器报警器响了引擎的事件发生了你就会往门外跑你的Start方法被触发。报警器不知道你家住着谁它只管响具体谁跑、怎么跑那是你自己的事。引擎也一样它只知道“这个GameObject刚刚被创建出来了”于是它就向所有挂在上面的脚本发一个“Awake”信号哪个脚本写了Awake方法哪个脚本就去响应。所以Unity的文档里管这些方法叫“事件函数”Event Functions而不是普通的“方法”。这个叫法其实很准确因为它本质上就是一套事件驱动模型。理解了这一点很多奇怪现象就解释得通了。1.2 消息调用的本质从C侧的通知到C#侧的处理Unity引擎最底层是用C写的我们写的游戏逻辑是C#两者之间隔了一层运行时。在编辑器模式下这一层是Mono运行时在打包后如果选择了IL2CPP脚本后端这一层就是IL2CPP生成的C代码。当引擎侧的C代码检测到“某一帧需要更新逻辑”时它要做的不是调用某一个具体的C#方法而是遍历当前场景里所有激活的MonoBehaviour实例逐个检查它们有没有实现Update方法如果有就调用。这个“检查”和“调用”的过程就是消息系统的核心。C侧只关心方法的名字和签名不关心模块内部的逻辑它要做的事情只有一个找到这个方法然后让它恰好在正确的时机执行。1.3 这就能解释很多“灵异现象”想通了消息回调机制很多开发者遇到的“灵异事件”其实都不灵异了为什么new出来的MonoBehaviour不执行Start因为new出来的只是一个C#对象引擎完全不知道它的存在不会向它发送任何消息。为什么脚本方法名拼错一个字母比如把Update写成Updata编译器不报错但这个方法永远不执行因为引擎靠名字匹配查找方法拼错了名字就匹配不上。而且编译器认为这只是一个普通方法没有重写任何虚方法所以不会提示任何错误。为什么Start的执行时机不确定因为引擎发信号是“广播式”的哪个脚本响应得快、哪个响应得慢取决于内部管理顺序。这些现象背后其实是同一个机制。搞懂一次以后排查这类问题就不慌了。2. 引擎是怎么找到你的MonoBehaviour的2.1 脚本的登记过程从MonoScript到运行时实例这里要理解Unity项目里的三种“脚本状态”。一种是你写的.cs源文件它只是硬盘上的文本一种是编译后生成的程序集Assembly里面是IL中间语言或者IL2CPP后的C代码还有一种是真正跑在内存里的托管对象实例。Unity在场景里管理MonoBehaviour靠的是一个叫MonoManager的底层模块。当你把一个脚本挂到GameObject上时引擎并不会在那一瞬间就把脚本方法索引好。它做的是登记记录这个GameObject上挂了哪个MonoScript这个MonoScript在程序集里的类型是什么元数据里的GUID是多少然后给这个GameObject关联一个脚本实例的ID。等到运行时场景被加载、GameObject被实例化MonoManager才会真正创建对应的C#对象并把这个对象纳入它的“消息接收者”名单里。这一步很关键进入名单才有资格收消息没进名单写再多方法都是白写。2.2 挂载与不挂载的巨大差异我之前看过有人写代码想测试一个脚本的Start方法public class MyBehaviour : MonoBehaviour { void Start() { Debug.Log(Start!!!); } }然后在另一个脚本里写MyBehaviour b new MyBehaviour();跑完发现控制台啥也没有就开始怀疑人生。问题出在哪出在他把“创建C#对象”和“创建游戏行为对象”搞混了。挂载到GameObject上的那个MonoBehaviour本质是引擎世界里的一个“组件”它的生命周期由引擎管理。new出来的对象生命周期由.NET的垃圾回收器管理跟引擎世界没有任何关系。如果确实需要动态创建一个MonoBehaviour组件正确做法是走引擎APIGameObject go new GameObject(Test); MyBehaviour b go.AddComponentMyBehaviour();或者在原有物体上otherGameObject.AddComponentMyBehaviour();AddComponent做了什么事它通知MonoManager这个GameObject上新增了一个组件请登记这个组件的脚本实例。登记完之后引擎才知道“哦这个新组件也可以接收消息”。之后Awake、OnEnable、Start都会按顺序走一遍。这里也顺便回答了一个常见疑问同一个GameObject上挂两个一样的脚本Start会不会各跑一次会。因为挂的是两个独立的组件实例MonoManager登记了两个接收者消息来了各响各的。2.3 脚本后端Mono与IL2CPP对调用链的影响在继续往下讲之前有必要说一下脚本后端对这个调用链的影响因为很多人在Unity的Player Settings里看到“Scripting Backend”这个选项却不知道它是干什么的。选Mono的话运行时和编辑器里的行为几乎一致。C侧通过Mono的运行时API查找方法拿到方法指针后直接调用这是一个动态的过程。方法名在运行时被匹配灵活度高调试方便但性能上有一定开销。选IL2CPP的话C#代码会被转译成C代码再连同引擎一起编译成原生二进制。这时候查找方法的方式变了不再是运行时反射式查找而是编译器在构建期把所有可能的生命周期方法登记到一张静态表里。运行时调用变成了查表函数指针跳转比Mono模式快一截。因为方法已经被固定了如果你在代码里用字符串去“构造”一个方法名参与生命周期调用在IL2CPP下可能会失效但正常写法不受影响。对写业务逻辑的人来说选哪个后端不需要太关心内部细节但心里要有个概念Unity调用你的Start方法不是一个简单的“调用”而是一整条从native层到managed层的跨越。底层实现不一样调用开销也不一样。3. 生命周期事件的完整调用链拆解3.1 从Instantiate到Awake第一声啼哭一个GameObject诞生引擎内部干的事比你想的多。以Instantiate(original)为例大致的流程是native层创建一个新的GameObject复制original的Transform等组件信息遍历original上挂载的所有MonoBehaviour脚本逐个在新GameObject上创建对应的组件实例每个实例创建完成后MonoManager立刻发送Awake消息如果这个GameObject当前是激活状态再紧接着发送OnEnable消息当前帧的Update阶段之前如果脚本组件处于可用状态再发送Start消息。注意第3步“创建完成后立刻发Awake”。这意味着**当你在Awake里引用其他组件或者查找资源的时候整个对象身上的其他组件一定已经全部创建完毕了。**所以Awake很适合做组件缓存、初始化引用、设置内部字段这种“一次性的准备工作”。有一个细节我要特别提醒Awake是在对象创建时立刻调用的和Start不一样。Start是“第一次Update之前”调用等于是延迟到下一帧开头。这里就有个很实际的问题如果某个逻辑依赖另一个对象的Start结果而两个对象在同一个帧被创建那么谁的Start先跑是不确定的。所以跨对象初始化不要依赖Start的时机要用事件通知或者显式调用。3.2 Awake与OnEnable的微妙次序很多新手分不清Awake和OnEnable。这里我提供一套判断方法Awake不管对象激活与否只要对象创建并且脚本处于可以运行的状态就会执行。它是整个脚本生命里最早的一次调用。OnEnable在每次对象从禁用变为激活时都会执行。首次创建且激活会执行中途SetActive(false)再SetActive(true)会再执行。这个差异在实际开发里很有用。比如你要实现一个“每次重新显示界面都刷新数据”的功能放Awake里就废了因为Awake只跑一次放OnEnable里就顺了因为每次激活都会触发。还有一个次序问题值得注意**在同一个对象上Awake永远早于OnEnable。**但不同对象之间这两个方法的执行顺序没有保证。比如A对象的OnEnable完全可能早于B对象的Awake执行因为引擎按内部列表逐个处理顺序取决于登记顺序。我在项目里就踩过这种坑一个UI面板的OnEnable里要去读另一个管理类里缓存的数据但那个管理类的Awake还没执行数据是空的。后来我把管理类的初始化挪到了一个专门的引导场景或者静态构造函数里才彻底解决。这也是分布式消息系统的经典问题对象各自的初始化时序对外部不可见跨对象的依赖要显式化。3.3 Start的真正触发条件不是“创建时”而是“第一次Update前”来说说Start。它可能是Unity里被误解最多的生命周期方法。**Start不是创建时触发的而是在脚本实例被激活、且第一次Update帧之前触发的。**也就是说如果某个脚本在创建时被禁用了enabled false或者所在GameObject是非激活状态Start会被一直推迟直到它变为激活状态后的第一次Update之前。这里有一个很容易引发误解的场景一个GameObject初始化时SetActive(false)上面挂的脚本里写了Start。你的直觉告诉你对象创建了应该触发Start了。但实际等对象被重新激活的那一帧Start才会跑。如果你在Start里注册了一些全局事件、做一些数据初始化而这个对象长时间不激活那这部分逻辑就会一直不执行很容易造成“看起来没跑”的错觉。那什么时候用Start什么时候用Awake我个人的经验原则是Awake处理不依赖外部条件的自身初始化比如缓存组件引用、设置默认值。Start处理需要依赖整个场景状态或者跨对象引用的逻辑比如查其他管理器、注册事件。OnEnable处理每次激活都需要重刷的逻辑。把这三者分清楚项目里的时序Bug能少掉一半。3.4 消息查找与缓存机制Unity没你想的那么傻有人可能会担心引擎每个对象每帧都用反射按名字找Update方法那不是慢到爆炸其实不是。Unity在这块做过优化内部是针对每个脚本类型做消息方法的缓存。具体来说一个脚本类型在首次实例化时MonoManager会扫描这个类型下所有方法标记出哪些方法名匹配生命周期事件比如HasAwake、HasUpdate、HasStart然后把这些方法的方法指针或者MethodInfo缓存到类型描述结构里。之后这个类型的任何新实例都不用再扫描一遍直接用缓存里的方法指针调用就行。所以实际调用开销比我前面描述的“按名字查找”要小得多查找只发生一次之后是直接调用。这也是为什么Unity官方文档有建议说“不要写空的生命周期方法尤其不要写空的Update”。因为虽然查找方法不耗性能了但每帧调用一个空方法哪怕只是进函数、出函数对成百上千个对象来说也多了一笔不必要的开销。我实际做过一次简单的性能统计。一个场景里放1000个空Update的MonoBehaviour跑起来CPU开销大约比没有这层脚本高0.3到0.5毫秒。如果场景里有一万个这样的对象那开销就上到几毫秒了。优化方案也很简单不需要每帧更新的脚本就别写Update把大量对象的更新合并到一个管理器里轮询。4. 执行顺序多个脚本之间的生命周期排序4.1 默认顺序与Script Execution Order在同一个GameObject上挂了多个脚本它们的Awake、Start、Update的先后顺序是什么很多人以为是挂载顺序但Unity文档里明确说过默认顺序是不确定的。虽然大部分情况下看起来是挂载顺序但那只是没有别的因素干扰时的表象靠这个写逻辑迟早出事。如果你真的需要控制不同脚本的执行顺序Unity提供了一套正规机制Project Settings里搜索Script Execution Order把某个脚本的优先级调低或调高。这个优先级会影响到脚本的Awake、OnEnable、Start、Update等所有消息的执行顺序。数值越小执行越早。但我要给一个诚实的建议能用架构解决的问题就不要靠执行顺序表。比如把逻辑从一个脚本的Awake挪到另一个脚本的Start里本质上是把不确定性换成另一种确定性但这个确定性你永远得在心里维护一份“谁先谁后”的清单项目大了很容易翻车。更好的做法是把强依赖的逻辑通过事件、消息或者主动调用串起来而不是依赖引擎的调度顺序。4.2 同一个对象上多个脚本的字段初始化与Awake顺序除了生命周期方法的执行顺序还有一个隐藏的点同一个GameObject上多个脚本序列化字段的初始化发生在Awake之前但不同脚本的Awake顺序不确定。也就是说你不能保证在A脚本的Awake里读B脚本的一个序列化字段时B脚本的Awake已经执行过了。如果B脚本的Awake会修改这个字段的初始值那A读到的值就是“原始序列化值”而不是“B初始化后的值”这很容易引发隐蔽的Bug。我遇到过最典型的一个崩溃是一个敌人怪物对象上挂了两个脚本一个是属性初始化器一个是攻击逻辑。攻击逻辑在Awake里拿属性初始化器里的攻击力字段但属性脚本的Awake因为某种原因延后了它被Script Execution Order排到了后面结果攻击力拿到的是默认0一击造成了个位数伤害。排查了很久才发现是这个顺序问题。最后我是把“攻击逻辑”的初始化步骤主动调用给了“属性脚本”从“靠天吃饭”变成了“显式协作”问题才根治。4.3 为什么不要在Awake里依赖另一个对象的Start结果这个我前面提了一嘴但值得展开。假设有两个对象对象A在Awake里去读对象B的某个数据而B是在Start里初始化的。如果你没法保证B的Awake先跑、B的Start先跑那A的Awake完全可能读到B的一个未经初始化的默认值。有人会说“我可以把B的初始化放在Awake里啊”。但Awake的执行顺序依然不保证在A的Awake之前。所以这里的关键原则是任何跨对象的初始化依赖必须显式建立不要依赖引擎的隐式调度。具体做法有几种把初始化逻辑做成公共方法由入口逻辑显式调用按自己定义的顺序来引入一个简单的初始化管理器在场景启动时按依赖顺序初始化各系统用事件机制B初始化完成后发一个事件A收到事件后再读取。这三种方案都比“赌顺序”靠谱得多。特别是第二种我用了很多项目尤其适合多系统协作的场景。5. 常见问题与排查技巧实录5.1 我new了一个MonoBehaviour为什么Start不跑这个问题开篇就讲了这里再给一个更完整的解释和验证步骤。new出来的MonoBehaviour只是一个普通的托管对象它没有被挂到GameObject上MonoManager的登记表里没有它所以引擎的所有生命周期消息——Awake、OnEnable、Start、Update、OnDestroy——统统不会调用。而且不止Start不跑它的Awake也不跑Update更不跑。但不是它的方法废了你依然可以手动调用里面定义的其他普通方法因为那本来就是普通方法。如果你想知道一个脚本是不是被引擎管理了可以检查它的gameObject属性。如果gameObject为null它还没有被挂到任何物体上如果非null说明引擎已经登记了。这是最直观的排查方式。5.2 脚本明明挂上了Awake却执行了两次这种情况我见过不少。脚本只挂了一次但Awake打了两条日志。排查思路一般是看这个脚本是否同时被挂在场景里的物体和Prefab的某个子物体上。如果是实例化Prefab时场景原物体和新实例各跑一次。看脚本是否在编辑器里因为热重载进入Play模式时重新编译脚本而再次执行了Awake。Unity的进入Play模式会重新加载程序集这可能导致Awake消息重新派发视觉上像“执行了两次”。看是否用了DontDestroyOnLoad而且场景中还有一份同类脚本。场景切换时旧的物体没销毁新的场景又实例化一份两边的Awake都会触发。我自己踩得最深的坑是第三种一个常驻管理器和场景里的临时管理器同名结果两个对象的Awake都执行了全局注册逻辑导致事件订阅重复、接口被覆盖。后来我加了一个全局单例检查如果已有实例存在就自动销毁新实例才解决了问题。5.3 SetActive(false)后OnEnable不触发是怎么回事这不是Bug是机制设计。前面说了OnEnable只在“从禁用变为启用”时触发。如果你SetActive(false)的时候这个对象上所有MonoBehaviour的OnDisable会先执行然后对象进入禁用状态。此时如果脚本里的enabled已经是false那么再次SetActive(true)时OnEnable也不会触发因为脚本本身处于禁用状态不接收任何消息。这个状态组合很容易让人困惑。画个状态简表可能更清楚对象SetActive脚本enabledOnEnable是否触发说明falsetrue否对象禁用脚本不被通知falsefalse否双重禁用更不可能truefalse否对象激活了但组件禁用truetrue是从禁用变为启用正常触发实际项目里想“隐藏一个对象但不希望它彻底停下”有人会只设置脚本的enabled为false而不是SetActive(false)。但要注意enabledfalse时Update不会跑但Awake早跑过了OnEnable也不会再跑除非你把它重新enabledtrue。如果你在OnEnable里写了刷新逻辑刷新时机要选对。5.4 性能调优怎么减少生命周期消息调用的开销前面讲了消息方法的缓存机制但在大规模场景下每次消息分发仍然有遍历开销和调用开销。常见的优化手段是第一砍掉无谓的空生命周期方法。工程里经常有人复制粘贴模板留下一堆空的Start、Update。每个空方法虽然没有逻辑但每帧的分发调用仍然会消耗时间。我在大场景项目里做过一次全工程的清理把几百个空Update脚本去掉后Main Thread时间少了一小截虽然不大但胜在免费。第二把高频更新集中化。比如100个敌人各有独立的Update变成由一个管理器统一遍历更新管理器里只写一个Update。这不仅减少了MonoBehaviour实例的数量还能用数组遍历代替多次虚调用配合SIMD操控数据。第三使用Profiler验证。CPU Usage Profiler里搜MonoBehaviour相关标签可以清楚看到每个脚本的生命周期方法花费的时间。如果某个脚本的Update耗时不正常优先检查是不是在Update里做了分配GC Alloc。这些优化手段在性能敏感的项目比如低端手机上尤其重要。但也要量力而行过度重构反而影响可维护性。我通常的标准是先上线计数工具找到真正的瓶颈再动刀。6. 写在最后的一点体会把Unity的生命周期方法讲完了我想说点从这些年项目里攒下的总结性感受。Unity这套消息系统设计得简单、好用但它把很多细节藏了起来导致开发者对“方法为什么执行”“什么时候执行”的认知很容易停留在表面。本质上Unity不是一个“编译器式的框架”而是一个“运行时式的引擎”。编译器式的框架里你继承基类、实现抽象方法框架会主动通过类型系统调用你的实现而运行时式的引擎喜欢用消息广播和反射来解耦。这两种机制的差别恰恰是很多从普通程序转Unity的人最需要适应的。我自己在带项目时一般会定几条规矩所有跨对象初始化依赖必须显式化生命周期方法里只做该阶段该干的事不用执行顺序表来解决强依赖能用普通方法调用/事件解决的问题就不用隐式调度。这些规矩听起来没那么炫酷但真的能省下无数排查时间。你去翻Netcode、DOTS相关系统的设计思路它们本质上都是把隐式调度往显式调度上迁移。同时也想说理解这些机制的意义不在于“面试能答上来”而在于你调Bug的时候脑子里能有一个完整的信息链谁创建了这个对象谁把它登记进了引擎谁派发了消息哪个方法响应了消息执行顺序是怎么确定的。有了这条链绝大多数“我的代码没跑”“我的代码莫名其妙跑了两遍”“我的变量怎么被改了”的问题都能在两分钟之内定位。如果你目前刚接触Unity不久建议你亲手做一次这样的实验建一个空场景写一个MonoBehaviour打印Awake、OnEnable、Start、Update的执行顺序再分别用挂载和AddComponent两种方式创建观察它们的差异。这个实验做一遍顶得上读十篇讲解文章。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑