站内已有一整卷 《Cocos 游戏开发·高级》 ——那 34 篇讲的是 Cocos Creator 引擎内部与定制:纹理怎么管、资源怎么加载、引擎怎么改。本章讲的是与引擎无关的性能分析方法:首包体积 → 启动耗时的分解模型、卡顿的归因路径、内存峰值与包体的取舍——方法可迁移到任何引擎。那卷教你「在 Cocos 里怎么省内存」,本章教你「怎么把包体和内存的取舍画成一张能看懂的图」。
前两节分别处理了首包冷启动与帧率卡顿。这一节处理性能优化的第三条战线——内存与包体。它俩常被当成一回事(都是「资源太大」),但其实是两个会互相打架的目标:压小包体的手段往往会让运行时常驻内存变大,反过来也一样。这一节就把这种取舍量化。
包体与内存:一对会打架的目标
包体是下载时的体积,内存峰值是运行时的占用。它们由同一批资源决定,但优化方向经常相反:
- 降包体的手段常常升内存。压缩纹理、重编码音频能让包体变小,但压缩数据在运行时必须解压,解压后的像素 / 采样常驻内存,反而让内存峰值抬高。
- 降内存的手段常常升包体。构建期把资源预展开、运行期裁剪缓存能压低内存,但预展开的数据要塞进包里,包体就变大。
只有极少数优化(比如剔除根本没用到的资源)能同时降两者——它不构成取舍,属于「无条件该做」的那一类。判断「哪些优化该做、做到什么程度」,本质是在包体与内存这张二维平面上找一条帕累托前沿。
三类优化:谁帮谁、谁害谁
本节模型给六项优化各一对整数增量(KB):
| 优化 | 包体增量 | 内存增量 | 类型 |
|---|---|---|---|
| 纹理压缩 texCompress | −640 | +49152 | 降包体·升内存 |
| 音频重编码 audioReencode | −300 | +20480 | 降包体·升内存 |
| 图集合并 atlasMerge | −320 | +8192 | 降包体·升内存 |
| 构建期预展开 preExpand | +520 | −32768 | 升包体·降内存 |
| 运行期裁剪 memTrim | +256 | −24576 | 升包体·降内存 |
| 剔除未用资源 dropUnused | −192 | −4096 | 两者都降 |
基线是 3072KB 包体、196608KB(192MB)内存峰值。六项优化各有「做 / 不做」两种状态,一共 2⁶=64 种组合。
⚠️ 照例声明:这是解析模型,不是真机实测。每项增量都是模型假设——真实项目里「纹理压缩升多少内存」取决于压缩格式、贴图集大小、是否分帧解压,能差好几倍。模型的用途是看清取舍的结构(谁和谁打架),不是预测真机的绝对 KB 数。
帕累托前沿:什么叫「无法同时再降」
64 个组合每个都是平面上一个点(x=包体,y=内存)。一个点被支配,当且仅当存在另一点两项都不更差、且至少一项更优。没被任何点支配的点组成帕累托前沿——在前沿上,你想再降一点包体,就必须接受内存变大;想再降一点内存,就必须接受包体变大。前沿之外的点都是浪费:总有一个方案能两项都不比你差。
S 层:取舍模型与前沿
下面这段是 S 层脚本 sim/e14_tradeoff.py:枚举 2⁶ 个子集,逐个算包体与内存,求帕累托前沿,产出 JSON + CSV + 纯 Python SVG 散点图(灰点是被支配的子集,蓝点是前沿折线)。
#!/usr/bin/env python3
# file: sim/e14_tradeoff.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/e14_tradeoff.py --config config/ --out out/
"""内存峰值与包体优化收益的取舍模型:求帕累托前沿。
⚠️ 这是**解析模型,不是真机实测**。每项优化给一对整数增量(pkgDeltaKB, memDeltaKB):
- 有的**降包体但升内存**(纹理压缩、音频重编码:压缩后的资源运行时解压,常驻内存变大);
- 有的**升包体但降内存**(构建期预展开、运行期裁剪:多带东西或少留缓存);
- 有的**两者都降**(剔除未用资源)——它不构成取舍,任何前沿点都会带上它。
枚举 2^n 个子集,把每个子集映射成 (packageKB, peakMemKB) 平面上的一个点,求「同时最小化
包体与内存」的**帕累托前沿**:一个点被支配,当且仅当另一点两项都不更差、且至少一项更优。
产出:out/e14_tradeoff.json + out/e14_tradeoff_subsets.csv + out/e14_tradeoff.svg
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET
def load(cfg_dir):
with open(os.path.join(cfg_dir, "e14_tradeoff.json"), encoding="utf-8") as f:
return json.load(f)
def enumerate_subsets(cfg):
"""枚举 2^n 个子集,每个映射成 (packageKB, peakMemKB)。全整数。"""
base, opts, n = cfg["baseline"], cfg["optimizations"], len(cfg["optimizations"])
points = []
for mask in range(1 << n):
pkg, mem, applied = base["packageKB"], base["peakMemKB"], []
for i in range(n):
if mask & (1 << i):
pkg += opts[i]["pkgDeltaKB"]
mem += opts[i]["memDeltaKB"]
applied.append(opts[i]["id"])
points.append({"mask": mask, "applied": applied, "packageKB": pkg, "peakMemKB": mem})
return points
def dominates(a, b):
"""a 支配 b:a 两项都不更差,且至少一项更优(都是最小化方向)。"""
return (a["packageKB"] <= b["packageKB"] and a["peakMemKB"] <= b["peakMemKB"]
and (a["packageKB"] < b["packageKB"] or a["peakMemKB"] < b["peakMemKB"]))
def frontier_of(points):
return [p for p in points if not any(dominates(q, p) for q in points)]
def draw_svg(path, points, frontier):
"""纯 Python SVG:所有子集散点(灰)+ 帕累托前沿折线(蓝),x=包体,y=内存峰值。"""
W, H, PAD = 780, 460, 64
xs, ys = [p["packageKB"] for p in points], [p["peakMemKB"] for p in points]
x_lo, y_lo = min(xs), min(ys)
xr, yr = max(1, max(xs) - x_lo), max(1, max(ys) - y_lo)
sx = lambda v: PAD + (W - 2 * PAD) * (v - x_lo) // xr
sy = lambda v: H - PAD - (H - 2 * PAD) * (v - y_lo) // yr
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": str(PAD), "y": "34", "font-size": "15", "fill": "#111827"}
).text = "包体 × 内存峰值:帕累托前沿(非真机实测)"
for p in points:
ET.SubElement(svg, "circle", {"cx": str(sx(p["packageKB"])), "cy": str(sy(p["peakMemKB"])),
"r": "3", "fill": "#d1d5db"})
fr = sorted(frontier, key=lambda p: p["packageKB"])
ET.SubElement(svg, "polyline", {"points": " ".join(f'{sx(p["packageKB"])},{sy(p["peakMemKB"])}' for p in fr),
"fill": "none", "stroke": "#2563eb", "stroke-width": "2"})
for p in fr:
ET.SubElement(svg, "circle", {"cx": str(sx(p["packageKB"])), "cy": str(sy(p["peakMemKB"])),
"r": "5", "fill": "#2563eb"})
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)
points = enumerate_subsets(cfg)
frontier = frontier_of(points)
fmasks = {p["mask"] for p in frontier}
n = len(cfg["optimizations"])
in_all = [cfg["optimizations"][i]["id"] for i in range(n) if all(m & (1 << i) for m in fmasks)]
for p in points:
p["onFrontier"] = p["mask"] in fmasks
fr_sorted = sorted(frontier, key=lambda p: p["packageKB"])
os.makedirs(args.out, exist_ok=True)
svg_bytes = draw_svg(os.path.join(args.out, "e14_tradeoff.svg"), points, frontier)
report = {
"version": cfg["version"], "game": cfg["game"], "seed": cfg["seed"],
"model": "package/memory tradeoff analytic model (NOT a device measurement)",
"baseline": cfg["baseline"], "optimizations": cfg["optimizations"], "budget": cfg["budget"],
"subsets": len(points), "frontierSize": len(frontier),
"frontier": [{"packageKB": p["packageKB"], "peakMemKB": p["peakMemKB"], "applied": p["applied"]}
for p in fr_sorted],
"bestPackage": {"packageKB": fr_sorted[0]["packageKB"], "peakMemKB": fr_sorted[0]["peakMemKB"],
"applied": fr_sorted[0]["applied"]},
"bestMemory": {"packageKB": fr_sorted[-1]["packageKB"], "peakMemKB": fr_sorted[-1]["peakMemKB"],
"applied": fr_sorted[-1]["applied"]},
"appliedInAllFrontier": in_all,
"csv": os.path.join(args.out, "e14_tradeoff_subsets.csv"),
"svg": os.path.join(args.out, "e14_tradeoff.svg"), "svgBytes": svg_bytes,
}
with open(os.path.join(args.out, "e14_tradeoff.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
with open(os.path.join(args.out, "e14_tradeoff_subsets.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["mask", "packageKB", "peakMemKB", "onFrontier", "applied"])
for p in points:
w.writerow([p["mask"], p["packageKB"], p["peakMemKB"], str(p["onFrontier"]).lower(),
"|".join(p["applied"])])
print(f'subsets={len(points)} frontierSize={len(frontier)}')
for p in fr_sorted:
print(f' pkg={p["packageKB"]:>5}KB mem={p["peakMemKB"]:>6}KB applied={"|".join(p["applied"]) or "(none)"}')
print(json.dumps({"frontierSize": len(frontier), "bestPackageKB": fr_sorted[0]["packageKB"],
"bestPackageMemKB": fr_sorted[0]["peakMemKB"],
"bestMemoryKB": fr_sorted[-1]["peakMemKB"], "bestMemoryPkgKB": fr_sorted[-1]["packageKB"],
"appliedInAllFrontier": in_all, "svgBytes": svg_bytes}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
本机实跑输出(前沿 13 个点,节选首尾):
$ /opt/homebrew/bin/python3 sim/e14_tradeoff.py --config config/ --out out/
subsets=64 frontierSize=13
pkg= 1620KB mem=270336KB applied=texCompress|audioReencode|atlasMerge|dropUnused
pkg= 1876KB mem=245760KB applied=texCompress|audioReencode|atlasMerge|memTrim|dropUnused
...
pkg= 3336KB mem=143360KB applied=atlasMerge|preExpand|memTrim|dropUnused
pkg= 3656KB mem=135168KB applied=preExpand|memTrim|dropUnused
{"frontierSize": 13, "bestPackageKB": 1620, "bestPackageMemKB": 270336, "bestMemoryKB": 135168, "bestMemoryPkgKB": 3656, "appliedInAllFrontier": ["dropUnused"], "svgBytes": 4293}
前沿两端就是取舍的两个极端:最小包体 1620KB,代价是内存涨到 270336KB(264MB);最小内存 135168KB(132MB),代价是包体涨到 3656KB。appliedInAllFrontier 是 ["dropUnused"]——剔除未用资源同时降包体与内存,所以它出现在每一个前沿点上,属于无条件该做的优化;它不是取舍,是白捡的。
B 层:参数扰动下的前沿
上面那张前沿建立在「纹理压缩升 49152KB 内存」这条假设上。而这条假设最不确定:换个压缩格式、换张更大的贴图集,这个数能翻倍。下面这段是 B 层脚本 sim/e14_tradeoff_bench.py:把纹理压缩的内存增量按不同倍率缩放,每档重算前沿,看「最小包体点」的内存是否还落在预算内。
#!/usr/bin/env python3
# file: sim/e14_tradeoff_bench.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/e14_tradeoff_bench.py --config config/ --out out/
"""B 基准:参数扰动下的帕累托前沿(软告警,不硬失败)。
「纹理压缩升多少内存」这条假设最不确定:不同平台、不同压缩格式、不同贴图集,运行时常驻
开销能差好几倍。这里把该优化的 memDeltaKB 按 scanPermille 各档缩放(整数地板除),每档重算
帕累托前沿,看前沿形状与「最小包体点」的内存是否仍落在 peakMemMaxKB 预算内。
软告警只打 WARN,恒返回 0。产出 out/e14_tradeoff_bench.json + out/e14_tradeoff_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_tradeoff import load, enumerate_subsets, frontier_of # noqa: E402
def perturbed_cfg(cfg, target_id, axis, permille):
"""复制 cfg,把 target 优化的 axis 增量按 permille 缩放(整数地板除)。"""
out = json.loads(json.dumps(cfg))
for opt in out["optimizations"]:
if opt["id"] == target_id:
opt[axis] = opt[axis] * permille // 1000
return out
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)
pt = cfg["benchPerturb"]
mem_max = cfg["budget"]["peakMemMaxKB"]
rows, warns = [], []
for m in pt["scanPermille"]:
pc = perturbed_cfg(cfg, pt["target"], pt["axis"], m)
fr = sorted(frontier_of(enumerate_subsets(pc)), key=lambda p: p["packageKB"])
best = fr[0]
warn = best["peakMemKB"] > mem_max
rows.append({"permille": m,
"texMemDeltaKB": next(o[pt["axis"]] for o in pc["optimizations"] if o["id"] == pt["target"]),
"frontierSize": len(fr), "bestPackageKB": best["packageKB"],
"bestPackageMemKB": best["peakMemKB"], "bestMemoryKB": fr[-1]["peakMemKB"],
"bestMemoryPkgKB": fr[-1]["packageKB"], "warn": warn})
if warn:
warns.append(f"纹理压缩内存增量 ×{m}‰ → 最小包体点内存 {best['peakMemKB']}KB 越过预算 {mem_max}KB")
os.makedirs(args.out, exist_ok=True)
report = {"source": "sim/e14_tradeoff_bench.py", "tier": "B",
"model": "package/memory tradeoff analytic model (NOT a device measurement)",
"target": pt["target"], "axis": pt["axis"], "peakMemMaxKB": mem_max,
"rows": rows, "warnings": warns, "ok": not warns,
"frontierSizeRange": {"min": min(r["frontierSize"] for r in rows),
"max": max(r["frontierSize"] for r in rows)},
"csv": os.path.join(args.out, "e14_tradeoff_bench.csv")}
with open(os.path.join(args.out, "e14_tradeoff_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_tradeoff_bench.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["permille", "texMemDeltaKB", "frontierSize", "bestPackageKB",
"bestPackageMemKB", "bestMemoryKB", "warn"])
for r in rows:
w.writerow([r["permille"], r["texMemDeltaKB"], r["frontierSize"], r["bestPackageKB"],
r["bestPackageMemKB"], r["bestMemoryKB"], str(r["warn"]).lower()])
for r in rows:
print(f'×{r["permille"]:>4}‰ texMem=+{r["texMemDeltaKB"]:>6}KB frontier={r["frontierSize"]:>2} '
f'bestPkg={r["bestPackageKB"]:>5}KB@mem{r["bestPackageMemKB"]:>6}KB '
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())
本机实跑输出如下:
$ /opt/homebrew/bin/python3 sim/e14_tradeoff_bench.py --config config/ --out out/
× 500‰ texMem=+ 24576KB frontier= 9 bestPkg= 1620KB@mem245760KB
× 750‰ texMem=+ 36864KB frontier=12 bestPkg= 1620KB@mem258048KB
×1000‰ texMem=+ 49152KB frontier=13 bestPkg= 1620KB@mem270336KB WARN
×1250‰ texMem=+ 61440KB frontier=11 bestPkg= 1620KB@mem282624KB WARN
×1500‰ texMem=+ 73728KB frontier=11 bestPkg= 1620KB@mem294912KB WARN
WARN 纹理压缩内存增量 ×1000‰ → 最小包体点内存 270336KB 越过预算 262144KB
== 取舍前沿扰动基准:WARN(软告警不硬失败)
看 bestPkg 这一列:不管内存增量怎么变,最小包体点永远是 1620KB(因为降包体的组合没变),变的是它的内存代价——从 245760KB 一路涨到 294912KB。内存预算 peakMemMaxKB=262144KB(256MB)在 ×1000‰ 档就被越过了,而且是最小包体点越过的——也就是说「把包体压到最小」这条路,在内存增量假设偏乐观时就已经不安全了。前沿形状(frontierSize 从 9 到 13 再到 11)也跟着扰动变形,说明取舍曲线的具体位置对假设很敏感。
这些数字怎么读
三点。第一,取舍是结构性的,不是调参能消掉的:只要「降包体」和「降内存」由同一批资源决定,前沿就一定存在,你能选的只是停在前沿的哪一点。第二,区分「白捡」与「取舍」:像 dropUnused 这种同时降两者的,不进入取舍,直接做;像 texCompress 这种降包体升内存的,才需要按目标用户的设备分布决定做到什么程度。第三,假设比结论更重要:前沿的位置完全由那六对增量假设决定,texCompress 的内存增量一扰动,安全边界就翻转。所以真要做决策,这六个增量必须在目标机型上实测——本节给的是求前沿的方法,不是可以照抄的数字。
小结与下一步
这一节把包体与内存放进同一张平面:六项优化各是一对增量,枚举所有子集求帕累托前沿,看清「降包体」与「降内存」何时是朋友(剔除未用资源)、何时是敌人(压缩 vs 预展开),再用参数扰动说明前沿位置对假设有多敏感。至此第 14 章的三条性能战线——冷启动、卡顿、内存包体——都过了一遍,它们的方法论是一致的:把笼统的「慢 / 卡 / 大」拆成可算的整数模型,显式声明模型边界,用相对量级指导取舍。下一章进入测试与质量保障:怎么让这些改动「敢发」——逻辑单测、端到端回放、兼容性矩阵。
阅读导航:上一节:帧率与卡顿定位 · 下一节:逻辑层单元测试 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。