《Cocos 游戏开发高级编程》1.2 启动流程与主循环

沿着 pal/pacer 到 Game、Director.tick、Root.frameMove 这条链,逐段核对 Cocos Creator 3.8.8 一帧里的七个阶段与组件回调位置,并用一段可单测的纯 TS 主循环复刻这套执行顺序,最后给出在组件里监听帧事件的正确写法与常见误区。

1.2 启动流程与主循环

1.1 把引擎分成四层,说清了「问题该往哪一层挖」。这一节顺着最常被问到的一条路径往下走:从引擎启动到每帧调用组件的 update,中间到底经过了哪些函数。

理解了这条链,你就能回答几个具体问题:为什么 start 只会被调用一次?为什么 lateUpdate 一定晚于所有 update?为什么 game.pause() 之后画面还在动?这些都不是文档里的孤立条目,而是同一段主循环代码的直接后果。本节的每个结论都对应 cocos/cocos-engine 仓库 tag 3.8.8 里的一段真实代码。

1.2.1 谁在驱动第一帧

启动的起点不在引擎里,而在平台适配层的帧同步器。Web 平台上是 pal/pacer/pacer-web.ts(tag 3.8.8)里的 Pacer 类:它内部用 requestAnimationFrame,每到一个绘制时机就调用一次 onTick 回调。原生和微信小游戏各有一份对应实现(pacer-native.ts / pacer-minigame.ts),对外接口一致——这又是一次「平台差异被锁在一层里」。

onTick 挂到哪里?在 cocos/game/game.ts(tag 3.8.8)里:

// cocos/game/game.ts, tag 3.8.8(节选)
private initPacer (): void {
    const frameRate = querySettings(SettingsCategory.SCREEN, 'frameRate') ?? 60;
    this._pacer = new Pacer();
    this._pacer.onTick = this._updateCallback.bind(this);
    this.frameRate = frameRate;
}

于是每帧的调用链是:

Pacer.onTick (rAF)
  └─> Game._updateCallback()
        └─> director.tick(this._calculateDT(false))

_updateCallback 里还有个细节:首帧要先跑启动画面、再 director.loadScene(launchScene, ...),加载完才 director.startAnimation() 进入正常循环。所以「游戏开始」不是引擎初始化完就发生的,而是启动场景加载完那一刻。这也解释了为什么首帧的逻辑常比后续帧慢——它夹着场景构建。

1.2.2 Director.tick 的七个阶段(L0)

真正的「一帧」定义在 cocos/game/director.ts(tag 3.8.8)的 Director.tick(dt) 里。把它逐行读一遍,整条主循环就清楚了:

// cocos/game/director.ts, tag 3.8.8(节选,保留结构,去掉无关分支)
public tick (dt: number): void {
    if (!this._invalid) {
        this.emit(DirectorEvent.BEGIN_FRAME);
        if (!EDITOR_NOT_IN_PREVIEW) {
            input._frameDispatchEvents();
        }

        // Update
        if (!this._paused) {
            this.emit(DirectorEvent.BEFORE_UPDATE);
            this._compScheduler.startPhase();        // start(仅一次)
            this._compScheduler.updatePhase(dt);     // 组件 update
            for (let i = 0; i < this._systems.length; ++i) {
                this._systems[i].update(dt);         // 各系统 update
            }
            this._compScheduler.lateUpdatePhase(dt); // 组件 lateUpdate
            this.emit(DirectorEvent.AFTER_UPDATE);
            CCObject._deferredDestroy();             // 延迟销毁
            for (let i = 0; i < this._systems.length; ++i) {
                this._systems[i].postUpdate(dt);     // 各系统 postUpdate
            }
        }

        this.emit(DirectorEvent.BEFORE_DRAW);
        uiRendererManager.updateAllDirtyRenderers();
        this._root!.frameMove(dt);                   // 渲染
        this.emit(DirectorEvent.AFTER_DRAW);

        Node.resetHasChangedFlags();
        Node.clearNodeArray();
        scalableContainerManager.update(dt);
        this.emit(DirectorEvent.END_FRAME);
        this._totalFrames++;
    }
}

把这段拆成阶段表,就是本节最该记住的东西:

