MonoBehaviour 生命周期

理解 Awake、Start 与逐帧回调的执行边界,正确处理初始化、物理更新、事件订阅、禁用与销毁。

难度 进阶 时长 标准深度约 14分钟
版本 Unity 6.6
what

MonoBehaviour 是可挂到 GameObject 上的脚本组件基类。Unity 会在对象状态变化和 PlayerLoop 的指定阶段调用名称与签名匹配的消息方法。

trap

不同对象的同类回调没有默认顺序,OnEnable 可以重复,而禁用 MonoBehaviour 也不会自动停止它启动的协程。

fix

把自身初始化放在 Awake,按同一生命周期边界配对获取与释放,并用显式依赖取代跨对象的隐含执行顺序。

是什么,为什么存在

MonoBehaviourBehaviour 的子类,也是一种 Unity 组件(Unity component) 。继承它的 C# 类可以作为脚本组件挂到 游戏对象(GameObject) 上,接收 AwakeOnEnableStartUpdate 等引擎消息。并非所有 Unity 脚本都继承它:编辑器工具、ScriptableObject、普通 C# 类和 DOTS 系统都有不同的基类或入口。

Unity 已经拥有自己的 PlayerLoop,因此场景脚本通常不自行编写顶层 while 循环。MonoBehaviour 提供的是接入点:对象加载时初始化,启用时取得活动期资源,每帧执行游戏逻辑,禁用或销毁时释放所有权。引擎控制调用时机,脚本负责让每个回调保持明确、短小且可重复推理。

这种模型解决了场景对象与运行循环之间的连接问题。设计者可以在 Inspector 中配置组件,运行时则由对象的活动状态、组件的启用状态和 PlayerLoop 阶段决定哪些消息会到达。你在角色控制、相机、UI、场景服务、对象池和协程所有者中都会遇到这套生命周期。

生命周期不是全局初始化调度器。它能保证一个实例内部的一些先后关系,却不会默认保证两个 GameObject 上的 Awake 谁先运行。依赖另一个对象已经就绪时,需要显式的引导阶段、注册事件、构造数据或受文档约束的执行顺序。

工作原理

Unity 按约定识别一组消息名称与签名。这些方法不是 MonoBehaviour 上等待重写的虚方法,所以通常写成 private void Awake(),而不是 override。拼错名称或写错参数时,C# 类型系统不一定能提醒你;方法可能正常编译,却永远收不到引擎调用。

三个状态共同决定消息是否到达:GameObject 是否在层级中活动,Behaviour.enabled 是否为 true,以及实例此前经历过哪些阶段。Behaviour.isActiveAndEnabled 同时反映前两项。把「对象存在」「对象活动」「组件启用」混成一个布尔概念,是生命周期错误的常见起点。

典型首次运行路径如下。图中只表达最常用的脚本消息,不是完整 PlayerLoop,也不表示不同实例之间存在默认顺序。

初始化阶段

Awake 在一个实例的生命周期中最多运行一次,适合建立同一 GameObject 上的引用、创建私有运行时状态和验证序列化配置。活动 GameObject 上的组件即使 enabledfalse,仍会收到 Awake;初始非活动的 GameObject 则会把 Awake 推迟到首次激活。不要把「场景加载」误写成对所有实例都立刻调用 Awake

OnEnable 在组件变为活动且已启用时调用。它会在首次活动时运行,也会在每次禁用后重新启用时再次运行。因此,它适合注册只在活动期存在的监听器、开始活动期工作或重置可重复状态,不适合无条件追加只能创建一次的数据。

Start 只在实例生命周期中运行一次,而且要等组件启用后,才会在首次帧更新之前运行。首次路径通常是 AwakeOnEnableStart;之后重新启用只会再次调用 OnEnable。如果 Start 建立订阅而 OnDisable 删除订阅,组件重新启用后不会自动恢复它。

对于场景开始时已经活动的对象,Unity 会在任何 Start 之前完成这些对象的 Awake。这个保证不能扩展成整个应用的永久阶段:运行中实例化的新对象会产生新的 AwakeOnEnable。代码应依赖局部契约,不应假定「所有 Awake 永远已经结束」。

