《移动游戏产品的架构·网络、资源与热更新》9.3 双案例的首包账:120MB 与 350MB

消消首包 120MB、方舟 350MB,占比分别是 25% 与 19.02%,但体积账不能只看占比。本节把首包、分包、失败安装、月度流量与年度更新放到同一张表上,用 312MB 与 2,340MB 的年度更新量说明两种产品节奏的差别与真实代价。

两张商店页,两种体积曲线

把《潮汐消消》和《潮汐方舟》的商店页放在一起,会看到两种完全不同的体积曲线。消消的首包是 120MB,总资源 480MB;方舟的首包是 350MB,总资源 1,840MB。前者靠 4 个 90MB 的分包把 360MB 移出安装路径,后者靠 6 个分包把 1,490MB 移出安装路径。

单看首包,方舟是消消的 2.9 倍。单看总资源,方舟是消消的 3.8 倍。两个倍数不一致,说明它们的分包策略并不相同:方舟把更大比例的资源放进了分包,首包只保留强联网战斗启动必须的部分。

这就引出一个常见问题:谁的首包更「克制」?用占比回答,消消是 25%,方舟是 19.02%,方舟更低;用绝对值回答,消消 120MB,方舟 350MB,消消更低。两个答案都对,但都没有回答真正的问题——这份体积对应的玩家成本是多少。

体积账必须把分母带上。消消的月新增是 42 万,方舟是 8 万,相差 5.25 倍;DAU 分别是 80 万和 25 万,相差 3.2 倍。同样 10MB 的首包增量,落在消消上是 4.2 万新增用户的等待,落在方舟上只有 0.8 万。

首包占比:25% 与 19.02% 说明不了谁更克制

占比是一个相对指标,它把「首包」和「总资源」绑在一起。总资源越大,占比越容易被稀释;一个游戏可以首包 350MB,但因为总资源 1,840MB,占比看起来只有 19.02%,比消消的 25% 更「好看」。

但玩家不下载「占比」,玩家下载「体积」。方舟的 350MB 首包对应 8 万月新增,消消的 120MB 对应 42 万月新增;前者的单个用户成本更高,后者的总用户成本更高。占比在这里只说明结构,不说明代价。

指标消消方舟说明
首包120MB350MB商店首次安装体积
分包合计360MB(4×90MB)1,490MB(4×260+260+190)安装后按需下载
总资源480MB1,840MB首包 + 分包
首包占比25%19.02%首包 ÷ 总资源
月新增42 万8 万新增安装
首包月流量约 48.07TiB约 26.70TiB新增 × 首包
CDN 占比约 23.9%约 18.2%首包流量 ÷ CDN 月下载

这张表里最有信息量的一行不是占比,而是首包月流量:消消的 48.07TiB 比方舟的 26.70TiB 高出约 80%,尽管它的首包只有对方的三分之一。原因就是新增安装数的差距。

占比还有一层误导:它假设总资源是固定的。实际上总资源会随版本增长,如果首包按固定预算锁死,占比会自然下降,看起来越来越「克制」,但玩家下载的绝对体积一点没变。占比适合描述结构,不适合作为预算指标。

单位资源体积:38.4KB 与 21.9KB

消消有 12,800 个资源文件,方舟有 86,000 个。用总资源除以文件数,消消的平均资源约 38.4KB,方舟约 21.9KB。方舟的文件数是消消的 6.7 倍,总资源是 3.8 倍,文件更碎、单文件更小。

这个差异会传导到下载调度。文件越碎,依赖图越密:消消的平均出度 3.2、最大深度 6,方舟是 4.1 和 9。深度 9 意味着一个资源可能牵着九层下游,任何一个中间节点缺失,都会让整条链无法完成。

文件更碎也意味着校验开销更高。86,000 个文件的哈希校验、索引建立和失败重试,都比 12,800 个文件更重;这就是方舟冷启动 P50 3.5 秒、P95 6.8 秒,而消消是 1.8 秒和 3.2 秒的原因之一。首包体积只是冷启动的一部分,文件数量和索引结构同样计入。

指标消消方舟
资源文件数12,80086,000
平均单文件约 38.4KB约 21.9KB
依赖图平均出度3.24.1
依赖图最大深度69
冷启动 P50 / P951.8s / 3.2s3.5s / 6.8s

这组数字说明,首包预算不能只按字节定,还要按文件数定。两个同样 350MB 的包,一个装 1 万个文件、一个装 8 万个文件,后者的校验与索引成本明显更高。首包准入如果把「文件数」作为第二预算,就能避免「字节没超、启动变慢」的情况。

