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 序列化与数据驱动配置 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。