《Cocos 游戏开发实战》15.3 启动耗时优化

首包压下去了、资源上了 CDN,用户从点开到看到第一屏仍要等。本节把启动时间拆成「引擎初始化 / 脚本执行 / 首场景加载 / 首屏渲染」四段,用引擎事件打点、用纯函数聚合,给出五个可落地的削减动作与构建面板里的对应开关,并把首屏预算对齐到 1.1 的 NFR-02。

15.3 启动耗时优化

15.1 把资源从首包挪走,15.2 把分包放上了 CDN。首包体积已经压到红线以下,但用户从「点开小游戏」到「看到第一屏」仍然要等——等的不是下载,而是引擎初始化、脚本执行、首场景加载、首屏渲染这一串串行动作。1.1 里 NFR-02 定的「中端机首屏 ≤ 1.2 秒」,说的就是这一串动作的总时长。

与站内 game/client/ 的性能文章的关系:那些文章多讲运行时帧率;本节讲的是启动阶段——它和帧率是两套预算,帧率看的是每帧 16.6ms,启动看的是从 0 到第一屏的墙钟时间。

优化启动耗时的前提是能测。测不出「哪一段花了多少」,所有优化都是拍脑袋。所以本节先解决测量(15.3.2、15.3.3),再给削减动作(15.3.4、15.3.5)。

15.3.1 首屏时间由什么构成

把从「点开」到「第一屏可见」的过程按顺序切开,得到四段。每一段的成因不同,对策也不同:

阶段包含什么典型占比(参考值)主要变量
引擎初始化加载引擎 JS、创建渲染上下文25%引擎模块裁剪、渲染后端
脚本执行解析并执行项目脚本、反序列化场景30%脚本体积、首场景节点数
首场景加载加载首场景依赖资源25%首包资源量、贴图格式
首屏渲染首次 draw、Splash 淡出20%首屏节点数、Shader 编译

这四个数字是量级参考,需在你的设备上复测——不同机型、不同平台差别很大。比如原生端的引擎初始化占比更高(要初始化 JSB 桥接),而 H5 端脚本执行占比更高(JS 解析慢)。所以先测自己的分布,再决定砍哪一段。

有一点必须分清:冷启动和热启动是两回事。冷启动指进程从零开始(首次进入、被系统回收后重进),热启动指进程还在、只是切回前台。小游戏用户大多数时候是热启动,但我们优化的目标是冷启动——因为它决定了「新用户第一次点开会等多久」。做测量时务必标注是哪一种,否则两拨人各测各的,永远对不上。

关于启动预算的分配原则:1.2 秒不是「越快越好」的口号,而是一份要分配掉的预算。参考的分配如下(同样需按你的实测调整):

  • 引擎初始化:留 300ms
  • 脚本执行:留 350ms
  • 首场景加载:留 300ms
  • 首屏渲染:留 250ms

任何一段超支,都要从别处借或就地削减。这份分配表的价值在于:当实测显示「脚本执行花了 600ms」时,你立刻知道超了 250ms,而不是笼统地觉得「启动有点慢」。

15.3.2 用引擎事件打点

Cocos 在启动过程中会派发一串事件,我们可以挂监听记录时间戳。下面这个组件用 game 与 director 的事件,配合 sys.now()(毫秒级时钟)记录三个关键时刻:

import { _decorator, Component, director, Director, game, Game, sys, log } from 'cc';

const { ccclass } = _decorator;

@ccclass('BootTimer')
export class BootTimer extends Component {
  private engineInitedAt = 0;
  private sceneLaunchedAt = 0;

  onLoad(): void {
    game.on(Game.EVENT_ENGINE_INITED, this.onEngineInited, this);
    director.on(Director.EVENT_AFTER_SCENE_LAUNCH, this.onSceneLaunched, this);
  }

  private onEngineInited(): void {
    this.engineInitedAt = sys.now();
    log(`[block-dash] engine inited @ ${this.engineInitedAt.toFixed(0)}ms`);
  }