更新节奏:312MB 与 2,340MB 的年度账

消消的增量热更是双周一次、平均 12MB,商店整包每 8 周一次,整包差量 55 到 70MB。方舟是每周一次、平均 45MB,商店整包每 6 周一次,整包差量 60 到 90MB。

把节奏折成年:消消一年约 26 次热更,合计 312MB;方舟一年约 52 次,合计 2,340MB。方舟的年度热更量是消消的 7.5 倍,也超过了它自己 1,840MB 的总资源——一个留存一年的玩家,光热更下载量就超过一份完整资源。

指标消消方舟
热更周期双周每周
单次热更平均 12MB平均 45MB
年热更次数约 26约 52
年热更量约 312MB约 2,340MB
商店整包周期每 8 周每 6 周
年商店版本约 6.5约 8.7
整包差量55–70MB60–90MB

商店整包的年度量可以按区间折算:消消约 6.5 次乘以 55 到 70MB,约 358 到 455MB;方舟约 8.7 次乘以 60 到 90MB,约 522 到 783MB。加上热更,消消一年约 670 到 767MB,方舟约 2,862 到 3,123MB。

这组数字决定了首包边界的重要性。包边界越不稳定,整包差量越接近整包体积;方舟的 60 到 90MB 差量对应 1,840MB 总资源,说明它的边界控制得不错,但年度累计仍然是 GB 级。

流量结构:资源下载与业务流量谁更大

消消的 CDN 月下载量约 201TB,方舟约 147TB。用每 DAU 日流量折算业务流量:消消每 DAU 3.6MB,按 80 万 DAU、30 天算,约 82.4TB/月;方舟每 DAU 21MB,按 25 万 DAU、30 天算,约 150.2TB/月。

对比很清楚:消消的资源下载约是业务流量的 2.4 倍,方舟则基本持平。这不是因为消消的资源更多,而是因为它的新增安装多、单局时长短——6.5 分钟的会话里产生不了多少业务流量,但每个新玩家都要下载 120MB。

流量项消消方舟
CDN 月下载约 201TB约 147TB
业务流量(折算)约 82.4TB约 150.2TB
资源 / 业务约 2.4 倍约 1.0 倍
每会话流量3.2MB18MB
每 DAU 日流量3.6MB21MB

这个结构差异会改变优化重点。消消的流量大头在资源分发,首包和分包的体积、CDN 命中率和增量命中率更值得投入;方舟的流量大头在长连接与战斗同步,每会话 18MB、每 DAU 21MB,优化协议头和心跳频率的收益更直接。

失败安装:绝对值比比率更诚实

消消的下载成功率是 97.5%,弱网环境 92%;方舟是 98.6%。单看比率,方舟更好;换算成绝对值,消消每月约 1.05 万次失败安装,方舟约 1,120 次,前者是后者的 9.4 倍。

失败安装的绝对值才是客服工单和差评的来源。一个 2.5% 的失败率放在 42 万新增上,就是每月一万多个玩家在下完首包后卡住;放在 8 万新增上,只有一千多。比率适合做质量指标,绝对值适合做运营指标。

指标消消方舟
下载成功率97.5%98.6%
弱网成功率92%—
月新增42 万8 万
月失败安装约 1.05 万约 1,120
首包体积120MB350MB

弱网数据的缺口也值得说明。消消有明确的弱网成功率 92%,方舟只有整体成功率 98.6%,没有单独的弱网口径。做对照时不能用一个游戏的弱网数字去推另一个游戏,只能承认这一格缺数据。

实现

下面的文件把两款游戏的包体、流量、更新与失败安装放进同一张账。输入是每个游戏的画像,输出是逐项派生指标、合并总量和一张可直接对照的表。

// file: src/lab/package-ledger.ts
// tier: L1
// verify: node --test test/lab/package-ledger.test.ts

export interface GameProfile {
  readonly id: string;
  readonly firstPackageBytes: number;
  readonly subpackageBytes: readonly number[];
  readonly downloadSuccess: number;
  readonly weakNetworkSuccess: number | null;
  readonly monthlyInstalls: number;
  readonly dau: number;
  readonly dailyTrafficMb: number;
  readonly incrementalUpdateBytes: number;
  readonly updatePeriodDays: number;
  readonly storeReleaseDays: number;
  readonly deltaMinBytes: number;
  readonly deltaMaxBytes: number;
  readonly assetFiles: number;
  readonly cdnMonthlyTb: number;
}

