站内已有一整卷 《Cocos 游戏开发·高级》 ——那 34 篇讲的是 Cocos Creator 引擎内部与定制:渲染管线怎么改、组件系统怎么扩、原生平台怎么接。本章讲的是与引擎无关的性能分析方法:首包体积 → 启动耗时的分解模型、卡顿的归因路径、内存峰值与包体的取舍——方法可迁移到任何引擎。那卷教你「在 Cocos 里怎么改引擎」,本章教你「怎么把一次冷启动拆开,看清每一段各花了多久」。
进入第 14 章「性能优化」。前三节分别处理首包与冷启动、帧率与卡顿、内存与包体——这三件事常被笼统地叫「优化」,但它们的测量口径、敏感变量、取舍方向完全不同。这一节只谈第一个:从「点了图标」到「看见能操作的首屏」这段时间花在哪。第二、三节再分别处理卡顿与内存。
冷启动是一条可以拆开的时间线
玩家第一次打开小游戏,从点了图标到看见能操作的首屏,中间其实排了一串事情:先把首包从网络上拉下来,再解析代码,再编译(或 JIT 预热),最后初始化并渲染出第一帧。这四件事的耗时量级不同,对包体体积的敏感度也不同——有的几乎正比于体积,有的近乎固定。把它们混成一个「启动慢」的笼统印象,就没法决定该砍哪一块。
本节用的模型把冷启动拆成四段可加的整数毫秒:
| 段 | 公式(整数毫秒) | 敏感于 |
|---|---|---|
| 下载 download | sizeKB × 1000 ÷ kbPerSec | 包体、网络 |
| 解析 parse | sizeKB × parseUsPerKB ÷ 1000 | 包体 |
| 编译 compile | sizeKB × compileUsPerKB ÷ 1000 | 包体 |
| 首帧 firstFrame | firstFrameBaseMs + sizeKB × firstFrameUsPerKB ÷ 1000 | 包体(弱) |
最后再叠一个固定启动项 bootBaseMs——引擎初始化、平台 SDK 握手这类与体积无关的开销。
⚠️ 必须先说清楚:这是解析模型,不是真机实测。各段系数是模型假设,它用来比较「哪一段是大头」与「不同网络档位差多少」的相对量级;绝对数值不可移植到任何真机。真实项目的冷启动还受冷热缓存、并发下载、解码线程数影响,这些本模型一概不建模。
为什么下载段要单独拎出来
四段里只有 download 是直接吃网络的:sizeKB × 1000 ÷ kbPerSec,每多 1KB 就多 1000 ÷ kbPerSec 毫秒。而 parse / compile / firstFrame 三段只跟包体大小有关,跟网络档位无关。这个区分很关键——它意味着「该优化下载还是优化解析」的答案,取决于你的目标用户在什么网络上:网络越快,下载段占比越低,解析编译段就越显眼;网络越慢,下载段会迅速吃掉整个预算,此时唯一有效的动作是砍包体,优化解析代码几乎没用。
三档网络:同一包体差出量级
拿《合成小岛》的 2048KB 探针包体跑一遍三档网络,四段分解如下(整数毫秒):
| 网络档位 | 带宽 | 下载 | 解析 | 编译 | 首帧 | 合计 |
|---|---|---|---|---|---|---|
| Wi-Fi | 1600KB/s | 1280 | 368 | 532 | 121 | 2421 |
| 4G | 420KB/s | 4876 | 368 | 532 | 121 | 6017 |
| 弱网 3G | 120KB/s | 17066 | 368 | 532 | 121 | 18207 |
注意解析 / 编译 / 首帧三列三档完全一样——它们不吃网络。差的全部来自下载段:从 1280ms 到 17066ms,跨三档网络就拉开了 15786ms 的极差。Wi-Fi 档下下载只占 528‰(约一半),到了弱网档下载几乎独占。这就是为什么「首包体积」在小游戏里是硬约束:弱网用户的启动时间几乎完全由它决定。
先看配置
下面这段是 D 层(静态层)配置 config/e14_firstload.json:探针包体、三档网络带宽、四段的系数,以及首屏软预算。系数全是模型假设,改它就能改模型的乐观 / 悲观程度。
{
"version": 1,
"game": "merge-island",
"seed": 20261012,
"note": "首包体积 → 冷启动耗时的解析模型(不是真机实测)。各段系数是模型假设,用来比较「各段占比」与「网络档位差异」的相对量级,绝对数值不可移植。全整数毫秒,禁浮点。",
"probeKB": 2048,
"networkTiers": [
{"id": "wifi", "label": "Wi-Fi", "kbPerSec": 1600},
{"id": "lte", "label": "4G", "kbPerSec": 420},
{"id": "weak", "label": "弱网 3G", "kbPerSec": 120}
],
"cost": {
"bootBaseMs": 120,
"parseUsPerKB": 180,
"compileUsPerKB": 260,
"firstFrameBaseMs": 60,
"firstFrameUsPerKB": 30
},
"sweep": {"startKB": 512, "stopKB": 6144, "stepKB": 512},
"budget": {"firstScreenSoftMs": 4000, "downloadSoftMs": 3000}
}
parseUsPerKB / compileUsPerKB 的单位是「微秒 / KB」,除以 1000 转成毫秒,全程整数地板除——这样 Python、TS、浏览器三处用同一份配置能逐位对齐,不引入浮点误差。
S 层:冷启动分解模型
下面这段是 S 层(仿真层)脚本 sim/e14_firstload.py:读 config/e14_firstload.json,对探针包体算三档网络的四段分解,对整条包体区间算首屏时间曲线,产出 JSON + CSV + SVG。SVG 是纯 Python 用 xml.etree 画的堆叠横条(本机没装 matplotlib)。
#!/usr/bin/env python3
# file: sim/e14_firstload.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/e14_firstload.py --config config/ --out out/
"""首包体积 → 冷启动耗时的分解模型:下载 / 解析 / 编译 / 首帧四段。
⚠️ 这是**解析模型,不是真机实测**。它把「冷启动耗时」拆成四段可加的整数毫秒:
download = sizeKB * 1000 // kbPerSec (按网络档位每秒可下 KB 递推)
parse = sizeKB * parseUsPerKB // 1000 (解析与体积成正比)
compile = sizeKB * compileUsPerKB // 1000 (编译与体积成正比)
firstFrame = firstFrameBaseMs + sizeKB * firstFrameUsPerKB // 1000
再叠一个固定启动项 bootBaseMs。各段系数都是**模型假设**,用来比较「哪一段是大头」与
「不同网络档位下同一包体差多少」的**相对量级**;绝对数值不可移植到真机。
产出:out/e14_firstload.json + out/e14_firstload_sweep.csv + out/e14_firstload.svg
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET
SEG_ORDER = ["download", "parse", "compile", "firstFrame"]
SEG_COLOR = {"download": "#2563eb", "parse": "#059669", "compile": "#f59e0b", "firstFrame": "#dc2626"}
def load(cfg_dir):
with open(os.path.join(cfg_dir, "e14_firstload.json"), encoding="utf-8") as f:
return json.load(f)
def segments(size_kb, kb_per_sec, cost):
"""把一个包体拆成四段整数毫秒。全整数,无浮点。"""
return {
"download": size_kb * 1000 // kb_per_sec,
"parse": size_kb * cost["parseUsPerKB"] // 1000,
"compile": size_kb * cost["compileUsPerKB"] // 1000,
"firstFrame": cost["firstFrameBaseMs"] + size_kb * cost["firstFrameUsPerKB"] // 1000,
}
def total_ms(size_kb, kb_per_sec, cost):
return cost["bootBaseMs"] + sum(segments(size_kb, kb_per_sec, cost).values())
def draw_svg(path, rows, cost):
"""纯 Python SVG:探针包体下,每个网络档位一根横条,按四段堆叠。"""
W, PAD_L, PAD_R, ROW = 780, 150, 170, 62
H = 92 + ROW * len(rows)
bar_max, peak = W - PAD_L - PAD_R, max(r["totalMs"] for r in rows) or 1
svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W),
"height": str(H), "viewBox": f"0 0 {W} {H}"})
ET.SubElement(svg, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
ET.SubElement(svg, "text", {"x": "16", "y": "30", "font-size": "15", "fill": "#111827"}
).text = "首包体积 → 冷启动耗时的分解(解析模型,非真机实测)"
for i, r in enumerate(rows):
y, x = 72 + i * ROW, PAD_L
ET.SubElement(svg, "text", {"x": "16", "y": str(y + 20), "font-size": "12", "fill": "#374151"}
).text = f'{r["label"]} {r["kbPerSec"]}KB/s'
for seg in SEG_ORDER:
w = max(2, (bar_max * r["seg"][seg]) // peak)
ET.SubElement(svg, "rect", {"x": str(x), "y": str(y + 6), "width": str(w),
"height": "26", "fill": SEG_COLOR[seg]})
x += w
ET.SubElement(svg, "text", {"x": str(x + 8), "y": str(y + 24), "font-size": "12",
"fill": "#111827"}).text = f'{r["totalMs"]}ms'
os.makedirs(os.path.dirname(path), exist_ok=True)
ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)
return len(ET.tostring(svg))
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
args = ap.parse_args()
cfg = load(args.config)
cost, probe_kb = cfg["cost"], cfg["probeKB"]
probe_rows = []
for tier in cfg["networkTiers"]:
seg = segments(probe_kb, tier["kbPerSec"], cost)
probe_rows.append({"tier": tier["id"], "label": tier["label"], "kbPerSec": tier["kbPerSec"],
"sizeKB": probe_kb, "seg": seg, "totalMs": cost["bootBaseMs"] + sum(seg.values())})
sw = cfg["sweep"]
sweep_rows = [{"sizeKB": s, "tier": t["id"], "totalMs": total_ms(s, t["kbPerSec"], cost)}
for s in range(sw["startKB"], sw["stopKB"] + 1, sw["stepKB"])
for t in cfg["networkTiers"]]
wifi = probe_rows[0]
spread = max(r["totalMs"] for r in probe_rows) - min(r["totalMs"] for r in probe_rows)
download_share = (wifi["seg"]["download"] * 1000) // wifi["totalMs"]
parse_compile_share = ((wifi["seg"]["parse"] + wifi["seg"]["compile"]) * 1000) // wifi["totalMs"]
os.makedirs(args.out, exist_ok=True)
svg_bytes = draw_svg(os.path.join(args.out, "e14_firstload.svg"), probe_rows, cost)
report = {
"version": cfg["version"], "game": cfg["game"], "seed": cfg["seed"],
"model": "cold-start decomposition analytic model (NOT a device measurement)",
"probeKB": probe_kb, "bootBaseMs": cost["bootBaseMs"], "cost": cost,
"networkTiers": cfg["networkTiers"], "probe": probe_rows,
"spreadMsAcrossTiers": spread, "wifiDownloadSharePerMille": download_share,
"wifiParseCompileSharePerMille": parse_compile_share, "budget": cfg["budget"],
"overFirstScreenBudgetTiers": [r["tier"] for r in probe_rows if r["totalMs"] > cfg["budget"]["firstScreenSoftMs"]],
"sweepPoints": len(sweep_rows),
"csv": os.path.join(args.out, "e14_firstload_sweep.csv"),
"svg": os.path.join(args.out, "e14_firstload.svg"), "svgBytes": svg_bytes,
}
with open(os.path.join(args.out, "e14_firstload.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
with open(os.path.join(args.out, "e14_firstload_sweep.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["sizeKB", "tier", "totalMs"])
for r in sweep_rows:
w.writerow([r["sizeKB"], r["tier"], r["totalMs"]])
for r in probe_rows:
print(f'{r["tier"]:>5} {r["kbPerSec"]:>5}KB/s download={r["seg"]["download"]}ms parse={r["seg"]["parse"]}ms '
f'compile={r["seg"]["compile"]}ms firstFrame={r["seg"]["firstFrame"]}ms total={r["totalMs"]}ms')
print(json.dumps({"probeKB": probe_kb, "spreadMsAcrossTiers": spread,
"wifiDownloadSharePerMille": download_share,
"wifiParseCompileSharePerMille": parse_compile_share,
"overFirstScreenBudgetTiers": report["overFirstScreenBudgetTiers"],
"sweepPoints": len(sweep_rows), "svgBytes": svg_bytes}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
本机实跑输出如下(同机相对量级,绝对数值不可移植):
$ /opt/homebrew/bin/python3 sim/e14_firstload.py --config config/ --out out/
wifi 1600KB/s download=1280ms parse=368ms compile=532ms firstFrame=121ms total=2421ms
lte 420KB/s download=4876ms parse=368ms compile=532ms firstFrame=121ms total=6017ms
weak 120KB/s download=17066ms parse=368ms compile=532ms firstFrame=121ms total=18207ms
{"probeKB": 2048, "spreadMsAcrossTiers": 15786, "wifiDownloadSharePerMille": 528, "wifiParseCompileSharePerMille": 371, "overFirstScreenBudgetTiers": ["lte", "weak"], "sweepPoints": 36, "svgBytes": 1554}
Wi-Fi 档下,下载占 528‰、解析加编译占 371‰——两者量级相当,所以 Wi-Fi 用户的启动优化空间在「解析编译」这一侧;而弱网档下下载独占,优化解析对总时长几乎没有帮助。overFirstScreenBudgetTiers 是 ["lte","weak"]:4G 与弱网档的首屏时间都越过了 4000ms 的软预算,只有 Wi-Fi 在预算内。
B 层:包体 → 首屏时间在不同网络下的分布
上面看的是单个探针包体。真实决策要问的是「包体涨到多少、哪一档网络会先撑不住」。下面这段是 B 层(基准层)脚本 sim/e14_firstload_bench.py:复用同一套分解模型,扫整条包体区间,对每个档位算三档网络的首屏时间分布(min / max / 档位间极差),越预算的档位给软告警。B 层只打 WARN,恒返回 0。
#!/usr/bin/env python3
# file: sim/e14_firstload_bench.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/e14_firstload_bench.py --config config/ --out out/
"""B 基准:扫「包体 → 首屏时间」在不同网络档位下的分布(软告警,不硬失败)。
复用 sim/e14_firstload.py 的分解模型(同一套系数假设),对每个包体档位算三档网络的首屏时间,
报告 min / max / 档位间极差(spread)。极差越大说明同一包体在不同网络下的体验越分裂;
首屏时间越过 firstScreenSoftMs 的档位给软告警——只是模型内的提示,不是真机结论。
产出:out/e14_firstload_bench.json + out/e14_firstload_bench.csv
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from e14_firstload import load, total_ms # noqa: E402
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
args = ap.parse_args()
cfg = load(args.config)
cost, tiers = cfg["cost"], cfg["networkTiers"]
soft, sw = cfg["budget"]["firstScreenSoftMs"], cfg["sweep"]
rows, warns = [], []
for size in range(sw["startKB"], sw["stopKB"] + 1, sw["stepKB"]):
per = {t["id"]: total_ms(size, t["kbPerSec"], cost) for t in tiers}
lo, hi = min(per.values()), max(per.values())
warn = hi > soft
rows.append({"sizeKB": size, "minMs": lo, "maxMs": hi, "spreadMs": hi - lo, "perTier": per, "warn": warn})
if warn:
warns.append(f"包体 {size}KB → 最慢档首屏 {hi}ms 越过软预算 {soft}ms")
os.makedirs(args.out, exist_ok=True)
report = {"source": "sim/e14_firstload_bench.py", "tier": "B",
"model": "cold-start decomposition analytic model (NOT a device measurement)",
"firstScreenSoftMs": soft, "tiers": [t["id"] for t in tiers], "rows": rows,
"warnings": warns, "ok": not warns, "maxSpreadMs": max(r["spreadMs"] for r in rows),
"csv": os.path.join(args.out, "e14_firstload_bench.csv")}
with open(os.path.join(args.out, "e14_firstload_bench.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
with open(os.path.join(args.out, "e14_firstload_bench.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["sizeKB"] + [f"{t['id']}Ms" for t in tiers] + ["minMs", "maxMs", "spreadMs", "warn"])
for r in rows:
w.writerow([r["sizeKB"]] + [r["perTier"][t["id"]] for t in tiers]
+ [r["minMs"], r["maxMs"], r["spreadMs"], str(r["warn"]).lower()])
for r in rows:
print(f'size={r["sizeKB"]:>5}KB min={r["minMs"]:>5}ms max={r["maxMs"]:>5}ms spread={r["spreadMs"]:>5}ms '
f'{"WARN" if r["warn"] else ""}')
for wmsg in warns:
print(f" WARN {wmsg}")
print(f"== 首屏时间分布基准:{'OK' if report['ok'] else 'WARN'}(软告警不硬失败)")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑输出(节选,spread 是同一包体在三档网络下的极差):
$ /opt/homebrew/bin/python3 sim/e14_firstload_bench.py --config config/ --out out/
size= 512KB min= 740ms max= 4686ms spread= 3946ms WARN
size= 2048KB min= 2421ms max=18207ms spread=15786ms WARN
size= 4096KB min= 4663ms max=36236ms spread=31573ms WARN
size= 6144KB min= 6906ms max=54266ms spread=47360ms WARN
WARN 包体 512KB → 最慢档首屏 4686ms 越过软预算 4000ms
== 首屏时间分布基准:WARN(软告警不硬失败)
这些数字怎么读
三点值得记住。第一,极差本身就是结论:512KB 的小包体在三档网络下的首屏时间极差就有 3946ms,6144KB 时拉到 47360ms——「首屏时间」这个指标离开网络档位就没有意义,同一款游戏对 Wi-Fi 用户「够快」、对弱网用户「慢到会退出」,是完全正常的。第二,软预算不是硬门槛:firstScreenSoftMs=4000ms 只是团队内部的一条提示线,它越小,越多的包体档位会被标 WARN,调这条线不该改模型,只该改你对「多慢算慢」的判断。第三,这里的每一档都是模型内的:真实设备上,下载段还受并发连接数、CDN 命中、首包分片策略影响,解析 / 编译段受设备 CPU 与 JIT 策略影响。本节的数字只在同一套假设下可比,用来回答「该砍哪一段」这类相对问题,而不是预测真机的绝对毫秒数。
小结与下一步
这一节把冷启动拆成下载 / 解析 / 编译 / 首帧四段:下载段吃网络、其余三段吃包体,因此「该优化哪一段」取决于目标用户的网络档位。模型是解析的、显式标注了不可移植,但它足够回答「砍 1MB 包体在弱网下省多少」这类相对问题。不过首屏时间只是第一道坎——玩家进了游戏之后,真正持续影响体验的是帧率与卡顿:平均帧耗时看着还行,长帧却在悄悄毁掉手感。下一节就专门定位卡顿。
阅读导航:上一节:渐进教学与容错 · 下一节:帧率与卡顿定位 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。