《Cocos 游戏开发实战》3.2 生命周期与执行顺序

组件挂上节点后代码何时执行,是卷二一半 bug 的根因。本节讲透 Cocos Creator 3.8 的七个生命周期回调、引擎一帧的调度骨架、@executionOrder 如何决定同类回调先后、director 的帧级事件钩子、schedule 与 scheduleOnce 定时调度,以及「节点未激活时 start 不触发」这类首帧陷阱,并给出打印验证顺序的步骤与输出。

3.2 生命周期与执行顺序

3.1 我们把组件挂上了节点,但组件里的代码什么时候跑、按什么顺序跑还没交代。这一节把 Cocos Creator 3.8 的七个生命周期回调和引擎一帧的调度骨架讲透。卷二里「为什么我的初始化拿到的是 null」「为什么 HUD 数字慢了一帧」这类 bug,根因几乎都在这里。

卷一 6.2 我们用 mini2d 从零实现了同样七个回调,并定义了三条保证(onLoad 早于任何 start、start 早于第一次 update、所有 update 早于任何 lateUpdate)。卷二不再重复推导,重点换成**「引擎给的实际顺序是什么、我怎么当场验证它」**。

3.2.1 七个回调与触发时机

回调触发时机调用次数典型用途
onLoad组件被激活、进入场景树时一次取兄弟组件引用、建缓存
onEnable每次从禁用变启用(含首次)多次订阅事件、开定时器
start第一次 update 之前一次依赖「所有 onLoad 已完成」的初始化
update每帧每帧读输入、算速度
lateUpdate每帧,所有 update 之后每帧相机跟随、同步显示
onDisable每次从启用变禁用多次退订事件、关定时器
onDestroy组件被移除或节点销毁一次释放资源、断开引用

在 cc.d.ts 里,这七个都是可选方法(形如 protected onLoad?(): void;)。覆写谁,谁才会被调用;不覆写引擎也不会报错。最小可观察的组件长这样:

import { _decorator, Component } from 'cc';
const { ccclass } = _decorator;

@ccclass('LifecycleProbe')
export class LifecycleProbe extends Component {
  onLoad(): void { console.log('onLoad'); }
  onEnable(): void { console.log('onEnable'); }
  start(): void { console.log('start'); }
  update(dt: number): void { void dt; }
  lateUpdate(dt: number): void { void dt; }
  onDisable(): void { console.log('onDisable'); }
  onDestroy(): void { console.log('onDestroy'); }
}

3.2.2 一帧的调度骨架

把回调接进主循环后,引擎一帧的近似次序是:

// 引擎每帧内部调度(伪代码,帮助理解顺序)
for (const c of activeComponents) c.ensureLoaded();  // onLoad + onEnable(首次)
for (const c of activeComponents) c.startIfNeeded(); // start(首次)
for (const c of activeComponents) c.update(dt);      // 全部 update
for (const c of activeComponents) c.lateUpdate(dt);  // 全部 lateUpdate

三条不变量记牢就不会踩坑:onLoad 一定早于任何 start;start 一定早于第一次 update;同一帧内所有组件的 update 都早于任何 lateUpdate。 这条「先全跑完 update 再跑 lateUpdate」的规则,是 lateUpdate 能做相机跟随、显示同步的前提——否则相机会比角色慢半帧。

3.2.3 executionOrder 决定同类回调的先后

同一帧里多个组件的 update 谁先谁后?默认按组件挂载顺序,但那是编辑器里的偶然顺序,不可靠。用 @executionOrder(priority) 显式声明,数值小的先执行:

import { _decorator, Component } from 'cc';
const { ccclass, executionOrder } = _decorator;

@ccclass('GameManager')
@executionOrder(-100)
export class GameManager extends Component {
  update(dt: number): void { void dt; }
}

@ccclass('Hud')
@executionOrder(100)
export class Hud extends Component {
  update(dt: number): void { void dt; }
}