export interface GameLedger {
  readonly id: string;
  readonly firstPackageBytes: number;
  readonly subpackageBytes: number;
  readonly totalBytes: number;
  readonly firstSharePct: number;
  readonly averageAssetBytes: number;
  readonly monthlyInstalls: number;
  readonly failedInstallsPerMonth: number;
  readonly installFirstTb: number;
  readonly cdnFirstSharePct: number;
  readonly monthlyBusinessTb: number;
  readonly updatesPerYear: number;
  readonly updateBytesPerYear: number;
  readonly storeReleasesPerYear: number;
  readonly weakNetworkSuccess: number | null;
}

export interface LedgerReport {
  readonly entries: readonly GameLedger[];
  readonly combinedInstalls: number;
  readonly combinedCdnTb: number;
  readonly combinedInstallFirstTb: number;
}

const TB_BYTES = 1024 ** 4;
const MB_BYTES = 1024 * 1024;
const MB_PER_TB = 1024 * 1024;

function round1(value: number): number {
  return Math.round(value * 10) / 10;
}

function round2(value: number): number {
  return Math.round(value * 100) / 100;
}

function validate(profile: GameProfile): void {
  if (!Number.isInteger(profile.firstPackageBytes) || profile.firstPackageBytes <= 0) {
    throw new Error(`invalid first package for ${profile.id}: ${profile.firstPackageBytes}`);
  }
  for (const bytes of profile.subpackageBytes) {
    if (!Number.isInteger(bytes) || bytes < 0) {
      throw new Error(`invalid subpackage for ${profile.id}: ${bytes}`);
    }
  }
  if (!(profile.downloadSuccess > 0 && profile.downloadSuccess <= 1)) {
    throw new Error(`invalid download success for ${profile.id}: ${profile.downloadSuccess}`);
  }
  if (
    profile.weakNetworkSuccess !== null &&
    !(profile.weakNetworkSuccess > 0 && profile.weakNetworkSuccess <= 1)
  ) {
    throw new Error(
      `invalid weak network success for ${profile.id}: ${profile.weakNetworkSuccess}`,
    );
  }
  if (!Number.isInteger(profile.monthlyInstalls) || profile.monthlyInstalls < 0) {
    throw new Error(`invalid installs for ${profile.id}: ${profile.monthlyInstalls}`);
  }
  if (!Number.isInteger(profile.dau) || profile.dau < 0) {
    throw new Error(`invalid dau for ${profile.id}: ${profile.dau}`);
  }
  if (profile.dailyTrafficMb < 0) {
    throw new Error(`invalid daily traffic for ${profile.id}: ${profile.dailyTrafficMb}`);
  }
  if (!Number.isInteger(profile.incrementalUpdateBytes) || profile.incrementalUpdateBytes < 0) {
    throw new Error(`invalid update bytes for ${profile.id}: ${profile.incrementalUpdateBytes}`);
  }
  if (!Number.isInteger(profile.updatePeriodDays) || profile.updatePeriodDays <= 0) {
    throw new Error(`invalid update period for ${profile.id}: ${profile.updatePeriodDays}`);
  }
  if (!Number.isInteger(profile.storeReleaseDays) || profile.storeReleaseDays <= 0) {
    throw new Error(`invalid store release period for ${profile.id}: ${profile.storeReleaseDays}`);
  }
  if (
    !Number.isInteger(profile.deltaMinBytes) ||
    !Number.isInteger(profile.deltaMaxBytes) ||
    profile.deltaMinBytes < 0 ||
    profile.deltaMaxBytes < profile.deltaMinBytes
  ) {
    throw new Error(
      `invalid delta range for ${profile.id}: ${profile.deltaMinBytes}..${profile.deltaMaxBytes}`,
    );
  }
  if (!Number.isInteger(profile.assetFiles) || profile.assetFiles <= 0) {
    throw new Error(`invalid asset files for ${profile.id}: ${profile.assetFiles}`);
  }
  if (!Number.isFinite(profile.cdnMonthlyTb) || profile.cdnMonthlyTb <= 0) {
    throw new Error(`invalid cdn volume for ${profile.id}: ${profile.cdnMonthlyTb}`);
  }
}

function installFirstTb(profile: GameProfile): number {
  return (profile.monthlyInstalls * profile.firstPackageBytes) / TB_BYTES;
}

