本节目标:把平台的包体上限变成一份可校验的预算表,并让分包方案由限额反推出来,而不是先做完再想办法塞进去。
站内有一套 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 分包的三个判断
最后把经验收敛成三条可复用的判断:
- 分包边界跟玩法边界对齐。不要按资源类型(图集 / 音频 / 配置)分,要按「玩家什么时候会用到它」分。装扮图集和装扮音效应该在同一包里,因为它们总是一起被需要。
- 首屏必须自足。主包加载完之后,玩家应该能立刻跑通一次核心循环,不需要等任何分包。否则冷启动会多一次网络往返,而那一次往返的流失比省下的几百 KB 值钱得多。
- 别把分包数量用满。限额是上限不是目标。分包越多,加载调度的分支越多,测试组合也越多。留出余量,给后续版本的内容更新留位置。
小结
- 包体是小游戏最硬的平台约束,它的作用是裁剪设计,不是发布前压缩。
- 限额有四条:主包、单分包、总包、分包数量,要一起看;最常见的翻车是总包很空而主包挤爆。
- D 层配置把限额与资产清单写成唯一真源;S 层脚本把它折算成各包体积、逐条判定、并对超限主包算出最小迁出集合。
- 本机实跑:v0 主包 4260KB 超限 164KB;挪走
island_decor_atlas后主包 920KB,四条限额全过,总包不变。 - 平台限额是公开可观察机制,观察时点 2026-10;文中数值是可配置参数,以平台最新文档为准。
主包定下来之后,下一个问题是:这份主包在用户的网络条件下要加载多久,加载多久会掉多少人。这是下一节的事。
阅读导航:上一节:品类选择加权评分与敏感度分析 · 下一节:首包加载与冷启动约束 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。