《Cocos 游戏开发入门》6.2 生命周期与消息分发

组件挂上去之后还什么都不会发生,本节把 mini2d 的组件系统接进主循环,定义 onLoad/onEnable/start/update/lateUpdate/onDisable/onDestroy 七个回调的固定顺序,用状态位保证只调用一次,讲清启用状态如何向子节点传播,并实现一套带订阅与退订的事件总线,末节给出 Cocos Creator 3.8 的生命周期与 EventTarget 对照。

6.2 生命周期与消息分发

上一节我们让 GameObject 能挂组件了,但组件挂上去之后什么都不会发生——update 没人调用,onAttach 也没接进主循环。这一节把组件系统接进 mini2d 的循环,定义清楚每个组件在什么时机被调用,并补上一套轻量的消息(事件)机制,让《方块冲刺》里「玩家吃到金币」这类跨组件通知不必互相硬引用。

6.2.1 七个生命周期回调

Cocos Creator 的组件有七个经典回调:onLoad / onEnable / start / update / lateUpdate / onDisable / onDestroy。我们照搬这套命名,含义如下:

回调触发时机典型用途
onLoad组件被挂载、实体进入场景时,每组件一次拿兄弟组件引用、建缓存
onEnable每次从禁用变启用(含首次)订阅事件、启动动画
start第一次 update 之前,只一次依赖「所有 onLoad 已完成」的初始化
update每帧,逻辑更新读输入、算速度
lateUpdate每帧,所有 update 之后相机跟随、同步显示
onDisable每次从启用变禁用退订事件、停动画
onDestroy组件被移除 / 实体销毁时,一次释放资源

关键顺序有三条,记牢就不会踩坑:onLoad 一定早于任何 start;start 一定早于第一次 update;同一帧内所有组件的 update 都早于任何 lateUpdate。 引擎每帧的调度骨架因此是:

// 一帧内的固定顺序
for (const c of activeComponents) {
  c.__ensureLoaded(); // onLoad + onEnable(首次)
  c.__startIfNeeded(); // start(首次)
  c.update(dt);
}
for (const c of activeComponents) {
  c.lateUpdate(dt);
}

6.2.2 用状态位保证「只调用一次」

onLoad 和 start 是「每组件一次」,onEnable 和 onDisable 是「每次切换」。给 Component 加几个状态位:

export class Component {
  gameObject!: GameObject;
  /** 位掩码:1=已 onLoad  2=已 start  4=启用中 */
  private state = 0;

  onLoad(): void {}
  onEnable(): void {}
  start(): void {}
  update(_dt: number): void {}
  lateUpdate(_dt: number): void {}
  onDisable(): void {}
  onDestroy(): void {}

  /** 由引擎调用,保证 onLoad 只跑一次 */
  __ensureLoaded(): void {
    if (this.state & 1) return;
    this.state |= 1;
    this.onLoad();
    if (this.enabled) this.__enable();
  }

  __enable(): void {
    if (this.state & 4) return;
    this.state |= 4;
    this.onEnable();
  }

  __disable(): void {
    if (!(this.state & 4)) return;
    this.state &= ~4;
    this.onDisable();
  }

  __startIfNeeded(): void {
    if (this.state & 2) return;
    this.state |= 2;
    this.start();
  }
}

用位掩码而不是三个布尔字段,是为了省内存——一个组件常常只占几十字节,实体上挂几十个组件时字段开销会累积。这属于「引擎层值得抠的细节」,业务组件不必效仿。

6.2.3 启用状态向子节点传播

GameObject 的 active 和组件的 enabled 是两级开关。规则是:只有实体自身与所有祖先都 active,且组件自身 enabled,组件的 onEnable 才生效。父节点被禁用时,整棵子树的组件都要收到 onDisable:

export class GameObject {
  active = true;
  parent: GameObject | null = null;
  readonly children: GameObject[] = [];
  readonly components: Component[] = [];

  /** 父链全 active 且自身 active 时才算「在树上激活」 */
  get activeInHierarchy(): boolean {
    if (!this.active) return false;
    return this.parent ? this.parent.activeInHierarchy : true;
  }

  setActive(v: boolean): void {
    if (this.active === v) return;
    const wasActive = this.activeInHierarchy;
    this.active = v;
    const nowActive = this.activeInHierarchy;
    if (wasActive === nowActive) return; // 深层节点翻转不影响本节点
    this.__propagate(nowActive);
    for (const c of this.children) c.__propagate(nowActive);
  }

  private __propagate(on: boolean): void {
    for (const c of this.components) {
      if (on) c.__enable();
      else c.__disable();
    }
  }
}

