4.1 主包与分包体积预算

包体上限是小游戏最硬的一条平台约束,也是立项阶段就必须定下的裁剪依据。本节先把主包、单分包、总包、分包数量四条限额写成一份可校验的配置表,再用一段 S 层脚本把资产清单折算成各包体积、逐条比对限额,并对超限的主包算出最小迁出集合,让分包方案由约束反推出来。

本节目标:把平台的包体上限变成一份可校验的预算表,并让分包方案由限额反推出来,而不是先做完再想办法塞进去。

站内有一套 46 篇的小程序专题 ,讲的是小程序工程口径——WXML 结构、请求域名白名单、分包加载配置、审核与发布流程那一整套工程做法。本章换一个方向:不教你怎么写小游戏,而是讲小游戏包体与冷启动约束如何反向裁剪玩法与架构。同样是「分包」两个字,那边关心的是怎么配置,这边关心的是这一刀切在哪里,会砍掉哪些玩法设想。

第 1 到第 3 章一路从市场、榜单、品类收敛到「做什么」。本章把方向反过来:从平台往回看。平台给的第一条硬约束就是包体——它不讲道理,超了就是上不了架。

4.1 主包与分包体积预算

4.1.1 为什么包体是一条设计约束,而不是工程细节

很多人把包体当成发布前的一次压缩优化:资源压一压、图集拼一拼、代码删一删,总能过。这个想法在内容量已经定死的时候成立,在小游戏里不成立。

小游戏的产品形态是「点开即玩」。用户从点击到能操作,中间没有任何安装等待的耐心预算——这不是一个下载 500MB 的 App,用户点了就等着;小游戏在信息流里被点开,几秒钟没反应就划走了。于是包体直接决定了首屏要加载多少东西,而首屏加载多少东西,决定了第一眼能看到什么玩法。

所以包体不是「优化」,是裁剪。它的真正作用是回答三个设计问题:

  • 首屏必须自足到什么程度——玩家进来第一眼能看到核心循环吗?
  • 哪些资源必须常驻、哪些可以按玩法节点加载?
  • 一个玩法体量到底做多大,才装得进预算?

这三个问题的答案会一路影响到美术精度、图集拆分粒度、关卡数量上限、以及要不要做装扮系统这类「纯增量内容」。先有预算表,再有资源表,顺序反了就要返工。

4.1.2 四条限额与它们的口径

平台的包体限制不是一条,是四条,它们约束的是不同的东西:

限额约束什么超了会怎样
主包上限首屏必须加载的那一份直接上不了架
单分包上限任意一个分包单独的体积该分包无法被加载
总包上限全部包加起来的体积上不了架,或无法发布新版本
分包数量上限一共能拆几个分包拆不动,只能往主包里塞

这四条要一起看。常见误判是只盯着主包上限,把东西全塞进分包,结果撞上总包上限或者分包数量上限——限额是一组,不是一条。

需要说明的是:下面这些数值是公开可观察的平台机制,观察时点 2026-10。它们会随平台政策调整,所以本节把它们全部写成可配置参数,具体数值以平台最新文档为准;文中出现的一切数字都只是这套参数下的一次计算结果。

下面这份是 D 层配置:四条限额、包的角色说明、资产清单,以及一份待验证的调整方案。它是本节所有计算的唯一输入。

