14.3 内存与包体优化

把每项优化写成一对整数增量:有的降包体却升内存,有的反过来。枚举所有子集,在包体 × 内存的平面上求帕累托前沿,找出「无法同时再降两者」的那些点,再用 B 基准扰动最不确定的内存增量看前沿怎么变——显式声明这是解析模型而非真机实测,帮你在取舍曲线上选点。

站内已有一整卷 《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 章的三条性能战线——冷启动、卡顿、内存包体——都过了一遍,它们的方法论是一致的:把笼统的「慢 / 卡 / 大」拆成可算的整数模型,显式声明模型边界,用相对量级指导取舍。下一章进入测试与质量保障:怎么让这些改动「敢发」——逻辑单测、端到端回放、兼容性矩阵。

阅读导航:上一节:帧率与卡顿定位 · 下一节:逻辑层单元测试 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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