本节目标:把「首包多大」换算成「玩家要等多久」,再把「等多久」换算成「有多少人愿意等」,最后反过来定出首包体积的上限。
站内那套 46 篇的小程序专题 讲的是小程序工程口径——代码包怎么拆、请求怎么放行、审核怎么过。本节不重复那些工程做法,讲的是小游戏包体与冷启动约束如何反向裁剪玩法与架构:上一节算出了主包 920KB,这一节回答的是——920KB 在用户的网络条件下要等几秒,等这几秒会掉多少人。
上一节把主包压到了限额内。但「不超限」只是及格线,不是目标线。真正决定首屏体验的是加载耗时,而加载耗时不是一个固定值——同一份包,在 Wi-Fi 下和在弱网下是两个世界。
4.2 首包加载与冷启动约束
4.2.1 加载耗时由三件事决定
玩家点开小游戏之后、能操作之前,经历的是三段串联的时间:
| 阶段 | 由什么决定 | 能不能优化 |
|---|---|---|
| 网络传输 | 首包体积 ÷ 实际带宽 | 靠减体积、靠 CDN 就近 |
| 冷启动初始化 | 固定开销 + 与体积成正比的解析/编译开销 | 靠减体积、靠懒解析 |
| 首屏渲染 | 资源解码 + 首帧绘制 | 靠图集与分辨率(卷二的话题) |
三段串联,总耗时是三段之和。容易忽略的一点:初始化开销本身也随体积增长——体积大不只是下载久,解析准备也更久,所以体积对总耗时的影响比「体积 ÷ 带宽」更陡。本节建模前两段,第三段归给卷二第 3 章。
4.2.2 模型参数(D 层配置)
下面这份是 D 层配置(config/ch04_firstload.json)的参数摘要。带宽档位是可配置参数,用来做相对比较,不代表任何真实网络环境的实测值。
| 参数 | 取值 | 含义 |
|---|---|---|
bandwidthTiers | 1800 / 700 / 260 KB/s | Wi-Fi / 4G 良好 / 4G 弱网三个档位 |
init | baseSec 1、perKBPerMille 1 | 冷启动固定开销 + 与体积成正比的整数千分比项 |
entryModel | basePerMille 950、decayPerSecPerMille 18、minPerMille 320 | 带下限的线性进入率模型 |
sweep / probeKB | 512–6144KB 步长 512KB;探针 2048KB | 曲线扫描区间与探针体积 |
进入率用千分比表示,模型是「基准值 − 每多等一秒扣掉的千分比」,并带一个下限。它是简化模型,不是真实漏斗:真实的进入率只能从平台后台观测,需真实业务环境,命令行不可复现(X)。本节用它做的是相对比较——同一套参数下,改体积或改带宽,进入率怎么动。
4.2.3 体积 → 耗时 → 进入率(S 层)
下面这段是 S 层仿真脚本。下载耗时用整数递推实现:每一轮扣掉一个「每秒可下载 KB」,扣完为止,轮数就是秒数——这样得到的天然是向上取整的整数秒,不需要任何浮点。冷启动开销再叠一个固定项加一个与体积成正比的整数千分比项。脚本对三条带宽档位各扫一条曲线,产出 JSON、CSV 与一张纯 Python 画的 SVG。
#!/usr/bin/env python3
# file: sim/ch04_firstload.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ch04_firstload.py --config config/ --out out/
"""首包体积 → 下载耗时 → 进入率的简化模型。
下载耗时用整数递推:每轮扣掉一个「每秒可下载 KB」,扣完的轮数即秒数。冷启动再叠一个
与体积成正比的整数千分比初始化开销。进入率是带下限的线性衰减模型,用千分比表示。
三条带宽档位各画一条曲线。产出 JSON + CSV + SVG。
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import sys
import xml.etree.ElementTree as ET
COLORS = ["#2563eb", "#059669", "#dc2626"]
def load(cfg_dir, name):
with open(os.path.join(cfg_dir, f"{name}.json"), encoding="utf-8") as f:
return json.load(f)
def download_sec(size_kb, kb_per_sec):
"""整数递推:每轮扣掉 kb_per_sec,扣完的轮数就是下载秒数。"""
remaining, sec = size_kb, 0
while remaining > 0:
remaining -= kb_per_sec
sec += 1
return sec
def cold_start_sec(size_kb, init):
"""初始化开销 = 固定项 + 与体积成正比的整数千分比项。"""
return init["baseSec"] + (size_kb * init["perKBPerMille"]) // 1000
def load_sec(size_kb, kb_per_sec, init):
return download_sec(size_kb, kb_per_sec) + cold_start_sec(size_kb, init)
def entry_per_mille(sec, model):
return max(model["minPerMille"], model["basePerMille"] - model["decayPerSecPerMille"] * sec)
def curve(cfg, tier):
fl, sw = cfg["firstLoad"], cfg["firstLoad"]["sweep"]
xs = list(range(sw["startKB"], sw["stopKB"] + 1, sw["stepKB"]))
ys = [entry_per_mille(load_sec(x, tier["kbPerSec"], fl["init"]), fl["entryModel"]) for x in xs]
return xs, ys
def write_svg(path, series, title):
W, H, PAD, YMAX = 680, 380, 56, 1000
x_max = max(max(xs) for _, xs, _ in series)
sx = lambda x: PAD + (W - 2 * PAD) * x // max(1, x_max)
sy = lambda y: H - PAD - (H - 2 * PAD) * y // YMAX
p = [f'<svg xmlns="http://www.w3.org/2000/svg" width="{W}" height="{H}" viewBox="0 0 {W} {H}">',
f'<rect width="{W}" height="{H}" fill="#ffffff"/>',
f'<text x="{PAD}" y="{PAD - 24}" font-size="14" fill="#111827">{title}</text>']
for i in range(6):
y = PAD + (H - 2 * PAD) * i / 5
p.append(f'<line x1="{PAD}" y1="{y:.1f}" x2="{W - PAD}" y2="{y:.1f}" stroke="#e5e7eb"/>')
p.append(f'<text x="{PAD - 8}" y="{y + 4:.1f}" font-size="11" text-anchor="end" '
f'fill="#6b7280">{YMAX - YMAX * i // 5}</text>')
for i in range(7):
p.append(f'<text x="{PAD + (W - 2 * PAD) * i // 6:.1f}" y="{H - PAD + 18}" font-size="11" '
f'text-anchor="middle" fill="#6b7280">{x_max * i // 6}</text>')
p.append(f'<text x="{W // 2}" y="{H - 12}" font-size="12" text-anchor="middle" fill="#374151">'
f'首包体积 (KB)</text>')
p.append(f'<line x1="{PAD}" y1="{H - PAD}" x2="{W - PAD}" y2="{H - PAD}" stroke="#9ca3af"/>')
p.append(f'<line x1="{PAD}" y1="{PAD}" x2="{PAD}" y2="{H - PAD}" stroke="#9ca3af"/>')
for i, (label, xs, ys) in enumerate(series):
pts = " ".join(f"{sx(x):.2f},{sy(y):.2f}" for x, y in zip(xs, ys))
p.append(f'<polyline points="{pts}" fill="none" stroke="{COLORS[i % 3]}" stroke-width="2"/>')
p.append(f'<text x="{W - PAD - 96}" y="{PAD + 16 + i * 18}" font-size="12" '
f'fill="{COLORS[i % 3]}">{label}</text>')
p.append("</svg>")
svg = "\n".join(p)
with open(path, "w", encoding="utf-8") as f:
f.write(svg)
ET.fromstring(svg)
return len(svg.encode())
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
args = ap.parse_args()
cfg, fl = load(args.config, "ch04_firstload"), None
fl = cfg["firstLoad"]
os.makedirs(args.out, exist_ok=True)
series, probe = [], []
for tier in fl["bandwidthTiers"]:
xs, ys = curve(cfg, tier)
series.append((tier["label"], xs, ys))
sec = load_sec(fl["probeKB"], tier["kbPerSec"], fl["init"])
probe.append({"tier": tier["id"], "label": tier["label"], "kbPerSec": tier["kbPerSec"],
"sizeKB": fl["probeKB"], "downloadSec": download_sec(fl["probeKB"], tier["kbPerSec"]),
"coldStartSec": cold_start_sec(fl["probeKB"], fl["init"]), "loadSec": sec,
"entryPerMille": entry_per_mille(sec, fl["entryModel"])})
with open(os.path.join(args.out, "ch04_firstload_curve.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["tier", "sizeKB", "loadSec", "entryPerMille"])
for (_, xs, ys), tier in zip(series, fl["bandwidthTiers"]):
for x, y in zip(xs, ys):
w.writerow([tier["id"], x, load_sec(x, tier["kbPerSec"], fl["init"]), y])
svg_bytes = write_svg(os.path.join(args.out, "ch04_firstload.svg"), series,
"《合成小岛》首包体积 vs 进入率(简化模型,千分比)")
report = {"source": "sim/ch04_firstload.py", "observation": cfg["observation"],
"probeKB": fl["probeKB"], "entryModel": fl["entryModel"], "probe": probe,
"spreadPerMille": max(p["entryPerMille"] for p in probe) - min(p["entryPerMille"] for p in probe),
"csv": os.path.join(args.out, "ch04_firstload_curve.csv"),
"svg": os.path.join(args.out, "ch04_firstload.svg"), "svgBytes": svg_bytes}
with open(os.path.join(args.out, "ch04_firstload.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
for p in probe:
print(f"{p['tier']:>7} {p['kbPerSec']:>5}KB/s size={p['sizeKB']}KB download={p['downloadSec']}s "
f"coldstart={p['coldStartSec']}s load={p['loadSec']}s entry={p['entryPerMille']}permille")
print(f"spread(probe)={report['spreadPerMille']}permille svgBytes={svg_bytes}")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑输出(探针体积 2048KB):
wifi 1800KB/s size=2048KB download=2s coldstart=3s load=5s entry=860permille
good4g 700KB/s size=2048KB download=3s coldstart=3s load=6s entry=842permille
weak4g 260KB/s size=2048KB download=8s coldstart=3s load=11s entry=752permille
spread(probe)=108permille svgBytes=2859
读这份输出:
- 下载耗时是向上取整的整数秒。2048KB 在 Wi-Fi 档位(1800KB/s)要 2 秒,在弱网档位(260KB/s)要 8 秒——体积一样,耗时差了 6 秒。
- 冷启动开销在三个档位下都是 3 秒,因为它只跟体积有关,跟带宽无关。体积越大这一项越贵,这正是「初始化开销也随体积增长」的具体体现。
- 总耗时从 5 秒到 11 秒,进入率从 860‰ 掉到 752‰,跨度 108‰。注意这个跨度是同机相对量级,绝对数值不可移植——它来自本节这套简化模型的参数,不是任何真实产品的观测结果。
SVG 曲线(out/ch04_firstload.svg)把三条档位画在同一张图上:横轴是首包体积、纵轴是进入率千分比。三条线在低体积处靠得近、在高体积处张得开——因为带宽越窄,「每多 1KB 要多等的时间」越长,体积惩罚被放大。这张图就是「首包体积上限」的来源。
4.2.4 反过来问:允许的首包上限是多少(B 层)
上面是「给定体积,算进入率」。更有用的问题是反过来的:要让进入率不低于某个目标,首包最大能有多大。下面这段是 B 层基准(软告警不硬失败):给定目标进入率,反推各带宽档位下的首包上限;再把带宽上下浮动 ±30%(整数百分点),看探针体积下进入率的摆动有多大,摆动过大就打印 WARN。
#!/usr/bin/env python3
# file: sim/ch04_firstload_bench.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/ch04_firstload_bench.py --config config/ --out out/
"""首包加载的带宽敏感性基准(B 层,软告警不硬失败)。
1) 给定目标进入率,反推各带宽档位下首包允许的最大体积——「平台约束反向决定设计」的
可计算形式;2) 把带宽上下浮动若干整数百分点,看探针体积下进入率的摆动,过大则 WARN。
产出 out/ch04_firstload_bench.json。
"""
from __future__ import annotations
import argparse
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from ch04_firstload import entry_per_mille, load, load_sec # noqa: E402
def max_size_for_target(tier, fl, target, size_max):
"""进入率随体积单调不增,线性扫描到第一个不达标为止。"""
best = 0
for size in range(1, size_max + 1):
if entry_per_mille(load_sec(size, tier["kbPerSec"], fl["init"]), fl["entryModel"]) < target:
break
best = size
return best
def sensitivity(cfg, probe_kb, pcts):
fl, rows = cfg["firstLoad"], []
for tier in fl["bandwidthTiers"]:
pts = []
for pct in pcts:
kbps = max(1, tier["kbPerSec"] * (100 + pct) // 100)
sec = load_sec(probe_kb, kbps, fl["init"])
pts.append({"pct": pct, "kbPerSec": kbps, "loadSec": sec,
"entryPerMille": entry_per_mille(sec, fl["entryModel"])})
vals = [p["entryPerMille"] for p in pts]
rows.append({"tier": tier["id"], "label": tier["label"], "baseKbPerSec": tier["kbPerSec"],
"min": min(vals), "max": max(vals), "swingPerMille": max(vals) - min(vals), "points": pts})
return rows
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
ap.add_argument("--target-per-mille", type=int, default=800)
ap.add_argument("--size-max", type=int, default=12288)
ap.add_argument("--step-pct", type=int, default=10)
ap.add_argument("--warn-swing", type=int, default=80)
args = ap.parse_args()
cfg = load(args.config, "ch04_firstload")
fl = cfg["firstLoad"]
limits = [{"tier": t["id"], "label": t["label"], "kbPerSec": t["kbPerSec"],
"maxFirstPackageKB": max_size_for_target(t, fl, args.target_per_mille, args.size_max)}
for t in fl["bandwidthTiers"]]
sens = sensitivity(cfg, fl["probeKB"], list(range(-30, 31, args.step_pct)))
warns = [f"带宽档位 {r['tier']} 在 ±30% 浮动下进入率摆动 {r['swingPerMille']}permille,"
f"超过阈值 {args.warn_swing}" for r in sens if r["swingPerMille"] > args.warn_swing]
report = {"source": "sim/ch04_firstload_bench.py", "probeKB": fl["probeKB"],
"targetPerMille": args.target_per_mille, "stepPct": args.step_pct,
"warnSwingPerMille": args.warn_swing, "maxFirstPackageForTarget": limits,
"sensitivity": sens, "warnings": warns}
os.makedirs(args.out, exist_ok=True)
with open(os.path.join(args.out, "ch04_firstload_bench.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
print(f"target={args.target_per_mille}permille probe={fl['probeKB']}KB")
for r in limits:
print(f" {r['tier']:>7} {r['kbPerSec']:>5}KB/s -> maxFirstPackage={r['maxFirstPackageKB']}KB")
for r in sens:
print(f" {r['tier']:>7} base={r['baseKbPerSec']}KB/s entry {r['min']}..{r['max']}permille "
f"swing={r['swingPerMille']}")
for w in warns:
print(f"WARN {w}")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑输出(目标进入率 800‰):
target=800permille probe=2048KB
wifi 1800KB/s -> maxFirstPackage=4999KB
good4g 700KB/s -> maxFirstPackage=2999KB
weak4g 260KB/s -> maxFirstPackage=1560KB
wifi base=1800KB/s entry 860..878permille swing=18
good4g base=700KB/s entry 806..842permille swing=36
weak4g base=260KB/s entry 680..770permille swing=90
WARN 带宽档位 weak4g 在 ±30% 浮动下进入率摆动 90permille,超过阈值 80
这份输出直接给出了本节最重要的一条结论:
- 同一套参数下,进入率 800‰ 对应的首包上限是:Wi-Fi 4999KB、4G 良好 2999KB、4G 弱网 1560KB。上一节主包压到 920KB,落在三个档位的上限之内,其中弱网档位还有约 640KB 余量。
- 弱网档位的上限只有 Wi-Fi 档位的三分之一左右——首包预算实际是被最差的那一档网络决定的,不是被最好的那一档。如果按 Wi-Fi 的上限来定预算,弱网用户的进入率会掉出目标线。
- 带宽敏感性是一条软告警:弱网档位在 ±30% 带宽浮动下,进入率摆动 90‰,超过 80‰ 的阈值,所以脚本打印 WARN。这条告警的意思是:在弱网档位上,你的结论对带宽假设高度敏感,测出来的「进入率 752‰」不是一个稳定值,换一批用户样本可能差很多。B 层是软告警,它不失败,只提醒你别把弱网档位的数字当成硬结论。
4.2.5 冷启动约束落到设计上
- 首屏必须自足。目标不是「把包压到多小」,而是「首包加载完就能跑通一次核心循环」。如果首屏还要等一个分包,那次等待是额外的网络往返,代价比省下的体积大。
- 加载过程要占住注意力。弱网档位下 11 秒的等待是既定事实,能改的只有这 11 秒里玩家看到什么。进度条、可交互的占位动画、可跳过的小演示,都比一块静止的 loading 图强。
- 按最差档位定预算,按平均档位做优化。预算是硬线,要卡最差档位;优化是持续动作,按平均档位衡量收益。
小结
- 首屏耗时是三段串联:网络传输 + 冷启动初始化 + 首屏渲染;初始化开销也随体积增长,所以体积的影响比「体积 ÷ 带宽」更陡。
- S 层用整数递推把体积折算成整数秒,再套进带下限的线性进入率模型;探针 2048KB 下三条档位分别是 5 / 6 / 11 秒,进入率 860 / 842 / 752‰。
- B 层反过来算:目标进入率 800‰ 对应的首包上限是 Wi-Fi 4999KB、4G 良好 2999KB、弱网 1560KB——预算是被最差档位决定的;弱网档位在带宽 ±30% 浮动下摆动 90‰,超过阈值触发 WARN。
- 带宽档位是可配置参数,不是实测值;真实进入率需真实业务环境,命令行不可复现(X);仿真数字是同机相对量级,绝对数值不可移植。
包体定下来了、加载耗时算清楚了,还差最后一关:这些设计要用到的平台能力,目标平台到底给不给。这是下一节的事。
阅读导航:上一节:主包与分包体积预算 · 下一节:平台能力矩阵与审核红线 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。