{
  "platform": "wechat",
  "observation": "公开可观察,观察时点 2026-10;限额为可配置参数,具体数值以平台最新文档为准",
  "limitsKB": {
    "mainPackageMax": 4096,
    "subPackageSingleMax": 2048,
    "totalPackageMax": 20480,
    "totalPackageCountMax": 20
  },
  "packages": {
    "main": "主包:启动引导 + 核心逻辑 + 首屏 UI",
    "sub_merge": "分包:合成图集与特效",
    "sub_levels": "分包:关卡配置与缩略图",
    "sub_decor": "分包:岛屿装扮图集",
    "sub_audio": "分包:音乐与音效"
  },
  "assets": [
    {"id": "boot", "pkg": "main", "kb": 180, "kind": "core", "movable": false},
    {"id": "core_logic", "pkg": "main", "kb": 120, "kind": "core", "movable": false},
    {"id": "ui_base_atlas", "pkg": "main", "kb": 420, "kind": "ui", "movable": false},
    {"id": "tutorial_assets", "pkg": "main", "kb": 160, "kind": "ui", "movable": false},
    {"id": "i18n_zh", "pkg": "main", "kb": 40, "kind": "config", "movable": false},
    {"id": "merge_tiles_atlas_full", "pkg": "main", "kb": 980, "kind": "art", "movable": true},
    {"id": "island_decor_atlas", "pkg": "main", "kb": 1400, "kind": "art", "movable": true},
    {"id": "bgm_main", "pkg": "main", "kb": 620, "kind": "audio", "movable": true},
    {"id": "sfx_pack", "pkg": "main", "kb": 340, "kind": "audio", "movable": true},
    {"id": "merge_fx_atlas", "pkg": "sub_merge", "kb": 700, "kind": "art", "movable": true},
    {"id": "merge_sfx", "pkg": "sub_merge", "kb": 220, "kind": "audio", "movable": true},
    {"id": "level_config", "pkg": "sub_levels", "kb": 90, "kind": "config", "movable": false},
    {"id": "level_thumbs", "pkg": "sub_levels", "kb": 240, "kind": "art", "movable": true},
    {"id": "island_decor_extra", "pkg": "sub_decor", "kb": 560, "kind": "art", "movable": true},
    {"id": "island_bgm", "pkg": "sub_audio", "kb": 720, "kind": "audio", "movable": true}
  ],
  "plan": {
    "label": "按主包限额重新分包的调整方案",
    "moves": [
      {"id": "merge_tiles_atlas_full", "to": "sub_merge"},
      {"id": "island_decor_atlas", "to": "sub_decor"},
      {"id": "bgm_main", "to": "sub_audio"},
      {"id": "sfx_pack", "to": "sub_audio"}
    ]
  }
}

清单里每条资产有两个关键字段:kb 是它的最终体积(已经压缩过,压缩本身是卷二第 4 章的话题),movable 表示它是否可以从主包挪走。boot、core_logic、ui_base_atlas 这些是首屏必需,movable: false;图集和音频原则上可以拆走。

4.1.3 资产清单 → 各包体积 → 是否超限(S 层)

下面这段是 S 层仿真脚本。它读上面那份配置,先把资产按包分组求和,再逐条比对四条限额;对超限的主包,它还会按体积从大到小挑出一个最小迁出集合——挪走这几项就能让主包回到限额内。全程整数运算(单位 KB),产出 JSON 与逐资产的 CSV。

#!/usr/bin/env python3
# file: sim/ch04_budget.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ch04_budget.py --config config/ --out out/
"""主包与分包体积预算校验。

读 config/ch04_budget.json:资产清单 + 各包体积上限。先把资产按包分组求和,再逐条
比对主包上限 / 单分包上限 / 总包上限 / 分包数量上限;对超限的主包,按体积从大到小
挑出「最小迁出集合」——这组资产挪到分包就能让主包回到限额内。
全程整数运算(体积单位 KB)。产出 out/ch04_budget.json + out/ch04_budget_assets.csv。
"""

from __future__ import annotations

import argparse
import csv
import json
import os
import sys

LIMIT_KEYS = ("mainPackageMax", "subPackageSingleMax", "totalPackageMax", "totalPackageCountMax")


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 apply_plan(assets, moves):
    """把 plan.moves 施加到资产清单上,返回新的资产列表。"""
    target = {m["id"]: m["to"] for m in moves}
    out = []
    for a in assets:
        b = dict(a)
        if a["id"] in target:
            b["pkg"] = target[a["id"]]
            b["moved"] = True
        out.append(b)
    return out


def group_totals(assets):
    totals = {}
    for a in assets:
        totals[a["pkg"]] = totals.get(a["pkg"], 0) + a["kb"]
    return totals