executionOrder 只影响 onLoad、onEnable、start、update、lateUpdate 五个回调,onDisable 和 onDestroy 不受它影响——这条来自 cc.d.ts 里 _decorator.executionOrder 的文档注释。卷二里的约定是:数据层给负数(GameManager 用 -100),玩法层用 0,表现层给正数(Hud 用 100)。这样「先算状态、再更新表现」就由声明保证,而不是靠运气。

3.2.4 帧级事件:director 的四个钩子

如果需要在所有组件之前或之后插一段逻辑,用 director 的帧事件。它们以静态常量暴露在 Director 上:

import { director, Director, Component, _decorator } from 'cc';
const { ccclass } = _decorator;

@ccclass('FrameObserver')
export class FrameObserver extends Component {
  onEnable(): void {
    director.on(Director.EVENT_BEFORE_UPDATE, this.onBeforeUpdate, this);
    director.on(Director.EVENT_AFTER_UPDATE, this.onAfterUpdate, this);
  }

  onDisable(): void {
    director.off(Director.EVENT_BEFORE_UPDATE, this.onBeforeUpdate, this);
    director.off(Director.EVENT_AFTER_UPDATE, this.onAfterUpdate, this);
  }

  private onBeforeUpdate(): void { void director.getDeltaTime(); }
  private onAfterUpdate(): void { void director.getTotalFrames(); }
}

四个可用事件是 EVENT_BEFORE_UPDATE / EVENT_AFTER_UPDATE / EVENT_BEFORE_DRAW / EVENT_AFTER_DRAW。EVENT_BEFORE_UPDATE 早于所有组件的 update,EVENT_AFTER_UPDATE 晚于所有 lateUpdate。适合放输入采样、帧计时统计这类「跨组件」逻辑。注意 director 是全局单例,on 一定要和 onDisable 里的 off 成对,否则组件销毁后回调仍持有已释放的 this。

3.2.5 定时调度:schedule 与 scheduleOnce

Component 自带定时器:schedule(callback, interval, repeat, delay) 按固定间隔回调,scheduleOnce(callback, delay) 只回调一次。无限循环用 macro.REPEAT_FOREVER:

import { _decorator, Component, macro } from 'cc';
const { ccclass } = _decorator;

@ccclass('Spawner')
export class Spawner extends Component {
  start(): void {
    this.schedule(this.spawn, 1.5, macro.REPEAT_FOREVER, 0.5);
    this.scheduleOnce(this.firstWave, 0.5);
  }

  private spawn(): void { /* 生成一组障碍 */ }
  private firstWave(): void { /* 第一波提示 */ }
}

schedule 的时间基准是引擎时间,会受 director.pause() 影响,暂停时定时器一起停——这正是我们想要的(暂停菜单弹出时障碍不该继续生成)。它和 update 里手写累加计时是等价的,但 schedule 由引擎统一管理,组件销毁时自动清理;手写累加则必须在 onDisable 里记得停。

3.2.6 启用与禁用:一对必须配对的回调

onEnable 和 onDisable 是七回调里唯一「成对多次触发」的一组。规则很简单:在 onEnable 里做的事,必须在 onDisable 里撤销。最常见的是事件订阅:

import { _decorator, Component, EventTarget } from 'cc';
const { ccclass } = _decorator;

const bus = new EventTarget();

@ccclass('CoinCounter')
export class CoinCounter extends Component {
  private count = 0;

  onEnable(): void {
    bus.on('coin:collected', this.onCoin, this);
  }

  onDisable(): void {
    bus.off('coin:collected', this.onCoin, this);
  }

  private onCoin(): void {
    this.count += 1;
  }
}

不配对的后果是「一次事件触发多次结算」:组件被反复启停几轮后,同一个回调在 bus 里注册了多份,金币加分会翻倍。Cocos 的 EventTarget.on 接受第三个 target 参数用于绑定 this,但它不返回退订函数(这点和卷一 6.2 我们自己写的 EventBus 不同),所以退订必须自己记住 type 和回调引用。