每帧阶段

FixedUpdate 按固定时间步调度。一个渲染帧之前可能没有固定步,也可能补做多个固定步;它不是「每帧恰好一次」。通过 Rigidbody 施加力或写入物理状态时,应遵守物理系统的固定步边界,并使用 Time.fixedDeltaTime 表达需要显式时间尺度的计算。

Update 在活动且已启用的行为上每个渲染帧调用一次。输入采样、非物理状态机和按 Time.deltaTime 推进的视觉逻辑通常放在这里。每帧路径中的场景搜索、组件查询、日志和分配都应由 Profiler 数据判断,而不是靠「Update 一定很慢」这样的口号。

LateUpdate 在常规 Update 阶段之后运行,常用于读取本帧已经更新的目标姿态,例如相机跟随。它只提供阶段边界,不会自动解决平滑、物理插值或不同 LateUpdate 脚本之间的顺序。相机仍需要明确目标、缺失策略和时间模型。

一次性的输入动作需要在 UpdateFixedUpdate 之间保存。若只在某次 Update 中把跳跃标志设为真,又在固定步消费后清除,就能避免渲染帧与物理步数量不同造成漏读或重复。持续方向则可以保存最新值,让每个固定步读取同一命令。

停用与销毁

OnDisable 在组件从活动且启用变为不满足该条件时调用,包括把组件禁用、把所属 GameObject 设为非活动以及销毁活动对象。这个消息可能出现多次,所以清理逻辑应能安全重复。与 OnEnable 配对的订阅、注册和临时句柄应在这里释放。

禁用 MonoBehaviour 不等于停止它启动的 协程(coroutine) 。如果活动期结束后协程不应再改状态,就在 OnDisable 中通过保存的 Coroutine 句柄显式停止它。把 GameObject 设为非活动会停止其协程,但重新激活不会从原位置恢复已停止的迭代器。

OnDestroy 适合释放真正属于实例生命周期、而非某次启用周期的资源。Unity 只会为此前活动过的 GameObject 上的对象调用它,因此它不应成为未激活配置对象的唯一安全网。原生句柄、文件和外部连接还应有自己的明确所有权协议,不能只依赖场景退出时的回调。

示例

下面四个组件逐步展示生命周期本身、活动期订阅、固定步物理和后更新相机。它们需要 Unity 运行时和场景配置。本地环境没有 Unity Editor、UnityEngine 程序集或 C# 编译器,因此每个块都明确标记为未执行,输出栏不会伪造日志。

记录一个实例的状态变化

先给空 GameObject 挂上 LifecycleTrace,在 Inspector 中切换组件与对象状态。它不会在 Update 中持续刷日志,因此输出只对应生命周期边界。

LifecycleTrace.cs
// # not executed here: Unity Editor 6.6 and UnityEngine are unavailable.
using UnityEngine;

public sealed class LifecycleTrace : MonoBehaviour
{
    private void Awake() => Write("Awake");

    private void OnEnable() => Write("OnEnable");

    private void Start() => Write("Start");

    private void OnDisable() => Write("OnDisable");

    private void OnDestroy() => Write("OnDestroy");

    private void Write(string message)
    {
        Debug.Log($"{name}: {message}", this);
    }
}
# not executed here: Unity Editor 6.6 and UnityEngine are unavailable.

一个初始活动且已启用的实例会先经过 AwakeOnEnableStart。之后禁用再启用,只会增加一组 OnDisableOnEnable,不会再次打印 AwakeStart。不同对象的日志可能交错,不能据此发明跨对象保证。

这个探针适合临时诊断,不应成为业务逻辑依赖。若测试需要验证顺序,把消息记录为结构化条目,并只断言文档保证的偏序关系。

让订阅跟随活动期

SceneLoadLogger 只在自身活动时接收场景加载事件。获取和释放位于对称的回调中,所以多次启用不会留下重复订阅。

