Skip to content

Stage05|Performance ​

Lesson058|CPU 性能:一帧时间到底花在哪里? ​

Tags: #Creator2.x #Stage05 #CPU #Update #LayoutDifficulty: ⭐⭐⭐⭐☆


真实问题:CPU 占满时,删掉几个 update 为什么没用 ​

Profiler 显示主线程一帧 28 ms。团队搜索所有 update 并删掉几个空函数,结果几乎不变。

CPU 一帧不只运行用户 update:

text
输入与事件
→ Scheduler / Tween / Animation
→ 用户脚本
→ Layout / Widget / Label
→ Transform 与渲染数据
→ 批次构建和图形提交
→ GC / 引擎维护

必须找到最长区间和调用次数,而不是按函数名优化。

一、本课目标 ​

CPU 卡顿通常不是某一行代码突然变慢,而是每帧工作累积超过预算。本课建立 CPU 帧分析方法。


二、一帧 CPU 工作的组成 ​

text
输入和事件
→ Scheduler / Timer
→ Component update
→ Animation / Action
→ Transform / Layout
→ Node 遍历和状态传播
→ RenderData 组装
→ DrawCall 准备和提交

不同版本和平台的具体顺序可能不同,但这些类别构成了主要排查范围。


三、CPU 的四种常见浪费 ​

  1. 调用太多:大量组件、定时器、事件和空 update。
  2. 单次太重:复杂排序、布局、字符串处理或同步加载。
  3. 重复计算:同一帧反复刷新 Layout、Transform 或 Label。
  4. 分配太多:临时数组、对象、闭包导致 GC。

四、update 优化不等于删逻辑 ​

错误方式:

ts
update() {
    if (!this.visible) {
        return;
    }
    this.refreshAllItems();
}

如果组件仍然每帧被调度,判断本身也有成本。更好的方式可能是:

ts
onEnable() {
    this.schedule(this.refreshVisibleItems, 0.2);
}

onDisable() {
    this.unschedule(this.refreshVisibleItems);
}

具体选择要看刷新频率和交互需求。


五、遍历和布局成本 ​

一个 UI 操作可能触发:

text
修改子节点尺寸
→ 父 Layout 标记 dirty
→ 重新遍历子节点
→ 计算位置
→ Widget 再次修正
→ RenderData 更新

如果在循环中逐项修改并立即读取布局结果,可能反复触发计算。应尽量批量修改,再在需要时统一刷新。


六、字符串和 Label 的 CPU 成本 ​

ts
update() {
    this.label.string = 'Score: ' + this.score;
}

每帧拼接字符串不一定是大问题,但在大量 Label 或复杂文本中会产生:

  • 字符串分配。
  • 文本解析。
  • 顶点/纹理更新。
  • 布局重算。

可以只在 score 改变时刷新,而不是每帧赋相同值。


七、CPU 和 DrawCall 的关系 ​

DrawCall 既可能影响 GPU,也会增加 CPU 提交成本:

text
渲染组件准备数据
→ 状态切换
→ 组织批次
→ 提交 DrawCall

因此 DrawCall 优化不能只看 GPU。真正要判断的是:提交成本是否占用了 CPU 帧预算,GPU 是否同时成为瓶颈。


八、案例:列表刷新卡顿 ​

ts
for (const item of items) {
    item.node.active = true;
    item.refresh(data);
    item.layout();
}

潜在问题:

  • 每个 Item 触发布局。
  • 每个 Item 更新 Label。
  • 每项都执行事件和动画。
  • 产生大量临时对象。

改进方向:

text
先准备数据
→ 只更新可见项
→ 批量修改属性
→ 避免循环内强制读取布局结果
→ 最后统一刷新

九、常见误区 ​

误区一:减少 Node 数量一定能解决 CPU 卡顿 ​

如果主要成本是字符串、资源解码或某个重函数,删空节点收益有限。

误区二:函数调用次数多就一定要合并成一个巨型脚本 ​

合并可能降低调度成本,却增加耦合和维护成本,应先测量。

误区三:只看单次函数耗时 ​

总成本 = 单次耗时 × 调用次数,频繁的小函数也可能成为主要成本。

误区四:布局刷新越早越好 ​

过早读取可能强制同步计算,导致重复刷新。


十、练习与答案 ​

  1. CPU 帧成本通常包括哪些类别?
  2. 为什么循环中逐项修改并立即读取布局可能很慢?
  3. Label 字符串应该每帧更新吗?
  4. 如何判断一个小函数是否值得优化?