def evaluate(assets, limits):
    """按四条限额检查一份分包方案,返回体积表、违例清单与判定。"""
    totals = group_totals(assets)
    main_kb = totals.get("main", 0)
    sub_kb = {p: v for p, v in totals.items() if p != "main"}
    total_kb = sum(totals.values())
    count = len(totals)

    violations = []
    if main_kb > limits["mainPackageMax"]:
        violations.append(
            f"主包 {main_kb}KB 超过上限 {limits['mainPackageMax']}KB,超出 {main_kb - limits['mainPackageMax']}KB"
        )
    for p, v in sorted(sub_kb.items()):
        if v > limits["subPackageSingleMax"]:
            violations.append(f"分包 {p} {v}KB 超过单包上限 {limits['subPackageSingleMax']}KB")
    if total_kb > limits["totalPackageMax"]:
        violations.append(f"总包 {total_kb}KB 超过上限 {limits['totalPackageMax']}KB")
    if count > limits["totalPackageCountMax"]:
        violations.append(f"分包数量 {count} 超过上限 {limits['totalPackageCountMax']}")

    return {
        "packages": totals,
        "mainKB": main_kb,
        "subPackagesKB": sub_kb,
        "totalKB": total_kb,
        "packageCount": count,
        "mainSharePerMille": main_kb * 1000 // max(1, total_kb),
        "mainHeadroomKB": limits["mainPackageMax"] - main_kb,
        "violations": violations,
        "ok": not violations,
    }


