Skip to content

Stage02|Engine Loop ​

Lesson015|Scheduler 和 ComponentScheduler 分别负责什么? ​

Tags: #Creator2.x #Stage02 #Scheduler #ComponentScheduler #TimerDifficulty: ⭐⭐⭐⭐☆


一、本课目标 ​

本课回答:

引擎如何在每一帧找到“现在应该执行的任务”?

重点区分:

  • 通用时间调度与组件生命周期调度。
  • schedule 和 update 的适用场景。
  • 为什么关闭节点或组件会让任务停止。
  • 为什么一个空 update 也可能进入每帧调度。

二、两类调度器的职责 ​

可以先用下面的模型理解:

text
Scheduler
├── 定时器
├── schedule / scheduleOnce
├── 优先级和时间间隔
└── 一般性的时间任务

ComponentScheduler
├── Component 注册
├── onLoad / start
├── update / lateUpdate
├── enabled 状态
└── activeInHierarchy 状态

Creator 2.x 的具体内部类名和调用细节应以当前引擎源码为准,但职责边界可以稳定地这样记忆:

Scheduler 管时间任务,ComponentScheduler 管组件生命周期和每帧方法。


三、为什么不能只遍历所有 Node ​

假设场景有 10,000 个 Node,但真正需要每帧更新的只有 300 个组件:

text
直接遍历所有 Node
    ↓
逐个查找 Component
    ↓
逐个判断 enabled / active
    ↓
调用少量 update

这会带来大量无意义的遍历。调度系统可以在组件启用、禁用、挂载和销毁时维护可执行集合,减少每帧筛选成本。

这不是说引擎永远不遍历,而是说明“注册时整理状态、运行时快速调度”的设计思路。


四、ComponentScheduler 关注什么 ​

一个组件能否进入更新阶段,至少要满足:

text
Component 已被创建
Component 实现了目标生命周期方法
Component.enabled = true
Node.activeInHierarchy = true
对象没有待销毁

当状态改变时,调度系统需要更新组件状态:

text
enabled = true
    ↓
组件加入可执行集合

enabled = false
    ↓
组件移出或标记为不可执行

父节点 active = false
    ↓
子节点 activeInHierarchy 变化
    ↓
相关组件进入 disable 状态

这就是 enabled、activeInHierarchy 和调度集合之间的联系。


五、Scheduler 的时间任务 ​

常见写法:

ts
this.schedule(this.tick, 1);
this.scheduleOnce(this.openPanel, 2);
this.unschedule(this.tick);

可以抽象成:

text
注册回调和间隔
    ↓
每帧接收 dt
    ↓
累计时间
    ↓
达到间隔
    ↓
执行回调
    ↓
保留或移除任务

它不是创建一个操作系统线程,也不是保证回调精确发生在某个毫秒。

text
目标时间到了
    ↓
下一次引擎有机会检查时执行

如果主线程被阻塞,调度回调也不能绕过阻塞立即执行。


六、schedule 和 update 怎么选 ​

适合 update ​

ts
update(dt: number) {
    this.progress += this.speed * dt;
    this.refreshPosition();
}

适合:

  • 每帧连续变化。
  • 需要读取本帧状态。
  • 移动、跟随、插值和实时控制。

适合 schedule ​

ts
this.schedule(this.refreshServerTime, 1);

适合:

  • 低频轮询。
  • 周期性刷新。
  • 延迟一次执行。
  • 不需要每帧精度的任务。

如果一个任务每秒只需要执行一次,就不要写成空转的 update。


七、调度和对象状态的关系 ​

ts
this.schedule(this.tick, 1);
this.node.active = false;

这时可能出现的效果是:

text
节点进入非激活状态
    ↓
组件不再满足正常运行条件
    ↓
关联调度任务暂停或不再触发

不要仅凭函数名判断“定时器一定继续”或“一定停止”。需要看任务注册在哪个对象、对象是否启用、Scheduler 是否全局运行以及当前 Creator 版本的行为。

工程上应明确任务归属:

  • UI 倒计时绑定 UI 组件生命周期。
  • 网络心跳绑定网络服务生命周期。
  • 全局轮询绑定明确的全局管理器。

八、优先级和执行顺序 ​

当多个任务都在同一帧到期时,引擎需要某种顺序规则。常见影响因素包括:

text
组件注册顺序
执行优先级
节点层级
executionOrder
调度器内部队列

不要把“Hierarchy 面板上谁在前面”直接当成所有 update 的执行顺序。需要使用引擎支持的执行顺序配置,并尽量减少跨组件的隐式顺序依赖。

更稳妥的设计是:

text
输入采集
→ 状态计算
→ 表现同步

把关键阶段显式化,而不是让十几个脚本互相猜谁先执行。


九、调度系统为什么会影响性能 ​

每个可调度组件都会带来:

text
注册成本
状态判断
队列维护
每帧遍历
函数调用

因此:

  • 不用 update 就不要声明空 update。
  • 低频任务使用 schedule 或事件。
  • 暂停功能时优先关闭具体组件或任务。
  • 列表大量 Item 不要每个都维护无意义的定时器。
  • 复杂系统可以由一个管理器集中更新数据。

