站内已有一整卷 《Cocos 游戏开发·高级》 ——那 34 篇讲的是 Cocos Creator 引擎内部与定制:怎么改渲染管线、怎么接原生、怎么做引擎级优化。本章讲的是与引擎无关的性能分析方法:首包体积 → 启动耗时的分解模型、卡顿的归因路径、内存峰值与包体的取舍——方法可迁移到任何引擎。那卷教你「在 Cocos 里用 profiler」,本章教你「不看 profiler 也能先把卡顿拆成可算的量」。
上一节把冷启动拆成四段,回答了「第一次打开为什么慢」。这一节处理进游戏之后的持续问题:帧率与卡顿。这里有个反直觉的坑——用「平均帧耗时」衡量流畅度,几乎一定会漏掉真正让玩家烦躁的东西。
卡顿不是平均帧耗时的问题
设想一次 60fps 的运行:绝大多数帧都很快,但每隔一会儿冒出一帧明显拖长。如果只看平均值,那几帧长帧会被大量稳定帧「稀释」掉,平均帧耗时看起来几乎没变。但玩家的主观感受完全由那几帧决定——一次 48ms 的长帧,在 60fps 的节奏里相当于卡了约 3 帧,眼睛能清楚看到顿挫。
所以定位卡顿的第一步,是把「帧耗时」从单个数拆成分布:稳定帧多快、长帧多长、长帧出现的频率是多少。频率才是关键变量,平均值不是。
两种帧与三个量
本节模型把一次运行简化成两种帧:绝大多数是 stableFrameMs 的稳定帧,少数是 jankFrameMs 的长帧,长帧占比由 jankPerMille(千分比)给定。由此算出三个量(全整数):
| 量 | 公式 | 含义 |
|---|---|---|
| 长帧数 longFrames | runFrames × jankPerMille ÷ 1000 | 整场运行里长帧有几帧 |
| 平均帧耗时 avgFrameMs | (stable×(1000−j) + jank×j) ÷ 1000 | 被稀释后的均值 |
| 感知评分 score | baseScore − jankPerMille × penaltyPerMille | 直接由长帧频率决定 |
再加一个 effectiveFps = 1000 ÷ avgFrameMs 的整数倒数,作为「名义帧率」。
⚠️ 老规矩:这是解析模型,不是真机实测。稳定帧 / 长帧耗时与长帧占比都是模型假设,用来比较「平均值」与「长帧频率」谁更能反映卡顿,绝对数值不可移植。真实卡顿还受 GC、纹理上传、网络回调的影响,本模型把它们统统折进「长帧比例」这一个参数里。
平均值会骗人
拿三个档位跑一遍:优化后(长帧 6‰)、中等(40‰)、优化前(120‰):
| 档位 | 长帧占比 | 长帧数 | 平均帧耗时 | 名义帧率 | 感知评分 |
|---|---|---|---|---|---|
| 优化后 | 6‰ | 108 | 12ms | 83 | 964 |
| 中等 | 40‰ | 720 | 13ms | 76 | 760 |
| 优化前 | 120‰ | 2160 | 16ms | 62 | 280 |
从「优化后」到「优化前」,平均帧耗时只从 12ms 涨到 16ms(涨了 333‰),看着还在可接受范围;但感知评分从 964 掉到 280(跌了 709‰),也就是玩家感受层面已经是两个游戏。这就是「平均值陷阱」:均值被大量稳定帧压住,长帧频率却在快速上涨。优化卡顿时该盯的是长帧频率,不是平均帧耗时。
先看配置
下面这段是 D 层配置 config/e14_jank.json:帧预算、运行帧数、两种帧的耗时、三个档位的长帧占比,以及感知评分的参数。
{
"version": 1,
"game": "merge-island",
"seed": 20261012,
"note": "帧耗时分布的解析模型(不是真机实测)。稳定帧/长帧耗时与长帧比例都是模型假设,用来比较「平均帧耗时」与「长帧频率」哪个更能反映卡顿感知,绝对数值不可移植。全整数毫秒/千分比,禁浮点。",
"fps": 60,
"frameBudgetMs": 16,
"runFrames": 18000,
"stableFrameMs": 12,
"jankFrameMs": 48,
"profiles": [
{"id": "tuned", "label": "优化后", "jankPerMille": 6},
{"id": "mid", "label": "中等", "jankPerMille": 40},
{"id": "raw", "label": "优化前", "jankPerMille": 120}
],
"perception": {"baseScore": 1000, "penaltyPerMille": 6, "minScore": 0, "softScoreFloor": 600},
"benchScanPerMille": [2, 5, 10, 20, 40, 80, 160]
}
penaltyPerMille=6 的含义是「每 1‰ 的长帧扣 6 分」——这是一条主观标定,不同品类的容忍度不同:休闲合成游戏对顿挫的容忍度低,动作游戏更低。它不该被当成客观常数,而应作为可调参数。
S 层:帧耗时分布模型
下面这段是 S 层脚本 sim/e14_jank.py:读配置,对三个档位算长帧数、平均帧耗时、名义帧率与感知评分,并对比「均值涨幅」与「评分跌幅」,产出 JSON + CSV + SVG。
#!/usr/bin/env python3
# file: sim/e14_jank.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/e14_jank.py --config config/ --out out/
"""帧耗时分布模型:稳定帧 vs 长帧 → 卡顿感知评分。
⚠️ 这是**解析模型,不是真机实测**。它把一次运行拆成两种帧:绝大多数是 stableFrameMs 的稳定帧,
少数是 jankFrameMs 的长帧,长帧占比由 jankPerMille(千分比)给定。核心结论是:
- 平均帧耗时几乎被长帧「稀释」掉(长帧占比小,均值变化不大);
- 但长帧频率直接决定卡顿感知:score = baseScore − jankPerMille × penaltyPerMille。
各段系数都是**模型假设**,用来比较「平均值」与「长帧频率」谁更能反映卡顿,绝对数值不可移植。
产出:out/e14_jank.json + out/e14_jank_profiles.csv + out/e14_jank.svg
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET
def load(cfg_dir):
with open(os.path.join(cfg_dir, "e14_jank.json"), encoding="utf-8") as f:
return json.load(f)
def profile_stats(jank_per_mille, cfg):
"""一个长帧占比档位的整数统计量。全整数,无浮点。"""
j = jank_per_mille
run = cfg["runFrames"]
stable, jank = cfg["stableFrameMs"], cfg["jankFrameMs"]
long_frames = run * j // 1000
avg_ms = (stable * (1000 - j) + jank * j) // 1000
eff_fps = 1000 // avg_ms if avg_ms else 0
p = cfg["perception"]
score = max(p["minScore"], p["baseScore"] - j * p["penaltyPerMille"])
return {"jankPerMille": j, "longFrames": long_frames,
"longPerMinute": cfg["fps"] * 60 * j // 1000,
"avgFrameMs": avg_ms, "effectiveFps": eff_fps,
"worstFrameMs": jank, "score": score}
def draw_svg(path, rows):
"""纯 Python SVG:每个档位一根横条(感知评分 0–1000),右侧标长帧频率与平均帧耗时。"""
W, PAD_L, PAD_R, ROW = 780, 170, 260, 56
H = 92 + ROW * len(rows)
bar_max = W - PAD_L - PAD_R
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 = "长帧频率 → 感知卡顿评分(解析模型,非真机实测)"
ET.SubElement(svg, "text", {"x": "16", "y": "50", "font-size": "11", "fill": "#9ca3af"}
).text = "评分 0–1000;长帧 48ms(约 3 帧),稳定帧 12ms"
for i, r in enumerate(rows):
y = 72 + i * ROW
ET.SubElement(svg, "text", {"x": "16", "y": str(y + 24), "font-size": "12", "fill": "#374151"}
).text = f'{r["label"]}'
w = max(3, (bar_max * r["score"]) // 1000)
color = "#16a34a" if r["score"] >= 800 else ("#f59e0b" if r["score"] >= 500 else "#dc2626")
ET.SubElement(svg, "rect", {"x": str(PAD_L), "y": str(y + 8), "width": str(w), "height": "26",
"rx": "4", "fill": color})
ET.SubElement(svg, "text", {"x": str(PAD_L + 6), "y": str(y + 26), "font-size": "12",
"fill": "#ffffff"}).text = str(r["score"])
ET.SubElement(svg, "text", {"x": str(W - PAD_R + 8), "y": str(y + 26), "font-size": "12",
"fill": "#6b7280"}
).text = f'{r["jankPerMille"]}‰ · 均{r["avgFrameMs"]}ms · {r["longPerMinute"]}长帧/分'
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)
rows = []
for prof in cfg["profiles"]:
st = profile_stats(prof["jankPerMille"], cfg)
st["id"], st["label"] = prof["id"], prof["label"]
rows.append(st)
# 「平均值陷阱」:从最优化到最差档,平均帧耗时涨幅 vs 感知评分跌幅
first, last = rows[0], rows[-1]
avg_rise_per_mille = ((last["avgFrameMs"] - first["avgFrameMs"]) * 1000) // max(1, first["avgFrameMs"])
score_drop_per_mille = ((first["score"] - last["score"]) * 1000) // max(1, first["score"])
os.makedirs(args.out, exist_ok=True)
svg_bytes = draw_svg(os.path.join(args.out, "e14_jank.svg"), rows)
report = {
"version": cfg["version"], "game": cfg["game"], "seed": cfg["seed"],
"model": "frame-time distribution analytic model (NOT a device measurement)",
"fps": cfg["fps"], "frameBudgetMs": cfg["frameBudgetMs"], "runFrames": cfg["runFrames"],
"stableFrameMs": cfg["stableFrameMs"], "jankFrameMs": cfg["jankFrameMs"],
"perception": cfg["perception"], "profiles": rows,
"avgFrameMsRisePerMille": avg_rise_per_mille,
"perceptionScoreDropPerMille": score_drop_per_mille,
"csv": os.path.join(args.out, "e14_jank_profiles.csv"),
"svg": os.path.join(args.out, "e14_jank.svg"), "svgBytes": svg_bytes,
}
with open(os.path.join(args.out, "e14_jank.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
with open(os.path.join(args.out, "e14_jank_profiles.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["id", "label", "jankPerMille", "longFrames", "longPerMinute",
"avgFrameMs", "effectiveFps", "worstFrameMs", "score"])
for r in rows:
w.writerow([r["id"], r["label"], r["jankPerMille"], r["longFrames"], r["longPerMinute"],
r["avgFrameMs"], r["effectiveFps"], r["worstFrameMs"], r["score"]])
for r in rows:
print(f'{r["id"]:>6} {r["label"]:>5} jank={r["jankPerMille"]:>3}‰ long={r["longFrames"]:>5} '
f'long/min={r["longPerMinute"]:>3} avg={r["avgFrameMs"]}ms effFps={r["effectiveFps"]} '
f'score={r["score"]}')
print(json.dumps({"avgFrameMsRisePerMille": avg_rise_per_mille,
"perceptionScoreDropPerMille": score_drop_per_mille,
"tunedScore": first["score"], "rawScore": last["score"],
"tunedAvgMs": first["avgFrameMs"], "rawAvgMs": last["avgFrameMs"],
"svgBytes": svg_bytes}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
本机实跑输出如下(同机相对量级,绝对数值不可移植):
$ /opt/homebrew/bin/python3 sim/e14_jank.py --config config/ --out out/
tuned 优化后 jank= 6‰ long= 108 long/min= 21 avg=12ms effFps=83 score=964
mid 中等 jank= 40‰ long= 720 long/min=144 avg=13ms effFps=76 score=760
raw 优化前 jank=120‰ long= 2160 long/min=432 avg=16ms effFps=62 score=280
{"avgFrameMsRisePerMille": 333, "perceptionScoreDropPerMille": 709, "tunedScore": 964, "rawScore": 280, "tunedAvgMs": 12, "rawAvgMs": 16, "svgBytes": 1570}
注意 avgFrameMsRisePerMille=333(均值只涨 333‰)与 perceptionScoreDropPerMille=709(评分跌 709‰)的对比——这正是「平均值陷阱」的量化:感知评分的相对跌幅(709‰)明显大于平均帧耗时的相对涨幅(333‰)。
B 层:长帧频率 → 感知评分
上面只看了三个离散档位。真实调优过程中,长帧比例是个连续变化的目标。下面这段是 B 层脚本 sim/e14_jank_bench.py:扫一串长帧占比,算每档的感知评分与长帧 / 分钟,评分低于 softScoreFloor 的档位给软告警。关键观察是:平均帧耗时几乎不动,评分却单调下滑。
#!/usr/bin/env python3
# file: sim/e14_jank_bench.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/e14_jank_bench.py --config config/ --out out/
"""B 基准:扫「长帧出现频率 → 感知卡顿评分」(软告警,不硬失败)。
复用 sim/e14_jank.py 的模型假设,扫 benchScanPerMille 各档长帧占比,算每档的感知评分、
长帧/分钟与平均帧耗时。评分低于 softScoreFloor 的档位给软告警——只是模型内的提示,不是真机结论。
关键观察:平均帧耗时在这些档位间几乎不动,评分却单调下滑,说明「平均值」会掩盖卡顿。
产出:out/e14_jank_bench.json + out/e14_jank_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_jank import load, profile_stats # 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)
floor = cfg["perception"]["softScoreFloor"]
rows, warns = [], []
for j in cfg["benchScanPerMille"]:
st = profile_stats(j, cfg)
warn = st["score"] < floor
st["warn"] = warn
rows.append(st)
if warn:
warns.append(f"长帧 {j}‰ → 感知评分 {st['score']} 低于软底线 {floor}")
avg_lo = min(r["avgFrameMs"] for r in rows)
avg_hi = max(r["avgFrameMs"] for r in rows)
os.makedirs(args.out, exist_ok=True)
report = {"source": "sim/e14_jank_bench.py", "tier": "B",
"model": "frame-time distribution analytic model (NOT a device measurement)",
"softScoreFloor": floor, "rows": rows, "warnings": warns, "ok": not warns,
"avgFrameMsRange": {"min": avg_lo, "max": avg_hi, "spread": avg_hi - avg_lo},
"scoreRange": {"max": max(r["score"] for r in rows), "min": min(r["score"] for r in rows)},
"csv": os.path.join(args.out, "e14_jank_bench.csv")}
with open(os.path.join(args.out, "e14_jank_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_jank_bench.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["jankPerMille", "longPerMinute", "avgFrameMs", "effectiveFps", "score", "warn"])
for r in rows:
w.writerow([r["jankPerMille"], r["longPerMinute"], r["avgFrameMs"],
r["effectiveFps"], r["score"], str(r["warn"]).lower()])
for r in rows:
print(f'jank={r["jankPerMille"]:>3}‰ long/min={r["longPerMinute"]:>3} avg={r["avgFrameMs"]}ms '
f'score={r["score"]:>4} {"WARN" if r["warn"] else ""}')
for wmsg in warns:
print(f" WARN {wmsg}")
print(f'平均帧耗时区间 {avg_lo}–{avg_hi}ms(仅差 {avg_hi - avg_lo}ms),评分区间 '
f'{report["scoreRange"]["min"]}–{report["scoreRange"]["max"]}')
print(f"== 卡顿感知基准:{'OK' if report['ok'] else 'WARN'}(软告警不硬失败)")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑输出如下:
$ /opt/homebrew/bin/python3 sim/e14_jank_bench.py --config config/ --out out/
jank= 2‰ long/min= 7 avg=12ms score= 988
jank= 5‰ long/min= 18 avg=12ms score= 970
jank= 10‰ long/min= 36 avg=12ms score= 940
jank= 20‰ long/min= 72 avg=12ms score= 880
jank= 40‰ long/min=144 avg=13ms score= 760
jank= 80‰ long/min=288 avg=14ms score= 520 WARN
jank=160‰ long/min=576 avg=17ms score= 40 WARN
WARN 长帧 80‰ → 感知评分 520 低于软底线 600
WARN 长帧 160‰ → 感知评分 40 低于软底线 600
平均帧耗时区间 12–17ms(仅差 5ms),评分区间 40–988
== 卡顿感知基准:WARN(软告警不硬失败)
看这两行:平均帧耗时从 12ms 到 17ms,整整跨了 7 个长帧档位却只差 5ms;而评分从 988 掉到 40,几乎跌到底。如果拿平均帧耗时当验收指标,这七个档位「看起来都一样」;可玩家的感受是从「丝滑」到「根本玩不下去」。这就是为什么卡顿定位必须落到长帧频率,而不是任何形式的平均值。
这些数字怎么读
三点结论。第一,长帧频率是唯一值得盯的指标:它单调映射到感知评分,而平均帧耗时会把问题藏起来。第二,软底线是可调的价值判断:softScoreFloor=600 只是「跌到这里就该动手」的团队约定,改它不改模型,改的是团队对流畅度的要求。第三,别把它当成真机数据:真机的长帧来自 GC、纹理上传、平台回调,频率会随设备、场景、后台任务波动;本模型只回答「给定长帧频率,感知会差到什么程度」这类结构性问题。真要定位是哪一行代码造成的长帧,还是得在真机上做帧耗时打点——但那已经是「测量」,不是「建模」了。
小结与下一步
这一节把帧率问题从「平均值」扭到「长帧频率」:稳定帧与长帧的分布、长帧数、平均帧耗时、感知评分四个量里,只有长帧频率和它衍生的评分真正对应玩家感受。平均值被大量稳定帧稀释,会系统性低估卡顿。但性能优化还有第三条战线——内存:它既影响包体,又和「降包体」的优化互相打架。下一节就把内存峰值与包体优化放在同一张帕累托前沿上,看它们何时是朋友、何时是敌人。
阅读导航:上一节:首包与冷启动优化 · 下一节:内存与包体优化 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。