  private onSceneLaunched(): void {
    this.sceneLaunchedAt = sys.now();
    const sceneMs = this.sceneLaunchedAt - this.engineInitedAt;
    log(`[block-dash] scene launched, engine->scene = ${sceneMs.toFixed(0)}ms`);
  }

  onDestroy(): void {
    game.off(Game.EVENT_ENGINE_INITED, this.onEngineInited, this);
    director.off(Director.EVENT_AFTER_SCENE_LAUNCH, this.onSceneLaunched, this);
  }
}

三个符号都必须写对:Game.EVENT_ENGINE_INITED(引擎初始化完成,值 "engine_inited")、Director.EVENT_AFTER_SCENE_LAUNCH(首场景启动完成)、sys.now()(当前毫秒时钟)。game 与 director 都继承自 EventTarget,所以 on / off 的第三个参数是回调里的 this 绑定——漏了它,onDestroy 里就 off 不掉,事件会一直挂着。这是 Cocos 里很典型的一处泄漏。

如果还想测到「首帧渲染完成」,可以在场景启动后等一帧再记录。渲染发生在引擎的绘制阶段,所以挂在 Director.EVENT_AFTER_DRAW 上、只取第一次即可:

import { director, Director, sys } from 'cc';

let firstFrameAt = 0;
const onFirstDraw = (): void => {
  if (firstFrameAt !== 0) return;
  firstFrameAt = sys.now();
  director.off(Director.EVENT_AFTER_DRAW, onFirstDraw);
};

director.on(Director.EVENT_AFTER_DRAW, onFirstDraw);

这段是模块级代码(不在组件里),因为它要在场景树建立之前就挂上。firstFrameAt 就是 BootMarks.firstFrame 的来源。注意它只取第一次——EVENT_AFTER_DRAW 每帧都触发,不摘监听会把日志刷爆,也会让 firstFrameAt 被反复覆盖成「最后一帧」。

15.3.3 把打点结果聚合成可断言的纯函数

打点日志只能看,不能判定「通过 / 不通过」。把三个时间戳交给一个纯 TS 函数聚合,就能在 CI 里断言。这段逻辑与引擎无关,可直接单测:

export interface BootMarks {
  readonly engineInited: number;
  readonly sceneLaunched: number;
  readonly firstFrame: number;
}

export interface BootBreakdown {
  readonly engineMs: number;
  readonly sceneMs: number;
  readonly totalMs: number;
  readonly overBudget: boolean;
}

export const BOOT_BUDGET_MS = 1200;

export function summarizeBoot(marks: BootMarks): BootBreakdown {
  const engineMs = marks.engineInited;
  const sceneMs = marks.sceneLaunched - marks.engineInited;
  const totalMs = marks.firstFrame;
  return { engineMs, sceneMs, totalMs, overBudget: totalMs > BOOT_BUDGET_MS };
}

BOOT_BUDGET_MS = 1200 直接对齐 1.1 的 NFR-02。overBudget 这个布尔值就是 CI 的失败判据:16.2 的冒烟脚本会把真实采样的 BootMarks 喂进 summarizeBoot,只要 overBudget 为 true 就标红。把「1.2 秒」这个数字写死在纯函数里,比散落在文档里可靠。

15.3.4 五个可落地的削减动作

按「投入产出比」从高到低排,前两个几乎每个项目都该做:

动作做法预期收益(参考值,需复测)
引擎模块裁剪构建面板关掉用不到的物理 / 3D / 粒子模块引擎初始化 -20%~30%
首场景瘦身首场景只留启动逻辑,UI 用代码按需实例化脚本执行 -15%
纹理压缩首包贴图用 ASTC / ETC2,别用未压缩 PNG首场景加载 -30%
脚本分包非首屏脚本延迟加载脚本执行 -10%
关掉 Source Maps构建面板取消勾选产物变小,加载略快

第一条最容易被忽略:Cocos 默认打包的引擎是全量的,一个纯 2D 跑酷根本用不到 3D 物理和骨骼。在构建面板里把「引擎模块配置」按需勾选,能把引擎体积砍掉一大块。代价是以后用到新模块要重新勾——所以裁剪清单要写进工程文档,而不是靠记忆。