顺序动作关键点
1BEGIN_FRAME帧起点
2input._frameDispatchEvents()本帧输入事件在这统一派发
3BEFORE_UPDATE逻辑阶段开始
4startPhase()调 start,只对新增组件调一次
5updatePhase(dt)调所有组件 update
6systems[i].update(dt)各系统更新(物理、动画等)
7lateUpdatePhase(dt)调所有组件 lateUpdate
8AFTER_UPDATE逻辑阶段结束
9_deferredDestroy()真正销毁上一帧标记的节点
10systems[i].postUpdate(dt)各系统收尾
11root.frameMove(dt)渲染
12END_FRAME帧结束,帧计数 +1

两个立刻能用的结论:

  • update 与 lateUpdate 之间夹着所有系统的 update。所以物理刚体位置在 update 里读到的是上一帧的结果,要拿本帧最终位置得用 lateUpdate。
  • _totalFrames++ 在 if (!this._paused) 之外。暂停后逻辑不再跑,但帧计数继续涨——这解释了为什么暂停时 director.getTotalFrames() 仍在变。

1.2.3 渲染阶段:Root.frameMove(L0)

阶段 11 的 this._root.frameMove(dt) 才是出图的地方。它在 cocos/root.ts(tag 3.8.8)里,结构是三个子步骤:

// cocos/root.ts, tag 3.8.8(结构摘要)
public frameMove (deltaTime: number): void {
    this._frameTime = deltaTime;
    ++this._frameCount;
    // ... FPS 统计 ...
    this._frameMoveBegin();     // 清理上一帧的批次、相机列表
    this._frameMoveProcess();   // 提取相机、更新批次、更新场景
    this._frameMoveEnd();       // 排序相机、执行管线渲染、present
}

_frameMoveProcess() 里会对每个窗口提取渲染相机,然后调用 this._batcher.update() 与场景的 update();_frameMoveEnd() 里则按 camera.priority 排序相机,发出 EVENT_BEFORE_RENDER / EVENT_AFTER_RENDER,最后 this._device.present() 把画面送到屏幕。这就是第 2 章要展开的渲染管线的入口——记住它挂在 tick 的第 11 步,物理与逻辑都算完了才轮到它。

1.2.4 组件回调到底怎么被调起来

阶段 4/5/7 调的是 ComponentScheduler 的三个方法,实现在 cocos/scene-graph/component-scheduler.ts(tag 3.8.8)。它的内部按执行顺序(executionOrder)把组件分成三组:优先级为负、为零、为正,调用时先负后零再正:

// cocos/scene-graph/component-scheduler.ts, tag 3.8.8(摘要)
public startPhase (): void {
    this._updating = true;
    this.startInvoker.invoke();
    this._startForNewComps();   // 本帧新激活节点的 start
}
public updatePhase (dt: number): void {
    this.updateInvoker.invoke(dt);
}
public lateUpdatePhase (dt: number): void {
    this.lateUpdateInvoker.invoke(dt);
    this._updating = false;
    this._startForNewComps();   // update 期间新激活的组件,下帧再 start
}

这解释了两件事:为什么 executionOrder 能控制回调顺序(三组分开调),以及为什么运行中动态 addComponent 的组件不会当帧立即 start(_startForNewComps 把新组件推到下一帧)。生产环境的 invokeUpdate 是 JIT 生成的(c.update(dt)),比逐次 hasOwnProperty 判断快得多——这也是引擎为热路径做的小优化。

1.2.5 用纯 TS 复刻主循环(L1)

现在把上面的顺序用纯 TypeScript 写一遍,不依赖 cc,可以直接单测。这个 FrameLoop 就是 Director.tick 的骨架:

// frame-loop.ts —— 纯逻辑,无 cc 依赖
export class FrameLoop {
  private readonly startFns: Array<() => void> = [];
  private readonly updateFns: Array<(dt: number) => void> = [];
  private readonly systemFns: Array<(dt: number) => void> = [];
  private readonly lateFns: Array<(dt: number) => void> = [];
  private startDone = false;
  private paused = false;
  private frames = 0;
  private time = 0;

  onStart(fn: () => void): void { this.startFns.push(fn); }
  onUpdate(fn: (dt: number) => void): void { this.updateFns.push(fn); }
  addSystem(fn: (dt: number) => void): void { this.systemFns.push(fn); }
  onLateUpdate(fn: (dt: number) => void): void { this.lateFns.push(fn); }

  pause(): void { this.paused = true; }
  resume(): void { this.paused = false; }

  get totalFrames(): number { return this.frames; }
  get totalTime(): number { return this.time; }

