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),构建面板里选「自动」让引擎按平台挑。
把这五条展开成可执行的动作,团队里按顺序做:
- 引擎裁剪:先做。它不依赖美术资源,改的是构建配置,一次生效、收益最稳。
- 首场景瘦身:把首场景里「其实进游戏才用」的节点删掉,改成代码在
start()里按需实例化。 - 纹理压缩:需要美术配合出压缩图,周期长,但收益大,要提前排期。
- 脚本分包:把非首屏的脚本模块(结算、商店、设置)挪到独立 Bundle,首屏只引启动逻辑。
- 关 Source Maps:收尾动作,一行配置的事,顺手就做。
其中第 2 条最容易和「省事」冲突:很多人图方便,把所有 UI 都塞进首场景,反正「反序列化而已」。但首场景每多一个节点,序列化数据就多一份,脚本执行阶段就要多解析一份——这正是启动耗时里最贵的那一段。首场景的正确形态是极简:一个启动脚本节点,其余全部按需创建。
这里还有一个反直觉的点:减少首屏节点往往比减少资源体积更有效。资源压缩砍的是「首场景加载」那 300ms,而减少首屏节点砍的是「脚本执行」那 350ms——后者往往更贵。实测中常见的情形是:贴图压了半天只省下 80ms,而把首屏 UI 从 120 个节点减到 40 个,脚本执行直接少了 200ms。原因是节点反序列化涉及大量对象创建与属性赋值,在弱机上开销被放大。所以优化顺序上,先动节点结构,再动资源体积。
15.3.5 构建面板里的对应开关
以下操作需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现:
- 打开 项目 → 构建发布,选目标平台。
- 展开 「引擎模块配置」(部分版本叫「引擎裁剪」),取消勾选:
3D Physics、Skeletal Animation、Particle System、Terrain、Tiled Map。 - 展开 「首场景」,确认填的是
main(启动场景名,不带扩展名)。 - 展开 「Splash Screen」,把「显示时长」设为
0.3秒——太长的 Splash 会直接吃掉首屏预算。 - 确认 「Source Maps」 未勾选。
- 点「构建」,构建结束后打开产物里的
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) | 是否达标 |
|---|---|---|---|---|---|
| 优化前 | 中端机 A | 380 | 720 | 1310 | 否 |
| 裁剪引擎后 | 中端机 A | 260 | 700 | 1150 | 是 |
| 压缩贴图后 | 中端机 A | 260 | 480 | 940 | 是 |
上表的数字是量级示意,需在你的设备上复测。它想说明的是:优化后总时长从 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 逻辑单测与类型校验 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。