集中更新不等于所有逻辑塞进一个巨型脚本,而是减少重复调度并保持职责清晰。


十、案例:倒计时列表的两种写法 ​

写法 A:每个 Item 都有 update ​

ts
update(dt: number) {
    this.leftTime -= dt;
    this.label.string = formatTime(this.leftTime);
}

100 个 Item 就可能产生 100 个每帧调用,即使大部分倒计时显示相同。

写法 B:管理器统一推进 ​

ts
update(dt: number) {
    this.clock += dt;
    if (this.clock < 1) {
        return;
    }

    this.clock = 0;
    this.refreshVisibleItems();
}

这并不是所有场景都必须集中更新,而是要根据刷新频率、可见范围和数据依赖选择调度粒度。


十一、常见误区 ​

误区一:Scheduler 就是线程池 ​

Scheduler 通常运行在游戏主线程的时间调度体系中,不等于创建后台线程。

误区二:schedule(1) 会精确每 1000 毫秒执行 ​

它只能在引擎有机会推进时检查时间,主线程阻塞、后台切换和帧率变化都会影响实际触发时间。

误区三:所有 update 都由同一个简单数组遍历 ​

引擎可能维护不同阶段、优先级和状态集合。不要用一个简化模型推断所有源码细节。

误区四:关闭节点后全局任务也一定停止 ​

如果任务归属于全局 Scheduler 或其他管理器,关闭某个节点不一定影响它。

误区五:用更高频 update 解决时间精度 ​

提高业务调用频率不等于提高平台时间精度,反而可能增加 CPU 成本。


十二、练习与答案 ​

练习 ​

  1. Scheduler 和 ComponentScheduler 的职责分别是什么?
  2. 为什么不用的脚本不建议写空 update?
  3. 每秒刷新一次的任务,为什么通常不需要每帧 update?
  4. 关闭一个节点后,为什么某些全局任务仍可能继续?
  5. 多个组件依赖执行顺序时,应该如何降低风险?

参考答案 ​

  1. Scheduler 主要处理时间任务,ComponentScheduler 主要处理组件生命周期和每帧方法。
  2. 空 update 仍可能进入组件调度、判断和函数调用。
  3. 使用 schedule 或低频管理器可以减少每帧调度成本。
  4. 任务可能注册在全局管理器或独立 Scheduler 上,和节点生命周期无关。
  5. 使用明确的阶段、执行顺序配置或集中管理,避免隐式依赖注册顺序。

十三、Creator 2.4.x 可复现实验:三种调度来源 ​

在同一个节点挂载以下脚本:

ts
const { ccclass } = cc._decorator;

@ccclass
export default class SchedulerProbe extends cc.Component {
    private count = 0;
    onLoad() {
        this.schedule(() => cc.log('[schedule]', ++this.count), 0.5);
        this.scheduleOnce(() => cc.log('[scheduleOnce]'), 1.2);
        cc.director.getScheduler().schedule(() => cc.log('[global]'), this, 1, cc.macro.REPEAT_FOREVER, 0);
    }
    update(dt: number) { if (this.count < 2) cc.log('[update]', dt.toFixed(3)); }
    onDisable() { cc.log('[disable]'); }
}

先运行 3 秒,再把节点设为 inactive,观察哪些日志停止;最后在 onDestroy 中取消全局调度并重复切场景。预期组件的 schedule 和 update 随节点/组件状态停止,全局 Scheduler 上注册的任务若未显式取消可能继续执行。若日志数量异常,检查重复 onLoad、重复注册和未对称 unschedule。

十四、版本边界与排查矩阵 ​

this.schedule、unschedule 和 cc.director.getScheduler() 是 Creator 2.4.x 可用的公开入口;ComponentScheduler 的内部队列、优先级和某些重复注册细节应以当前版本源码为准。调度器是主线程时间队列,不会把回调自动移到后台线程。

症状证据修复方向
回调越来越多每次打开页面数量递增在 onDisable/onDestroy 对称取消
节点关闭后仍打印回调注册在全局 Scheduler保存 owner,显式 unschedule
低频任务造成卡顿单次回调耗时高拆分任务、降低频率、分帧处理

十五、应用题 ​

题目: 一个金币计数器每 0.1 秒刷新一次,但页面不可见时仍消耗 CPU。应如何改?

答案: 让刷新任务归属页面组件,使用 schedule 而不是全局 Scheduler;在 onDisable 取消,在 onEnable 恢复。真正的金币状态由数据层保存,页面重新启用时立即读取一次,避免依赖停留期间的 UI 回调。

十六、本课总结 ​

text
Scheduler 管时间任务。
ComponentScheduler 管组件生命周期和 update。
调度不是线程,主线程阻塞时回调也会延迟。
组件状态变化会影响调度集合。
调度粒度应该和任务频率、可见范围、数据依赖匹配。

十七、下一课预告 ​

text
Lesson016|组件生命周期如何注册、启用和移出调度队列?

下一课会专门解释组件从创建到进入 update 调度需要经过哪些注册步骤。