回调该做的事不该做的事
onLoad取引用、建缓存依赖其他组件 start 后的状态
onEnable订阅、开定时器、播动画做重计算(会随启停反复执行)
start依赖已就绪的初始化每帧都需要的逻辑
update读输入、算速度分配临时对象、console.log
lateUpdate相机、显示同步修改 update 阶段已定稿的状态
onDisable退订、关定时器释放还需复用的资源
onDestroy释放资源、断引用访问已销毁的兄弟节点

3.2.7 首帧陷阱与节点激活

三个高频坑,卷二全程规避:

  1. start 在节点未激活时不触发。 如果一个节点在场景里默认 active = false,它的组件只跑 onLoad,start 要等到第一次被激活。把「初始化玩家」放在 start 里,而玩家节点默认隐藏,就会看到「游戏卡在准备界面」。
  2. onLoad 里拿不到别的组件已经 start 完的状态。 onLoad 阶段所有组件都还没 start,此时读 other.startTime 拿到的是默认值。要跨组件读「已初始化」的字段,放到 start 里,或用 executionOrder 保证顺序。
  3. addComponent 是同步生效的。 运行时 node.addComponent(X) 之后,X 的 onLoad 会立即被调用(若节点已激活),但 start 要等到下一帧的 start 阶段。所以在 addComponent 之后立刻读它的 start 初始化结果,也是 null。
  4. onLoad 里销毁自己会漏掉 start。 在 onLoad 中调用 this.destroy(),引擎会把该组件标记为待销毁,start 与后续 update 都不再触发;但同一帧里其他组件的 onLoad 仍会执行完。
import { _decorator, Component } from 'cc';
const { ccclass } = _decorator;

@ccclass('SelfDestruct')
export class SelfDestruct extends Component {
  onLoad(): void {
    if (this.node.name === 'Temp') {
      this.destroy(); // 本组件的 start / update 不再触发
    }
  }
}

这类「自己把自己干掉」的写法只在对象池回收临时节点时才合理,普通业务组件应当避免。

3.2.8 在《方块冲刺》里验证顺序

把探针组件挂到 main.scene 的 GameRoot 上,再挂上 GameManager(-100)、PlayerController(0)、Hud(100),运行时控制台应看到:

GameManager onLoad
PlayerController onLoad
Hud onLoad
GameManager start
PlayerController start
Hud start
... 每帧 GameManager update / PlayerController update / Hud update ...

三组 onLoad 全部早于任何 start,且 update 严格按 -100 → 0 → 100 的顺序,就是 3.2.3 的约定在生效。若 Hud 的数字比 PlayerController 的位置早更新一帧,说明 Hud 的 executionOrder 设反了。

3.2.9 编辑器里的验证步骤(需编辑器,不可命令行复现)

本节涉及 .scene 工程文件,需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现。 步骤:

  1. 打开 assets/scenes/main.scene,层级管理器里选中 GameRoot 节点。
  2. 属性检查器「添加组件」→ 依次搜索并添加 GameManager、PlayerController、Hud。
  3. 运行预览(编辑器右上角三角按钮),打开浏览器控制台。
  4. 控制台应输出上面那串顺序。

打开 main.scene 会看到 GameRoot 节点的 _components 数组里有三项,__type__ 分别是注册名:

{
  "__type__": "cc.Node",
  "_name": "GameRoot",
  "_components": [
    { "__type__": "GameManager" },
    { "__type__": "PlayerController" },
    { "__type__": "Hud" }
  ]
}

注意 .scene 里看不到 executionOrder——它是类级装饰器,编译进脚本而非存进场景,所以改 @executionOrder 后要重新构建才生效,热重载不一定立即反映。

下一节我们让组件之间能互相说话:节点事件、全局事件总线,以及触摸与键盘输入怎么接进来。

阅读导航:上一节:3.1 @ccclass / @property 与类型系统 · 下一节:3.3 组件通信与事件 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cocos」更多文章

  1. 《Cocos 游戏联机实战》9.3 延迟补偿与体验权衡
  2. 《Cocos 游戏联机实战》9.2 网络状态 UI 与提示
  3. 《Cocos 游戏联机实战》9.1 同步表现与插值动画