第三条要配合 15.1 的分包纪律:首包贴图必须压缩。一张 1024×1024 的未压缩 PNG 解码后占 4 MB 内存、下载体积可能几百 KB;换成 ASTC 后下载体积和内存都显著下降。平台支持不同格式(iOS 偏好 ASTC,Android 偏好 ETC2),构建面板里选「自动」让引擎按平台挑。

把这五条展开成可执行的动作,团队里按顺序做:

  1. 引擎裁剪:先做。它不依赖美术资源,改的是构建配置,一次生效、收益最稳。
  2. 首场景瘦身:把首场景里「其实进游戏才用」的节点删掉,改成代码在 start() 里按需实例化。
  3. 纹理压缩:需要美术配合出压缩图,周期长,但收益大,要提前排期。
  4. 脚本分包:把非首屏的脚本模块(结算、商店、设置)挪到独立 Bundle,首屏只引启动逻辑。
  5. 关 Source Maps:收尾动作,一行配置的事,顺手就做。

其中第 2 条最容易和「省事」冲突:很多人图方便,把所有 UI 都塞进首场景,反正「反序列化而已」。但首场景每多一个节点,序列化数据就多一份,脚本执行阶段就要多解析一份——这正是启动耗时里最贵的那一段。首场景的正确形态是极简:一个启动脚本节点,其余全部按需创建。

这里还有一个反直觉的点:减少首屏节点往往比减少资源体积更有效。资源压缩砍的是「首场景加载」那 300ms,而减少首屏节点砍的是「脚本执行」那 350ms——后者往往更贵。实测中常见的情形是:贴图压了半天只省下 80ms,而把首屏 UI 从 120 个节点减到 40 个,脚本执行直接少了 200ms。原因是节点反序列化涉及大量对象创建与属性赋值,在弱机上开销被放大。所以优化顺序上,先动节点结构,再动资源体积。

15.3.5 构建面板里的对应开关

以下操作需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现:

  1. 打开 项目 → 构建发布,选目标平台。
  2. 展开 「引擎模块配置」(部分版本叫「引擎裁剪」),取消勾选:3D Physics、Skeletal Animation、Particle System、Terrain、Tiled Map。
  3. 展开 「首场景」,确认填的是 main(启动场景名,不带扩展名)。
  4. 展开 「Splash Screen」,把「显示时长」设为 0.3 秒——太长的 Splash 会直接吃掉首屏预算。
  5. 确认 「Source Maps」 未勾选。
  6. 点「构建」,构建结束后打开产物里的 assets/main/ 复核首包资源。

在编辑器里打开构建面板,你会看到:「引擎模块配置」是一个可折叠的树状勾选列表,展开后能看到 Physics、Render、UI 等分组;「Splash Screen」下面有「背景色」「显示时长」「Logo」三项;「首场景」是一个文本框,值就是 main。首场景名写错会让构建直接失败,因为引擎找不到要打包的场景。

15.3.6 实测与复测

改完配置必须复测,否则不知道优化有没有生效。测法有两种:

# H5 端:构建后在本地起服务,用浏览器 DevTools 的 Performance 面板录首屏
# 关键看两个标记:navigationStart 到首次绘制(FCP)
npx http-server build/web-mobile -p 8080

# 原生 / 小游戏端:用 BootTimer 打出的日志时间戳
# 在真机日志里搜 "[block-dash] scene launched"

复测要固定条件:同一台设备、同一网络、冷启动(清掉缓存)。否则测出来的波动会盖过优化收益。把每次复测的 BootBreakdown 记进表格,形成一条趋势线——单次测量没有意义,趋势才有。

不同平台的测量工具也不一样,别混用结论:H5 端用浏览器 DevTools 的 Performance / Lighthouse,看的是 navigationStart 到 FCP;微信小游戏端用微信开发者工具的「性能」面板,看的是「启动耗时」;原生端没有现成面板,只能靠 BootTimer 的日志。三者的计时口径不同,跨平台比较绝对值没有意义,只能各自和自己的历史比。

复测轮次机型引擎 (ms)场景 (ms)总时长 (ms)是否达标
优化前中端机 A3807201310否
裁剪引擎后中端机 A2607001150是
压缩贴图后中端机 A260480940是