  // 对齐 Director.tick:start → update → systems → lateUpdate
  tick(dt: number): void {
    if (!this.paused) {
      if (!this.startDone) {
        this.startDone = true;
        for (const fn of this.startFns) fn();
      }
      for (const fn of this.updateFns) fn(dt);
      for (const fn of this.systemFns) fn(dt);
      for (const fn of this.lateFns) fn(dt);
      this.time += dt;
    }
    this.frames += 1; // 注意:与引擎一致,暂停也计数
  }
}

配套 vitest 断言,把顺序和边界都钉死:

// frame-loop.test.ts
import { describe, expect, it } from 'vitest';
import { FrameLoop } from './frame-loop';

describe('FrameLoop.tick', () => {
  it('按 start → update → system → lateUpdate 的顺序执行', () => {
    const loop = new FrameLoop();
    const order: string[] = [];
    loop.onStart(() => order.push('start'));
    loop.onUpdate(() => order.push('update'));
    loop.addSystem(() => order.push('system'));
    loop.onLateUpdate(() => order.push('late'));
    loop.tick(1 / 60);
    expect(order).toEqual(['start', 'update', 'system', 'late']);
  });

  it('start 只执行一次,update 每帧执行', () => {
    const loop = new FrameLoop();
    let starts = 0;
    let updates = 0;
    loop.onStart(() => { starts += 1; });
    loop.onUpdate(() => { updates += 1; });
    loop.tick(0.016);
    loop.tick(0.016);
    expect(starts).toBe(1);
    expect(updates).toBe(2);
  });

  it('暂停时跳过逻辑阶段,但帧计数仍递增', () => {
    const loop = new FrameLoop();
    let updates = 0;
    loop.onUpdate(() => { updates += 1; });
    loop.pause();
    loop.tick(0.016);
    expect(updates).toBe(0);
    expect(loop.totalFrames).toBe(1);
  });

  it('未暂停时累加经过时间', () => {
    const loop = new FrameLoop();
    loop.tick(0.5);
    loop.tick(0.25);
    expect(loop.totalTime).toBeCloseTo(0.75, 6);
  });
});

这四条断言不是随手写的:第三条正是「暂停后帧计数仍涨」那个引擎行为的回归测试。把引擎的怪癖写成测试,下次它变了你会第一时间知道。

1.2.6 在组件里监听帧事件(L2)

组件脚本可以监听 Director 上的帧事件,在 update 之外插入自己的逻辑。事件常量定义在 Director 类上,要用类名引用而不是实例:

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

@ccclass('FrameProbe')
export class FrameProbe extends Component {
  private _frame = 0;

  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 {
    log('before update, frame =', director.getTotalFrames());
  }

  private _onAfterUpdate(): void {
    this._frame += 1;
  }

  update(_dt: number): void {
    // 本组件的 update 位于 BEFORE_UPDATE 与 AFTER_UPDATE 之间
  }
}

两个必须注意的点:

  • 一定要成对 on / off。Director 是全局单例,只 on 不 off 会让组件被销毁后回调还在跑,这是最常见的泄漏。
  • 别在 EVENT_BEFORE_UPDATE 里改会被 update 读的数据,顺序上它先于所有组件的 update;反过来 EVENT_AFTER_UPDATE 才是「所有逻辑都算完」的时机。

1.2.7 工程决策:什么时候该用帧事件

大多数时候你不该用 Director 的帧事件——组件自带 update / lateUpdate 就够了,而且后者参与 executionOrder 排序,更可控。帧事件的合理场景只有三个:

场景用哪个事件理由
需要「所有组件都更新完」后统一处理EVENT_AFTER_UPDATE早于它拿不到本帧最终数据
需要在渲染前最后一次改数据EVENT_BEFORE_DRAW卡在 frameMove 之前
需要全局性能采样 / 帧计时EVENT_BEGIN_FRAME + END_FRAME包住整帧,测的是真实帧耗时

代价要清楚:帧事件是全局的,回调里做重活会拖慢所有帧;而且它不受 executionOrder 控制,多个监听者之间没有确定顺序。能用组件回调解决的,不要上升到帧事件。

下一节我们把视角从「时间」换到「空间」:cc.d.ts 里到底导出了什么、怎么快速查一个 API 存不存在、怎么从类型声明反推引擎的能力边界。

阅读导航:上一节:1.1 引擎分层与源码结构 · 下一节:1.3 从 cc.d.ts 读引擎 API 版图 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

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