def suggest_moves(assets, limits):
    """主包超限时,按体积从大到小挑出最小迁出集合(可挪动的资产)。"""
    main_kb = sum(a["kb"] for a in assets if a["pkg"] == "main")
    need = main_kb - limits["mainPackageMax"]
    if need <= 0:
        return {"neededKB": 0, "picked": [], "freedKB": 0}
    cand = sorted(
        (a for a in assets if a["pkg"] == "main" and a["movable"]), key=lambda a: -a["kb"]
    )
    picked, freed = [], 0
    for a in cand:
        if freed >= need:
            break
        picked.append(a["id"])
        freed += a["kb"]
    return {"neededKB": need, "picked": picked, "freedKB": freed, "candidates": [a["id"] for a in cand]}


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    args = ap.parse_args()

    cfg = load(args.config, "ch04_budget")
    limits, assets = cfg["limitsKB"], cfg["assets"]
    os.makedirs(args.out, exist_ok=True)

    baseline = evaluate(assets, limits)
    planned_assets = apply_plan(assets, cfg["plan"]["moves"])
    planned = evaluate(planned_assets, limits)
    suggestion = suggest_moves(assets, limits)

    report = {
        "source": "sim/ch04_budget.py",
        "platform": cfg["platform"],
        "observation": cfg["observation"],
        "limitsKB": limits,
        "baseline": baseline,
        "plan": {"label": cfg["plan"]["label"], "result": planned,
                 "movedAssets": [a["id"] for a in planned_assets if a.get("moved")]},
        "suggestion": suggestion,
        "ok": planned["ok"],
    }
    with open(os.path.join(args.out, "ch04_budget.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)

    with open(os.path.join(args.out, "ch04_budget_assets.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["profile", "package", "assetId", "kb", "kind", "movable"])
        for prof, lst in (("v0", assets), ("v1", planned_assets)):
            for a in sorted(lst, key=lambda x: (x["pkg"], -x["kb"])):
                w.writerow([prof, a["pkg"], a["id"], a["kb"], a["kind"], str(a["movable"]).lower()])

    print(f"platform={cfg['platform']} limits main={limits['mainPackageMax']} "
          f"sub={limits['subPackageSingleMax']} total={limits['totalPackageMax']} count={limits['totalPackageCountMax']}")
    for name, rep in (("v0 初版", baseline), ("v1 调整", planned)):
        print(f"[{name}] main={rep['mainKB']} total={rep['totalKB']} packages={rep['packageCount']} "
              f"mainShare={rep['mainSharePerMille']}permille ok={rep['ok']}")
        for v in rep["violations"]:
            print(f"  FAIL {v}")
    print(f"v0 主包超出 {suggestion['neededKB']}KB,最小迁出集合={suggestion['picked']} "
          f"(腾出 {suggestion['freedKB']}KB)")
    print("== 包体预算:OK" if report["ok"] else "== 包体预算:FAIL")
    return 0 if report["ok"] else 1


if __name__ == "__main__":
    sys.exit(main())

本机实跑输出:

platform=wechat limits main=4096 sub=2048 total=20480 count=20
[v0 初版] main=4260 total=6790 packages=5 mainShare=627permille ok=False
  FAIL 主包 4260KB 超过上限 4096KB,超出 164KB
[v1 调整] main=920 total=6790 packages=5 mainShare=135permille ok=True
v0 主包超出 164KB,最小迁出集合=['island_decor_atlas'] (腾出 1400KB)
== 包体预算:OK

逐条读这份输出:

  • v0(初版)主包 4260KB,超限 164KB。总包 6790KB 远低于 20480KB,分包 5 个也远少于 20 个上限——只有主包这一条越界。这正是最常见的翻车方式:总包很空,主包挤爆。
  • v1(调整后)主包 920KB,四条限额全过,主包占总包的比例从 627‰ 降到 135‰。注意总包体积没有变(都是 6790KB)——分包不改变总量,它只改变「谁在首屏等」。
  • 最小迁出集合是 island_decor_atlas 一项。它 1400KB,挪走之后主包降到 2860KB,一步就够。脚本按体积从大到小挑,所以挑出来的一定是最少的项数。

这些数字是同机相对量级,绝对数值不可移植:换一套资产清单,结论会完全不同。真正可移植的是这套流程——预算表是输入,资产清单是输入,越界判定和迁出建议是输出。

4.1.4 约束反向决定设计:分包线画在哪里

脚本给出的「挪走 island_decor_atlas」只是一个体积上的答案。把它翻译成设计决策,才是这一章真正的产出:

  • 装扮系统不能出现在首屏。岛屿装扮图集是主包里最大的一块,它被请出主包之后,意味着玩家进入游戏的第一屏看不到装扮玩法。如果原设计是「一进来就展示你的小岛」,那这个设计就废了——要么改成核心循环跑几局之后再解锁岛屿,要么接受装扮图集常驻主包、把别的挪走。
  • 合成图集要拆成两段。merge_tiles_atlas_full 是全量图集,它也被挪进了分包。首屏只需要前几级合成物,于是主包里保留一个「前 6 级」的轻量图集,全量图集等玩家玩到第 7 级再按需加载。这直接决定了新手引导覆盖到第几级。
  • 音频永远不进主包。bgm_main 与 sfx_pack 合计 960KB,纯听觉内容,首屏一秒都不需要。把它们放进 sub_audio 是零成本的。

三条合起来看:预算表决定了首屏能展示什么,首屏能展示什么决定了新手引导的边界,新手引导的边界决定了玩家多久才能碰到完整玩法。这就是「平台约束反向裁剪设计」的实际含义——它不是发布前的一次压缩,而是立项时的一次剪裁。

4.1.5 分包的三个判断

最后把经验收敛成三条可复用的判断:

  1. 分包边界跟玩法边界对齐。不要按资源类型(图集 / 音频 / 配置)分,要按「玩家什么时候会用到它」分。装扮图集和装扮音效应该在同一包里,因为它们总是一起被需要。
  2. 首屏必须自足。主包加载完之后,玩家应该能立刻跑通一次核心循环,不需要等任何分包。否则冷启动会多一次网络往返,而那一次往返的流失比省下的几百 KB 值钱得多。
  3. 别把分包数量用满。限额是上限不是目标。分包越多,加载调度的分支越多,测试组合也越多。留出余量,给后续版本的内容更新留位置。

小结

  • 包体是小游戏最硬的平台约束,它的作用是裁剪设计,不是发布前压缩。
  • 限额有四条:主包、单分包、总包、分包数量,要一起看;最常见的翻车是总包很空而主包挤爆。
  • D 层配置把限额与资产清单写成唯一真源;S 层脚本把它折算成各包体积、逐条判定、并对超限主包算出最小迁出集合。
  • 本机实跑:v0 主包 4260KB 超限 164KB;挪走 island_decor_atlas 后主包 920KB,四条限额全过,总包不变。
  • 平台限额是公开可观察机制,观察时点 2026-10;文中数值是可配置参数,以平台最新文档为准。

主包定下来之后,下一个问题是:这份主包在用户的网络条件下要加载多久,加载多久会掉多少人。这是下一节的事。

阅读导航:上一节:品类选择加权评分与敏感度分析 · 下一节:首包加载与冷启动约束 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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