export function summarizeGame(profile: GameProfile): GameLedger {
  validate(profile);

  const subpackageBytes = profile.subpackageBytes.reduce((sum, bytes) => sum + bytes, 0);
  const totalBytes = profile.firstPackageBytes + subpackageBytes;
  const updatesPerYear = Math.round(365 / profile.updatePeriodDays);
  const firstTb = installFirstTb(profile);

  return {
    id: profile.id,
    firstPackageBytes: profile.firstPackageBytes,
    subpackageBytes,
    totalBytes,
    firstSharePct: round2((profile.firstPackageBytes / totalBytes) * 100),
    averageAssetBytes: Math.round(totalBytes / profile.assetFiles),
    monthlyInstalls: profile.monthlyInstalls,
    failedInstallsPerMonth: Math.round(
      profile.monthlyInstalls * (1 - profile.downloadSuccess),
    ),
    installFirstTb: round2(firstTb),
    cdnFirstSharePct: round2((firstTb / profile.cdnMonthlyTb) * 100),
    monthlyBusinessTb: round2(
      (profile.dau * 30 * profile.dailyTrafficMb) / MB_PER_TB,
    ),
    updatesPerYear,
    updateBytesPerYear: updatesPerYear * profile.incrementalUpdateBytes,
    storeReleasesPerYear: round1(365 / profile.storeReleaseDays),
    weakNetworkSuccess: profile.weakNetworkSuccess,
  };
}

export function compareLedger(profiles: readonly GameProfile[]): LedgerReport {
  if (profiles.length === 0) {
    throw new Error("ledger needs at least one game");
  }

  const seen = new Set<string>();
  const entries: GameLedger[] = [];
  let combinedInstalls = 0;
  let combinedCdnTb = 0;
  let combinedInstallFirstTb = 0;

  for (const profile of profiles) {
    if (seen.has(profile.id)) throw new Error(`duplicate game: ${profile.id}`);
    seen.add(profile.id);
    validate(profile);
    entries.push(summarizeGame(profile));
    combinedInstalls += profile.monthlyInstalls;
    combinedCdnTb += profile.cdnMonthlyTb;
    combinedInstallFirstTb += installFirstTb(profile);
  }

  entries.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));

  return {
    entries,
    combinedInstalls,
    combinedCdnTb: round2(combinedCdnTb),
    combinedInstallFirstTb: round2(combinedInstallFirstTb),
  };
}

function mb(bytes: number): string {
  return (bytes / MB_BYTES).toFixed(2);
}

export function formatLedger(report: LedgerReport): string {
  const lines = [
    "game\tfirstMB\tsubMB\ttotalMB\tfirst%\tinstalls/mo\tfail/mo\tinstallFirstTiB\tcdnShare%\tupdateMB/yr",
  ];
  for (const entry of report.entries) {
    lines.push(
      [
        entry.id,
        mb(entry.firstPackageBytes),
        mb(entry.subpackageBytes),
        mb(entry.totalBytes),
        entry.firstSharePct.toFixed(2),
        entry.monthlyInstalls,
        entry.failedInstallsPerMonth,
        entry.installFirstTb.toFixed(2),
        entry.cdnFirstSharePct.toFixed(1),
        entry.updateBytesPerYear / MB_BYTES,
      ].join("\t"),
    );
  }
  lines.push(
    `combined\t-\t-\t-\t-\t${report.combinedInstalls}\t-\t${report.combinedInstallFirstTb.toFixed(2)}\t-\t-`,
  );
  lines.push(
    `cdn\t${report.combinedCdnTb.toFixed(2)}TB/mo\t${report.entries.length} games\t-\t-\t-\t-\t-\t-\t-`,
  );
  return lines.join("\n");
}

summarizeGame 先校验输入:首包为正、分包非负、成功率在 (0,1]、更新周期为正、差量区间不倒置。任何一项不合法都直接抛错,避免一张看似正常但口径错误的账进入评审。

派生指标分四组。体积组算总资源、首包占比和平均单文件;流量组把月新增乘首包换算成 TiB,再与 CDN 月下载量比较;更新组把周期折成年,算出年热更量与年商店版本数;失败组用新增乘失败率得到绝对值。

compareLedger 负责合并:新增安装相加、CDN 下载相加、首包流量相加,并按 id 排序,保证输出稳定。formatLedger 输出一行一个游戏,最后两行分别是合并新增与合并 CDN。

运行结果与边界