__propagate 只在「树上激活状态真的翻转」时才递归,避免每帧遍历整棵树。这也是为什么 setActive 里要先算 wasActive 与 nowActive 再比较:一个已经因父节点而禁用的子节点,自己被 setActive(true) 时树上状态并没有变,就不该重复触发 onEnable。

6.2.4 引擎调度:把回调挂进主循环

引擎侧维护一份「已激活组件」的扁平列表,每帧只遍历这份列表,而不是每帧递归遍历场景图:

export class ComponentScheduler {
  private active: Component[] = [];
  private pendingDestroy: Component[] = [];

  /** 每帧由主循环调用一次 */
  beginFrame(dt: number): void {
    for (const c of this.active) {
      c.__startIfNeeded();
      c.update(dt);
    }
  }

  endFrame(dt: number): void {
    for (const c of this.active) c.lateUpdate(dt);
    for (const c of this.pendingDestroy) c.onDestroy();
    this.pendingDestroy.length = 0;
  }

  markDestroy(c: Component): void {
    this.pendingDestroy.push(c);
  }
}

markDestroy 不立即调 onDestroy,而是压进队列、在帧末统一处理——这是为了避免在 update 遍历过程中修改 active 数组。游戏里「子弹打中敌人 → 销毁敌人」几乎每帧都发生,如果当场从数组里删元素,正在跑的 for 循环就会漏掉或跳过组件。

6.2.5 消息(事件)分发:订阅与退订

组件之间要通信。硬引用会织出蜘蛛网(金币要引用玩家、HUD 要引用玩家、音效系统要引用所有人),所以我们加一个全局事件总线,让发布者只管发,订阅者只管收:

export type Handler = (payload?: unknown) => void;

export class EventBus {
  private listeners = new Map<string, Set<Handler>>();

  on(type: string, h: Handler): () => void {
    let set = this.listeners.get(type);
    if (!set) {
      set = new Set();
      this.listeners.set(type, set);
    }
    set.add(h);
    // 返回退订函数,订阅方不必再记住 type 与回调
    return () => this.off(type, h);
  }

  off(type: string, h: Handler): void {
    this.listeners.get(type)?.delete(h);
  }

  emit(type: string, payload?: unknown): void {
    const set = this.listeners.get(type);
    if (!set) return;
    // 复制一份,防止回调里退订导致遍历跳过
    for (const h of [...set]) h(payload);
  }
}

订阅方通常在 onEnable 里订阅、在 onDisable 里退订,二者成对出现,这样组件被临时禁用时不会漏收或重复处理事件:

export class HudComponent extends Component {
  private score = 0;
  private off: (() => void) | null = null;

  onEnable(): void {
    this.off = EventBus.on('coin:collected', (p) => this.refresh(p));
  }

  onDisable(): void {
    this.off?.();
    this.off = null;
  }

  private refresh(payload: unknown): void {
    const { value } = payload as { value: number };
    this.score += value;
  }
}

一个高频 bug:只在 onEnable 订阅却忘了在 onDisable 退订。组件反复启停几次后,同一个回调被注册多份,一次事件触发多次结算。让 on 返回退订函数,能让「订阅 / 退订」在代码里成对出现,比手工 bus.off(type, handler) 更不容易漏。

6.2.6 Cocos 对照:生命周期与 EventTarget

Cocos Creator 3.8 的 Component 把这七个回调声明为可选方法(cc.d.ts 中形如 protected onLoad?(): void;、protected update?(dt: number): void;、protected lateUpdate?(dt: number): void;),你不覆写就不会被调用,引擎也不会报错——这正是我们 Component 基类里留空实现的用意。

事件机制 Cocos 提供的是 EventTarget:cc.d.ts 中它既是构造器常量 export const EventTarget: new (...args: any[]) => ...,也是一个类型别名 export type EventTarget = InstanceType<typeof EventTarget>。用法:

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

export const bus = new EventTarget();

@ccclass('Hud')
export class Hud extends Component {
  @property
  score = 0;

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

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

  private onCoin(value: number): void {
    this.score += value;
  }
}

与我们的 EventBus 有两点不同:Cocos 的 on / off 接受第三个 target 参数用于绑定 this,且不返回退订函数,所以退订必须自己记住 type 和回调引用;此外 Node 本身就是 EventTarget,可以直接 node.on(...) 监听节点上的冒泡事件。Cocos 还提供 this.schedule(callback, interval) 与 this.scheduleOnce(callback, delay) 做定时调度(见 cc.d.ts 中 Component.schedule),我们这套 mini2d 暂时用 update 里的累加计时替代即可。

下一节我们把组件的参数从代码里搬出去,交给 JSON 配置——也就是序列化与数据驱动。

阅读导航:上一节:6.1 组件模型:从继承到组合 · 下一节:6.3 序列化与数据驱动配置 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

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