答案:

  1. 输入、调度、脚本、动画、布局、遍历、RenderData 和提交。
  2. 每次修改都可能触发布局或 Transform 计算,形成重复工作。
  3. 通常只在数据真正变化时更新。
  4. 看总耗时、调用次数、是否位于长帧中,而不是只看单次耗时。

CPU 成本的三个乘数 ​

text
总成本 ≈ 单次成本 × 调用次数 × 更新频率

例如单次只需 0.02 ms 的操作:

text
0.02 ms × 500 个 Item × 60 FPS
→ 每帧约 10 ms

因此优化可以来自:

  • 降低单次算法复杂度;
  • 缩小参与对象数量;
  • 从每帧改成事件驱动或低频更新。

Creator 2.4.x 实验:同一任务的三种调度 ​

CpuCostProbe.ts ​

ts
const { ccclass, property } = cc._decorator;

@ccclass
export default class CpuCostProbe extends cc.Component {
    @property([cc.Label])
    labels: cc.Label[] = [];

    private mode = 0;
    private elapsed = 0;
    private dirty = true;

    setMode(value: number) {
        this.mode = value;
        this.dirty = true;
    }

    markDirty() {
        this.dirty = true;
    }

    update(dt: number) {
        this.elapsed += dt;

        if (this.mode === 1) {
            this.refreshAll();
        } else if (this.mode === 2 && this.dirty) {
            this.dirty = false;
            this.refreshAll();
        }
    }

    private refreshAll() {
        for (let i = 0; i < this.labels.length; i++) {
            const next = String(i + Math.floor(this.elapsed));
            if (this.labels[i].string !== next) {
                this.labels[i].string = next;
            }
        }
    }
}

对照 ​

  1. mode 0:不刷新。
  2. mode 1:每帧全量刷新。
  3. mode 2:数据变化时刷新。
  4. 将 Label 数从 20 增加到 200。
  5. 固定 Label 尺寸与启用 Layout 分别测试。

记录函数调用数、Self/Total Time、Layout/Label 子调用和 p99。

遍历为什么常是隐藏乘数 ​

ts
for (const item of allItems) {
    if (item.visible) {
        item.refresh();
    }
}

即使只刷新可见项,仍每帧遍历全部。更好的数据结构可能直接维护可见集合或索引范围。

优化前问:

  • 数据量上限;
  • 每帧实际参与数;
  • 是否可由事件维护集合;
  • 是否重复搜索节点/组件;
  • 是否在嵌套循环中做字符串和分配。

真实项目故障:列表刷新只要 3 ms,为什么打开卡 80 ms ​

调用栈显示 refreshList 自身循环 3 ms,但它创建 200 Item,引发:

text
onLoad/onEnable
→ Label 字形
→ Layout 多轮刷新
→ Widget 对齐
→ Transform/RenderData

Total Time 远高于 Self Time。修复:

  • 首屏只创建可见项;
  • 批量设置数据后统一布局;
  • 避免每加一个 Item 立即刷新 Layout;
  • 使用对象池但完整 reset;
  • 将工作跨帧分摊并给出 loading 状态。

CPU 故障诊断 ​

JS 函数不高,但一帧仍长 ​

看引擎阶段、Layout、渲染数据、GC 和原生调用;Total Time 可能归在子栈。

每隔几秒长帧 ​

检查批量定时任务、存档、日志、网络消息聚合和 GC。用时间标记对齐。

优化后平均改善但 p99 不变 ​

持续循环变快了,尖峰来源仍存在。分别优化稳定成本和长帧。

版本边界与练习答案 ​

Web 与 Native 的 JS 引擎、JIT、JSB 和采样工具不同。Creator 2.4.x CPU 结论必须在目标平台复测。

  1. 为什么空 update 也不应大量存在? 调度与调用本身有成本,但删少量空函数不一定击中主要瓶颈。
  2. 事件驱动为何常优于每帧轮询? 只在数据变化时做工作,降低更新频率乘数。
  3. Object Pool 是否必然降低 CPU? 不一定,reset、监听清理、池查找和过大池都有成本;要比较创建/销毁尖峰。
  4. 为什么看调用次数? 很小的单次成本可被大量对象和高频率放大。

十一、本课总结 ​

text
CPU 性能要同时看调用次数、单次成本、重复计算和内存分配。
Update、Layout、Label、对象创建和渲染提交都可能成为 CPU 瓶颈。

十二、下一课预告 ​

text
Lesson059|GPU 性能:DrawCall、Overdraw、带宽和 Fill Rate