用两款游戏的画像运行,输出如下。体积单位是 MB,流量单位是 TiB:

game	firstMB	subMB	totalMB	first%	installs/mo	fail/mo	installFirstTiB	cdnShare%	updateMB/yr
casual	120.00	360.00	480.00	25.00	420000	10500	48.07	23.9	312
midcore	350.00	1490.00	1840.00	19.02	80000	1120	26.70	18.2	2340
combined	-	-	-	-	500000	-	74.77	-	-
cdn	348.00TB/mo	2 games	-	-	-	-	-	-	-

这张表能直接回答开头的问题:方舟的首包占比更低,但消消的首包月流量更高;方舟的失败率更低,但消消的失败绝对值更高;方舟的年度更新量是消消的 7.5 倍。三个结论分别对应结构、运营和质量三个视角。

第一条边界是口径。CDN 月下载包含热更、重复下载和回源,首包月流量只算新增用户的首次安装,两者相除得到的是一个量级参考,不是成本核算。表里的 23.9% 与 18.2% 只能用来比较量级,不能用来报销。

第二条边界是时间。年热更量按当前周期线性外推,实际节奏会随版本计划变化;方舟每周 45MB 是平均值,赛季版本可能明显高于它。线性外推适合做预算,不适合做承诺。

第三条边界是缺失数据。方舟没有单独的弱网成功率,表里用「—」而不是补一个估计值。对照表最危险的错误不是算错,而是把缺失的数据用另一款游戏的数字填上。

与相邻章节的关系

9.1 回答了首包里该放什么,9.2 回答了首包之外什么时候下,本节把两款游戏的账放在一起,回答「这些决定合起来贵不贵」。8.3 的包分层与 8.1 的依赖图是这张账的结构基础。

本节不讨论 CDN 调度与回源策略,那属于 10.x 和 11.x;也不讨论商店政策对体积的限制,那属于 10.1。账本只回答「体积、流量、更新和失败各是多少」,不回答「应该改成多少」。

小结

  • 首包占比只描述结构,不描述代价;同样的占比,42 万新增和 8 万新增的成本完全不同。
  • 消消首包 120MB、月流量约 48.07TiB,方舟 350MB、约 26.70TiB;体积小不代表总流量小。
  • 方舟的资源更碎:86,000 个文件、平均约 21.9KB,依赖深度 9,校验与索引成本更高。
  • 年度热更量消消约 312MB,方舟约 2,340MB;方舟一年的热更量超过自身总资源。
  • 失败率要换算成绝对值:消消约 1.05 万次/月,方舟约 1,120 次/月,前者是后者的 9.4 倍。
  • 对照表必须标注缺失数据,不能用一个游戏的弱网数字去填另一个游戏的空。

阅读导航:上一节:9.2 分包与按需下载的调度 · 下一节:10.1 商店允许更新什么 。

常见错误与边界

  1. 只用首包占比比较克制程度。占比会被总资源稀释,玩家下载的是绝对体积。
  2. 忽略新增安装的分母。同样 10MB 增量,42 万新增和 8 万新增的成本差 5.25 倍。
  3. 把 CDN 下载等同于首包流量。CDN 还包含热更、重复下载和回源,相除只能看量级。
  4. 按字节定预算,不看文件数。86,000 个文件的校验成本明显高于 12,800 个。
  5. 用线性外推承诺更新量。赛季版本的 45MB 平均值会被高峰拉高,预算可以外推,承诺不行。
  6. 拿一个游戏的弱网数字填另一个。方舟没有单独的弱网口径,缺失就是缺失。
  7. 只看失败率不看绝对值。2.5% 放在 42 万新增上是一万多玩家,放在 8 万上只有两千。
  8. 把年度热更量和总资源分开看。方舟一年热更 2,340MB,已经超过 1,840MB 总资源。
  9. 忽略业务流量的结构差异。消消资源流量约是业务的 2.4 倍,方舟基本持平,优化重点不同。
  10. 边界:账本不做成本核算。它给的是体积与流量的量级,不是 CDN 账单。
  11. 边界:账本不提供改造建议。它只回答现状是多少,改多少属于 9.1 与 9.2 的决策。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《移动游戏产品的架构·商业化与发行工程》附录 C 商业化与发行评审清单
  2. 《移动游戏产品的架构·商业化与发行工程》附录 B 双案例商业化事实表
  3. 《移动游戏产品的架构·商业化与发行工程》附录 A 商业化与发行术语速查