4.2 首包加载与冷启动约束

首包体积定下来之后,真正决定流失的是它要加载多久。本节用整数递推把体积折算成下载耗时,再叠一个随体积增长的冷启动初始化开销,套进一个带下限的线性进入率模型,产出三条带宽档位的曲线;B 层反过来算,给出目标进入率下各档位允许的首包上限与带宽敏感性告警。

本节目标:把「首包多大」换算成「玩家要等多久」,再把「等多久」换算成「有多少人愿意等」,最后反过来定出首包体积的上限。

站内那套 46 篇的小程序专题 讲的是小程序工程口径——代码包怎么拆、请求怎么放行、审核怎么过。本节不重复那些工程做法,讲的是小游戏包体与冷启动约束如何反向裁剪玩法与架构:上一节算出了主包 920KB,这一节回答的是——920KB 在用户的网络条件下要等几秒,等这几秒会掉多少人。

上一节把主包压到了限额内。但「不超限」只是及格线,不是目标线。真正决定首屏体验的是加载耗时,而加载耗时不是一个固定值——同一份包,在 Wi-Fi 下和在弱网下是两个世界。

4.2 首包加载与冷启动约束

4.2.1 加载耗时由三件事决定

玩家点开小游戏之后、能操作之前,经历的是三段串联的时间:

阶段由什么决定能不能优化
网络传输首包体积 ÷ 实际带宽靠减体积、靠 CDN 就近
冷启动初始化固定开销 + 与体积成正比的解析/编译开销靠减体积、靠懒解析
首屏渲染资源解码 + 首帧绘制靠图集与分辨率(卷二的话题)

三段串联,总耗时是三段之和。容易忽略的一点:初始化开销本身也随体积增长——体积大不只是下载久,解析准备也更久,所以体积对总耗时的影响比「体积 ÷ 带宽」更陡。本节建模前两段,第三段归给卷二第 3 章。

4.2.2 模型参数(D 层配置)

下面这份是 D 层配置(config/ch04_firstload.json)的参数摘要。带宽档位是可配置参数,用来做相对比较,不代表任何真实网络环境的实测值。

参数取值含义
bandwidthTiers1800 / 700 / 260 KB/sWi-Fi / 4G 良好 / 4G 弱网三个档位
initbaseSec 1、perKBPerMille 1冷启动固定开销 + 与体积成正比的整数千分比项
entryModelbasePerMille 950、decayPerSecPerMille 18、minPerMille 320带下限的线性进入率模型
sweep / probeKB512–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 冷启动约束落到设计上

  1. 首屏必须自足。目标不是「把包压到多小」,而是「首包加载完就能跑通一次核心循环」。如果首屏还要等一个分包,那次等待是额外的网络往返,代价比省下的体积大。
  2. 加载过程要占住注意力。弱网档位下 11 秒的等待是既定事实,能改的只有这 11 秒里玩家看到什么。进度条、可交互的占位动画、可跳过的小演示,都比一块静止的 loading 图强。
  3. 按最差档位定预算,按平均档位做优化。预算是硬线,要卡最差档位;优化是持续动作,按平均档位衡量收益。

小结

  • 首屏耗时是三段串联:网络传输 + 冷启动初始化 + 首屏渲染;初始化开销也随体积增长,所以体积的影响比「体积 ÷ 带宽」更陡。
  • S 层用整数递推把体积折算成整数秒,再套进带下限的线性进入率模型;探针 2048KB 下三条档位分别是 5 / 6 / 11 秒,进入率 860 / 842 / 752‰。
  • B 层反过来算:目标进入率 800‰ 对应的首包上限是 Wi-Fi 4999KB、4G 良好 2999KB、弱网 1560KB——预算是被最差档位决定的;弱网档位在带宽 ±30% 浮动下摆动 90‰,超过阈值触发 WARN。
  • 带宽档位是可配置参数,不是实测值;真实进入率需真实业务环境,命令行不可复现(X);仿真数字是同机相对量级,绝对数值不可移植。

包体定下来了、加载耗时算清楚了,还差最后一关:这些设计要用到的平台能力,目标平台到底给不给。这是下一节的事。

阅读导航:上一节:主包与分包体积预算 · 下一节:平台能力矩阵与审核红线 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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