SceneLoadLogger.cs
// # not executed here: Unity Editor 6.6 and UnityEngine are unavailable.
using UnityEngine;
using UnityEngine.SceneManagement;

public sealed class SceneLoadLogger : MonoBehaviour
{
    private void OnEnable()
    {
        SceneManager.sceneLoaded += HandleSceneLoaded;
    }

    private void OnDisable()
    {
        SceneManager.sceneLoaded -= HandleSceneLoaded;
    }

    private void HandleSceneLoaded(Scene scene, LoadSceneMode mode)
    {
        Debug.Log($"loaded: {scene.name} ({mode})", this);
    }
}
# not executed here: Unity Editor 6.6 and UnityEngine are unavailable.

选择 OnEnableOnDisable,就是声明「禁用期间不接收通知」。如果需求要求隐藏组件仍然监听,应该改用更长的所有权边界,并写出相应的释放路径;不要只把取消订阅挪到别处而不说明生命周期。

使用 -= 删除当前未注册的相同处理器是安全的,这让清理易于保持幂等。不过,对称性仍很重要:在 OnEnable 中用 lambda 订阅、再在 OnDisable 中创建另一个 lambda,并不能移除原来的委托实例。

在固定步驱动 Rigidbody

RigidbodyDriver 接收外部输入适配器保存的最新方向,并在 FixedUpdate 中向本对象的刚体施力。它不关心输入来自旧输入系统、新 Input System 还是 AI 控制器。

RigidbodyDriver.cs
// # not executed here: Unity Editor 6.6 and UnityEngine are unavailable.
using UnityEngine;

[RequireComponent(typeof(Rigidbody))]
public sealed class RigidbodyDriver : MonoBehaviour
{
    [SerializeField, Min(0f)]
    private float acceleration = 12f;

    private Rigidbody body;
    private Vector2 moveInput;

    private void Awake()
    {
        body = GetComponent<Rigidbody>();
    }

    public void SetMoveInput(Vector2 input)
    {
        moveInput = Vector2.ClampMagnitude(input, 1f);
    }

    private void FixedUpdate()
    {
        var direction = new Vector3(moveInput.x, 0f, moveInput.y);
        body.AddForce(direction * acceleration, ForceMode.Acceleration);
    }
}
# not executed here: Unity Editor 6.6 and UnityEngine are unavailable.

RequireComponent 表达同对象依赖,Awake 只缓存一次引用。这个特性在添加脚本时帮助补齐组件,却不会自动修复后来才增加要求的所有旧 Prefab;团队仍应运行 Prefab 验证,并为异常配置定义清楚的诊断。

方向是持续命令,所以保存最新值即可。跳跃、开火这类边沿事件需要单独缓冲并消费,不能假设每个 Update 后恰好只有一个 FixedUpdate

在 LateUpdate 跟随目标

最后,相机在 LateUpdate 读取目标完成本帧逻辑后的姿态。目标通过 Inspector 显式连接;缺失时,组件报告具体对象并停止继续更新。

FollowCamera.cs
// # not executed here: Unity Editor 6.6 and UnityEngine are unavailable.
using UnityEngine;

public sealed class FollowCamera : MonoBehaviour
{
    [SerializeField]
    private Transform target;

    [SerializeField]
    private Vector3 offset = new(0f, 5f, -8f);

    private void Awake()
    {
        if (target != null)
            return;

        Debug.LogError($"{name}: follow target is missing.", this);
        enabled = false;
    }

    private void LateUpdate()
    {
        transform.position = target.position + offset;
        transform.LookAt(target);
    }
}
# not executed here: Unity Editor 6.6 and UnityEngine are unavailable.

这个版本故意不宣称「LateUpdate 会消除抖动」。如果目标由物理系统驱动,相机质量还取决于 Rigidbody 插值、渲染时间和所选平滑算法。LateUpdate 只确保相机逻辑处在常规 Update 之后。

Awake 验证序列化目标不依赖目标自己的初始化,因此没有跨对象顺序问题。相反,若此处调用目标组件的运行时服务,就必须先定义目标何时进入可用状态。