上表的数字是量级示意,需在你的设备上复测。它想说明的是:优化后总时长从 1310ms 降到 940ms,跨过了 1200ms 的预算线。这个跨线才是本节要的结果,而不是某个具体的毫秒数。

这张表还有一个用途:它是回归基线。以后任何一次改动,只要 totalMs 比基线明显变差,就说明有人偷偷加了首屏节点或首包资源。把这个比对交给 CI(16.2 的冒烟脚本会把采样喂进 summarizeBoot),「启动变慢」就会像单元测试失败一样,在合并前被拦下来。

15.3.7 三个常见误判

启动耗时优化里,有三类「改了没用」的情况反复出现,值得单独列出来:

误判表象真相
以为瓶颈在下载首包已很小,还是很慢慢在脚本执行 / 反序列化,不在网络
只测一次就下结论某次测出 900ms,以为达标单次测量波动可达 ±20%,必须多轮取中位
拿开发构建测速开发模式启动很快开发模式不压缩、不打分包,和发布包不是一回事

第三条尤其坑:编辑器里点「预览」跑得飞快,发布包却慢一大截。原因是预览用的是未压缩的脚本与未合并的资源。所有启动耗时结论,都必须基于发布构建(构建面板正常构建出来的产物),不能在预览模式下测。

这三条误判的共同根源,是把「感觉快」当成了「测出来快」。启动耗时是一个跨层耦合的指标:它同时受构建配置、脚本结构、资源格式、运行设备影响。任何单点优化如果不配合实测,都无法确认收益。所以本节的顺序是「先能测,再优化」——BootTimer 和 summarizeBoot 不是附属品,而是整个优化流程的前提。

15.3.8 上线前的启动自检清单

启动这块的改动,上线前逐条过:

  • 引擎模块已按需裁剪,且裁剪清单写进了工程文档
  • 首场景是极简启动场景,UI 按需实例化
  • 首包贴图已压缩(ASTC / ETC2 / 自动)
  • Splash 显示时长 ≤ 0.3 秒
  • BootTimer 的 onDestroy 里 off 掉了所有监听
  • 发布包实测 BootBreakdown.totalMs ≤ 1200ms(多轮取中位)
  • 断网冷启动有明确提示,不是白屏
  • 首场景名与构建面板「首场景」一致,构建无「找不到场景」报错
  • 分包资源的加载时机在首屏之后,不阻塞第一帧
  • 复测记录进了趋势表,且有可对比的上一轮基线

15.3.9 工程视角的收口

第 15 章到这里收口。三节下来,工程里多了:bundle-levels / bundle-audio 两个 Bundle(含远程包配置)、一个可编译的 BundleLoader 与 RemoteBundleLoader、builder.json 里的远程地址与压缩配置、一个 BootTimer 与纯函数 summarizeBoot,以及一张缓存头策略表与一张启动预算表。

回到 1.1 的三条 NFR 对账:NFR-02 首屏 ≤ 1.2 秒由本节兑现;NFR-03 主包 ≤ 4 MB 由 15.1 兑现;NFR-06 Draw Call ≤ 60 由 13.1 负责,本节的分包纪律不能把它弄坏。这就是第 15 章「性能达标」这个里程碑(M3)的落点。

首包、CDN、启动耗时都优化完了,接下来要回答一个更根本的问题:怎么保证每次改完,这些指标不会悄悄退回去? 靠人肉复测不行——必须让机器在 CI 里自动跑单测、做类型校验、跑冒烟。第 16 章讲质量保障,第一节先给「逻辑单测 + 对 cc.d.ts 做类型校验」这条门禁。

这三节之间其实是一条链:分包决定首包下多少,CDN 决定剩下的怎么按需拿,启动优化决定拿完之后多久能看到画面。 三者任何一个失守,用户感受到的都是同一件事——「这个游戏打开好慢」。所以它们必须一起做、一起测、一起守。

阅读导航:上一节:15.2 远程资源与 CDN · 下一节:16.1 逻辑单测与类型校验 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

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