14.1 首包与冷启动优化

把冷启动耗时拆成下载 / 解析 / 编译 / 首帧四段可加的整数毫秒模型,比较同一包体在 Wi-Fi、4G、弱网三档下的分布,再用 B 基准扫包体看首屏时间在哪里越过预算——全程显式声明这是解析模型而非真机实测,用来判断该砍下载还是砍解析。

站内已有一整卷 《Cocos 游戏开发·高级》 ——那 34 篇讲的是 Cocos Creator 引擎内部与定制:渲染管线怎么改、组件系统怎么扩、原生平台怎么接。本章讲的是与引擎无关的性能分析方法:首包体积 → 启动耗时的分解模型、卡顿的归因路径、内存峰值与包体的取舍——方法可迁移到任何引擎。那卷教你「在 Cocos 里怎么改引擎」,本章教你「怎么把一次冷启动拆开,看清每一段各花了多久」。

进入第 14 章「性能优化」。前三节分别处理首包与冷启动、帧率与卡顿、内存与包体——这三件事常被笼统地叫「优化」,但它们的测量口径、敏感变量、取舍方向完全不同。这一节只谈第一个:从「点了图标」到「看见能操作的首屏」这段时间花在哪。第二、三节再分别处理卡顿与内存。

冷启动是一条可以拆开的时间线

玩家第一次打开小游戏,从点了图标到看见能操作的首屏,中间其实排了一串事情:先把首包从网络上拉下来,再解析代码,再编译(或 JIT 预热),最后初始化并渲染出第一帧。这四件事的耗时量级不同,对包体体积的敏感度也不同——有的几乎正比于体积,有的近乎固定。把它们混成一个「启动慢」的笼统印象,就没法决定该砍哪一块。

本节用的模型把冷启动拆成四段可加的整数毫秒:

段公式(整数毫秒)敏感于
下载 downloadsizeKB × 1000 ÷ kbPerSec包体、网络
解析 parsesizeKB × parseUsPerKB ÷ 1000包体
编译 compilesizeKB × compileUsPerKB ÷ 1000包体
首帧 firstFramefirstFrameBaseMs + sizeKB × firstFrameUsPerKB ÷ 1000包体(弱)

最后再叠一个固定启动项 bootBaseMs——引擎初始化、平台 SDK 握手这类与体积无关的开销。

⚠️ 必须先说清楚:这是解析模型,不是真机实测。各段系数是模型假设,它用来比较「哪一段是大头」与「不同网络档位差多少」的相对量级;绝对数值不可移植到任何真机。真实项目的冷启动还受冷热缓存、并发下载、解码线程数影响,这些本模型一概不建模。

为什么下载段要单独拎出来

四段里只有 download 是直接吃网络的:sizeKB × 1000 ÷ kbPerSec,每多 1KB 就多 1000 ÷ kbPerSec 毫秒。而 parse / compile / firstFrame 三段只跟包体大小有关,跟网络档位无关。这个区分很关键——它意味着「该优化下载还是优化解析」的答案,取决于你的目标用户在什么网络上:网络越快,下载段占比越低,解析编译段就越显眼;网络越慢,下载段会迅速吃掉整个预算,此时唯一有效的动作是砍包体,优化解析代码几乎没用。

三档网络:同一包体差出量级

拿《合成小岛》的 2048KB 探针包体跑一遍三档网络,四段分解如下(整数毫秒):

网络档位带宽下载解析编译首帧合计
Wi-Fi1600KB/s12803685321212421
4G420KB/s48763685321216017
弱网 3G120KB/s1706636853212118207

注意解析 / 编译 / 首帧三列三档完全一样——它们不吃网络。差的全部来自下载段:从 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 包体在弱网下省多少」这类相对问题。不过首屏时间只是第一道坎——玩家进了游戏之后,真正持续影响体验的是帧率与卡顿:平均帧耗时看着还行,长帧却在悄悄毁掉手感。下一节就专门定位卡顿。

阅读导航:上一节:渐进教学与容错 · 下一节:帧率与卡顿定位 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

  1. 附录 F:全书公式、参数与数据集索引
  2. 9.3 同意管理与数据出境
  3. 9.2 COPPA 与儿童向游戏的限制