陷阱

修复方法: Awake 只建立自身和同对象依赖。跨对象服务使用显式 bootstrap、依赖注入或「ready」事件;若确实使用 Script Execution Order,就把涉及的脚本类与原因写入项目文档,并用测试守住该约束。

修复方法: 按同一所有权周期配对回调。活动期订阅使用 OnEnableOnDisable;实例期订阅则需要明确建立时机、销毁清理和对象从未激活时的处理方式。

修复方法: 保存 StartCoroutine 返回的句柄,在所有权结束时显式停止并清空它。把重复启动策略写成「忽略、替换、排队或并行」之一,并为组件禁用、GameObject 非活动与销毁分别测试。

修复方法: 在输入所属的阶段采样并缓存命令,在 FixedUpdate 消费物理命令。持续值保留最新状态,一次性动作则使用明确的缓冲和清除规则。

修复方法: 活动期资源尽早在 OnDisable 释放,实例期资源使用幂等的显式所有权协议。必须释放的非 Unity 资源应有可直接测试的关闭方法;OnDestroy 可以调用它,但不应是唯一入口。

修复方法: 对照当前 Unity API 检查确切名称、返回类型和参数,删除不存在的 override。用最小探针或 Play Mode 测试触发目标状态变化,不要只凭编辑器没有红线就认定回调有效。

深入 执行顺序的边界

执行顺序的边界

生命周期保证大多是阶段之间的偏序,不是所有脚本的完整排序。对一个初始活动且已启用的实例,可以依赖 Awake 先于 OnEnableStart 先于其首次 Update。对场景开始时已经活动的对象,可以依赖这些对象的 Awake 在任何 Start 之前完成,但不能据此决定两个 Awake 的相对先后。

逐帧阶段也只给出大边界:固定步脚本、常规更新脚本和后更新脚本分别进入 PlayerLoop 的相应位置。同一消息在不同实例上的顺序默认不应成为业务契约。只有项目显式配置的脚本类顺序,或你自己的调度器,才能增加更细的约束。

常见状态转换可以压缩成下表。表中的「等待」表示消息尚未发生,而不是永久跳过。

操作或初始状态AwakeOnEnable / Start帧回调离开活动期
活动对象、组件启用一次OnEnable,再在首次更新前 Start接收禁用时 OnDisable
活动对象、组件禁用仍会一次等待启用不接收常规更新尚未进入活动期
对象初始非活动等待激活等待激活与启用不接收尚未进入活动期
已启动组件禁用后重启不重复OnEnable 重复,Start 不重复恢复每次禁用都有 OnDisable
活动对象被销毁不重复不重复停止OnDisable 后进入 OnDestroy

Script Execution Order 的适用范围

Project Settings 中的 Script Execution Order 和 [DefaultExecutionOrder] 可以调整不同 MonoBehaviour 子类在同类事件中的相对顺序。它们针对脚本类型,不是某个具体实例,也不把场景依赖变成编译期契约。同一类型的两个实例仍不应靠调用先后来分配所有权。

全局顺序表很容易变成隐藏耦合。给一个服务设为 -1000,只能让它较早收到某类消息,不能证明异步资源已经加载、网络已经连接或运行时生成的对象已经注册。就绪条件是数据,应通过状态、事件或可等待操作表达。

确实需要顺序设置时,把范围压到最小。例如,一个 bootstrap 类型先创建纯 C# 服务,普通组件随后从已冻结的容器取得依赖。测试应检查容器契约,而不是比较 Console 中两个对象偶然出现的帧号。

消息不是虚方法链

AwakeUpdate 等名称看起来像框架重写点,但 MonoBehaviour 没有为每个消息声明一个虚方法。Unity 的原生 PlayerLoop 识别脚本上匹配的消息,并在相应阶段调用。因此,C# 编译器不能像实现接口那样证明你的组件覆盖了正确成员。

消息入口通常应保持 private。把它改成 public 不会让 Unity 更容易找到,反而允许其他代码绕过引擎时机直接调用。

若业务需要主动重新初始化,不要手动调用 AwakeStart。把可重复操作提取成具名普通方法,并让消息入口与显式调用方共同使用它。

这也影响基类设计。若项目用自定义基类封装生命周期,不要假设 Unity 会替你构造普通的 base.Awake() 调用链。把可复用逻辑放进名称不同的普通方法,由一个清楚的消息入口调用;若选择虚拟模板方法,则由项目自己的基类显式定义、重写并测试。

反射、原生绑定和内部调用列表的具体实现会随 Unity 版本变化。应用代码应依赖公开的消息语义和 PlayerLoop 阶段,不应依赖「Unity 每帧通过反射查找方法」这类未经保证的内部描述。

生命周期所有权与测试

生命周期回调真正管理的是所有权。一个组件取得事件订阅、计时器、协程、输入映射或原生句柄时,应先写明它属于一次启用周期,还是整个实例周期。释放点由这个选择决定,而不是由哪个回调名字听起来像「清理」。

活动期资源通常用 OnEnable / OnDisable 配对。实例期资源可以在一次性初始化后持有更久,但仍需要应对从未激活、异常中断和编辑器运行模式差异。若资源也能由普通 C# 代码拥有,提供显式的 DisposeClose 路径会更容易测试。

测试状态矩阵

最小生命周期测试不只启动场景一次。它应覆盖初始活动且启用、活动但组件禁用、GameObject 初始非活动、一次禁用重启和销毁。每条路径记录消息与资源计数,断言「只运行一次」「每次成对」或「禁用期间不变化」等契约。

不要断言所有对象的完整日志字符串顺序。对保证的关系做局部断言,例如同一实例的 Awake 早于 Start,所有活动期订阅数在 OnDisable 后回到基线。这样测试不会把未承诺的调度细节固化成项目规则。

测试跨对象依赖

跨对象初始化测试应主动打乱创建顺序。先创建消费者再创建服务、让服务延迟一帧就绪、禁用后重新注册,并加载附加场景。若消费者只能在 Hierarchy 当前排列下工作,依赖协议仍然不完整。

测试失败消息应指出缺少哪个服务、哪个对象请求了它,以及当前生命周期状态。静默重试每帧查找会把配置错误拖成持续成本,也会让测试在某次碰巧找到对象时错误通过。

测试更新阶段

固定步测试应控制模拟输入与时间,而不是期待工作站恰好产生某个帧率。分别验证没有固定步、一个固定步和同一渲染帧内多个固定步时,持续命令与一次性命令的消费次数。相机测试则比较阶段后的姿态,不把视觉平滑和执行顺序混成一个断言。

测试重复清理

清理测试应连续执行两次禁用,并在第二次之前改变一部分外部状态。目标不是要求 Unity 重复发送同一消息,而是直接验证清理方法面对已释放句柄、已删除订阅或已经为空的引用时仍然安全。

随后重新启用组件,确认资源计数只回到一个活动实例,而不是每次循环再增加一个。对静态事件,可以让发布者发出一次通知并断言处理器只运行一次;对协程,则记录当前拥有的句柄和可见状态变更。

销毁路径也要经过同一个幂等释放入口。这样,显式关闭、OnDisableOnDestroy 即使在边界情况下相邻发生,也不会形成双重释放错误。

只有发现性能问题时才记录性能结论。使用目标平台 Player 构建,固定场景输入,分别查看 UpdateFixedUpdateLateUpdate 中的主线程时间与分配。没有测量数据时,删除空回调和重复查找可以作为清晰度改进,但不要附上虚构的加速比例。

延伸阅读

检查点

5个问题 · 1 道输出预测题 · 1 道找错题

前置内容 Unity 开发基础 Game loop 即将上线
下一篇 Unity 协程 Object pooling 即将上线 Game profiling 即将上线 Unity DOTS
复制为 Markdown 面试题库 在 GitHub 上编辑 报告错误 讲清楚了吗?