《移动游戏产品的架构·客户端与移动运行时》1.2 移动端约束清单:内存、电量、发热、网络、机型

方舟立项会上有人问「消消首包 120MB,方舟为什么不能也压到 120MB」。本节把内存、帧预算、包体、机型、联网与稳定性写成统一整数单位的可计算清单,给出双案例差异,说明电量与发热为什么只能用代理建模、不能在本机冒充设备测量,并把清单挂到四个固定检查点上。

一次「把首包压到 120MB」的争论

《潮汐方舟》立项后的第一次跨部门评审,发行同学问了一个很直接的问题:消消首包 120MB,方舟为什么不能也压到 120MB?

客户端的回答是「资源不一样」。这个回答没有错,但无法支撑决策:资源到底差在哪、压到 120MB 会牺牲什么、哪些机型会先出问题,都没有数字。会议纪要最后只留下一句「方舟首包暂定 350MB」,下个版本又原样讨论了一遍。

第二次评审,团队换了一种方式:不再争一个孤立的包体数字,而是把移动端约束列成清单,每一行都带单位、基线和耦合关系。清单第一次摆上桌,争论立刻从「能不能压」变成了「压哪一项、换什么代价」:

  • 方舟总资源 1.8GB,首包 350MB;消消总资源 480MB,首包 120MB。首包只是首次下载的一部分,后续还有资源要下。
  • 方舟低端机占比 15%,消消 35%。低端机越多,降级策略越接近主路径,首包和内存的策略也越保守。
  • 方舟默认 60fps,消消默认 30fps。帧预算一个 16.7ms、一个 33.3ms,同样的加载逻辑在方舟更容易造成卡顿。

结论是:首包不是孤立指标,它是内存、帧率、机型覆盖和联网形态共同作用的结果。要讨论它,先要有一张所有约束都能对齐单位的清单。

五类约束,先把单位统一

移动端约束经常被写成形容词:「内存要省」「发热要低」「弱网要稳」。形容词无法比较,也无法验收。下表把五类约束换成可计算口径,并给出双案例的案例设定值。

约束可计算口径潮汐消消潮汐方舟主要耦合
内存稳态内存 / 内存水位420MB / 520MB1,150MB / 1,450MB水位触发降级,余量决定缓冲区大小
帧率与帧预算每帧可用微秒33,333us(30fps)16,666us(60fps)帧预算挤压主线程任务切片
包体与资源首包 / 总资源120MB / 480MB350MB / 1.8GB首包决定首启等待,总量决定后续下载
机型覆盖4GB RAM 以下占比35%15%低端占比决定降级是不是主路径
联网形态弱联网 / 强联网弱联网强联网战斗 + 异步 PvP断线恢复影响状态保存粒度
稳定性结果崩溃率 / ANR 率 / Jank 率0.18% / 0.05% / 2.1%0.12% / 0.03% / 1.4%前五项挤占后的结果指标

这张表里最容易被忽略的是最后一列。内存不是独立预算:水位定得低,降级会更频繁,Jank 率可能上升;首包压得小,更多资源要放到运行期下载,内存峰值和弱网失败率都会变化;帧率目标定得高,单帧预算减半,同样的同步加载更容易超时。清单的价值不是列出五个数字,而是让这些耦合在评审时同时出现。

电量和发热需要单独说明。 本节的案例事实表没有给出毫瓦时或表面温度数值,因此这里不编造任何一个。电量与发热在客户端架构里通常通过两个代理观察:持续帧率是否稳定,以及单位时间内的主线程忙等比例。它们是代理,不是设备测量结果;真正的温度与功耗验收需要真机实验,放在第 9 章讨论。任何把代理值写成「设备功耗」的报告,都应该被退回。

为什么必须换成整数单位

性能预算最容易出错的地方不是算错,而是四舍五入之后再引用。

30fps 的帧预算写成 33.3ms,三帧就是 99.9ms,看起来还没到 100ms;但用微秒表示是 33,333us,三帧是 99,999us,仍然小于 100,000us。差别只在最后一位,却会让「是否超过 100ms」的判断在边界上摇摆。同理,0.18% 的崩溃率如果先写成 0.2%,再换算成每十万会话就是 200,而真实值是 180。案例圣经要求成本、内存、流量一律用整数单位,原因就在这里。

本节的约定是:

  • 时间用微秒,frameBudgetMicros(60) = 16_666;
  • 内存用 MB 整数,余量直接用「水位减稳态」;
  • 比例先换成基点,0.18% = 18bp,2.1% = 210bp;
  • 每千帧、每十万会话这类密度指标由基点直接换算,不再经过小数。

这样做还有一个工程上的好处:报告、告警阈值和测试断言可以用同一套整数,不需要在每层各写一个格式化函数。

双案例的约束差异

把上面的口径套进两款产品,得到一张可以直接贴进评审材料的差异表。

计算项潮汐消消潮汐方舟差异含义
每帧预算33,333us16,666us方舟单帧可用时间约为消消的一半
内存余量100MB300MB方舟水位更高,但稳态到水位的缓冲更大
总资源 / 首包4,000‰5,142‰方舟运行期还要下载的比例更高
Jank(每千帧)2114方舟 Jank 率更低,但帧预算更紧
崩溃(每十万会话)180120消消低端机更多,稳定性压力更大
ANR(每十万会话)5030同上,低端机的输入与主线程更敏感
低端机占比35%15%降级策略在消消是主路径,在方舟是保底

几个结论只有在整数口径下才站得住。

第一,方舟的内存余量是 300MB,比消消的 100MB 更大,但它的稳态内存是消消的约 2.7 倍。余量大不等于宽松,只说明水位和稳态之间的距离更大,能容纳的瞬时峰值更高;一旦峰值超过 1,450MB,降级触发得会更突然。

第二,方舟的总资源与首包之比是 5,142‰,也就是后续还要下载约四倍于首包的资源;消消是 4,000‰。这解释了为什么方舟不能简单照搬消消的「首包尽量小、其余后台下」策略:它的后续下载量更大,弱网和存储不足的场景也更多。

第三,消消的 Jank 密度是每千帧 21 次,方舟是 14 次;但方舟的帧预算只有消消的一半。同样的 21 次长帧如果发生在 60fps 下,体感会比 30fps 更明显。所以不能只看 Jank 率高低,还要把它放回帧预算里读。

第四,低端机占比 35% 与 15% 的差距,会改变「降级」在产品里的位置。消消必须假设每三个用户就有一个在 4GB 以下设备上玩,降级是主路径;方舟可以把降级当保底,但一旦触发,用户对画质骤降的感知会更强。低端机降级的具体策略可以参考低端机降级 ,机型矩阵的验证方法可以参考机型兼容矩阵测试 。

清单怎么用:四个检查点

约束清单如果只在立项时出现一次,就会退化成一份归档文档。可用的做法是把它挂到四个固定检查点上。

  1. 设计评审:先写目标机型、目标帧率和首包上限,再讨论功能。顺序反过来,功能会先占满预算,最后只能砍体验。
  2. 资源预算:首包、总资源、内存余量三项一起审。只审首包,团队会把资源挪到运行期下载;只审总资源,又会忽略首启等待。
  3. 构建与灰度:用同一套整数口径生成报告,避免客户端报微秒、发行报毫秒、运营报百分比。
  4. 线上回归:崩溃、ANR、Jank 分别用每十万会话和每千帧表示,和基线表逐项对齐;不要用「明显变好」这类描述。

这四个检查点不涉及服务端发布流程,也不替代真机测试。它们的作用是让同一张表在四个阶段被反复使用,而不是每个阶段重新发明一套指标。

可运行片段

下面的文件把清单变成可执行函数:帧预算、内存余量、资源比、Jank 密度、崩溃与 ANR 密度,以及两款产品的差异比较。所有返回值都是整数。

// file: src/core/constraints.ts
// tier: L1
// verify: node --test test/core/constraints.test.ts

export interface ConstraintProfile {
  readonly name: string;
  readonly steadyMemoryMb: number;
  readonly memoryWatermarkMb: number;
  readonly targetFps: number;
  readonly firstPackageMb: number;
  readonly totalResourceMb: number;
  readonly lowEndSharePercent: number;
  readonly weakNetwork: boolean;
  readonly crashRateBp: number;
  readonly anrRateBp: number;
  readonly jankRateBp: number;
}

export const MATCH3_BUDGET: ConstraintProfile = {
  name: "潮汐消消",
  steadyMemoryMb: 420,
  memoryWatermarkMb: 520,
  targetFps: 30,
  firstPackageMb: 120,
  totalResourceMb: 480,
  lowEndSharePercent: 35,
  weakNetwork: true,
  crashRateBp: 18,
  anrRateBp: 5,
  jankRateBp: 210,
};

export const ARK_BUDGET: ConstraintProfile = {
  name: "潮汐方舟",
  steadyMemoryMb: 1150,
  memoryWatermarkMb: 1450,
  targetFps: 60,
  firstPackageMb: 350,
  totalResourceMb: 1800,
  lowEndSharePercent: 15,
  weakNetwork: false,
  crashRateBp: 12,
  anrRateBp: 3,
  jankRateBp: 140,
};

export interface ConstraintLine {
  readonly key: string;
  readonly value: number | boolean;
  readonly unit: string;
}

export function frameBudgetMicros(fps: number): number {
  if (!Number.isInteger(fps) || fps <= 0) {
    throw new RangeError("fps must be a positive integer");
  }
  return Math.floor(1_000_000 / fps);
}

export function memoryHeadroomMb(profile: ConstraintProfile): number {
  return profile.memoryWatermarkMb - profile.steadyMemoryMb;
}

export function resourceToFirstPermille(profile: ConstraintProfile): number {
  return Math.floor((profile.totalResourceMb * 1_000) / profile.firstPackageMb);
}

export function jankFramesPerThousand(profile: ConstraintProfile): number {
  return profile.jankRateBp / 10;
}

export function crashSessionsPerHundredThousand(profile: ConstraintProfile): number {
  return profile.crashRateBp * 10;
}

export function anrSessionsPerHundredThousand(profile: ConstraintProfile): number {
  return profile.anrRateBp * 10;
}

export function constraintLines(profile: ConstraintProfile): readonly ConstraintLine[] {
  return [
    { key: "frameBudgetMicros", value: frameBudgetMicros(profile.targetFps), unit: "us" },
    { key: "memoryWatermarkMb", value: profile.memoryWatermarkMb, unit: "MB" },
    { key: "memoryHeadroomMb", value: memoryHeadroomMb(profile), unit: "MB" },
    { key: "firstPackageMb", value: profile.firstPackageMb, unit: "MB" },
    { key: "resourceToFirstPermille", value: resourceToFirstPermille(profile), unit: "permille" },
    { key: "lowEndSharePercent", value: profile.lowEndSharePercent, unit: "%" },
    { key: "jankFramesPerThousand", value: jankFramesPerThousand(profile), unit: "frames/1000" },
    {
      key: "crashSessionsPerHundredThousand",
      value: crashSessionsPerHundredThousand(profile),
      unit: "sessions/100000",
    },
    {
      key: "anrSessionsPerHundredThousand",
      value: anrSessionsPerHundredThousand(profile),
      unit: "sessions/100000",
    },
  ];
}

export interface ConstraintDelta {
  readonly key: string;
  readonly base: number | boolean;
  readonly next: number | boolean;
  readonly higher: "base" | "next" | "equal";
}

function rank(value: number | boolean): number {
  return typeof value === "boolean" ? Number(value) : value;
}

export function compareConstraints(
  base: ConstraintProfile,
  next: ConstraintProfile,
): readonly ConstraintDelta[] {
  const baseLines = new Map(constraintLines(base).map((line) => [line.key, line.value]));
  return constraintLines(next).map((line) => {
    const baseValue = baseLines.get(line.key);
    if (baseValue === undefined) {
      throw new Error(`missing constraint line: ${line.key}`);
    }
    const difference = rank(line.value) - rank(baseValue);
    const higher = difference > 0 ? "next" : difference < 0 ? "base" : "equal";
    return { key: line.key, base: baseValue, next: line.value, higher };
  });
}

几个设计选择值得说明。

frameBudgetMicros 拒绝非正整数帧率,包括 59.94 这类小数。移动游戏的目标帧率通常写成 30、60、90、120,接受小数只会让预算表出现无法复现的尾数;如果确实要支持可变刷新率,应该在调用方先取整并记录原始值。

resourceToFirstPermille 用千分比而不是百分比,是为了保留 4,000‰ 与 5,142‰ 这种超过 100% 的比例。用百分比会得到 400% 和 514%,虽然也对,但和「首包占比」的直觉冲突,容易在报告里被误读。

compareConstraints 返回 higher: "base" | "next" | "equal",只描述哪一侧数值更高,不判断「更好」。内存余量更高通常是好事,崩溃率更高通常是坏事,方向判断留给调用方;把一个通用的比较函数写成「越大越好」,迟早会在崩溃率上出错。

测试把这些口径钉住

// file: test/core/constraints.test.ts
// tier: L1
// verify: node --test test/core/constraints.test.ts

import { test } from "node:test";
import assert from "node:assert/strict";

import {
  ARK_BUDGET,
  MATCH3_BUDGET,
  anrSessionsPerHundredThousand,
  compareConstraints,
  constraintLines,
  crashSessionsPerHundredThousand,
  frameBudgetMicros,
  jankFramesPerThousand,
  memoryHeadroomMb,
  resourceToFirstPermille,
} from "../../src/core/constraints.ts";

test("frame budget stays in integer microseconds", () => {
  assert.equal(frameBudgetMicros(30), 33_333);
  assert.equal(frameBudgetMicros(60), 16_666);
  assert.equal(frameBudgetMicros(120), 8_333);
  assert.throws(() => frameBudgetMicros(0), RangeError);
  assert.throws(() => frameBudgetMicros(59.94), RangeError);
});

test("memory headroom follows the watermark minus steady state", () => {
  assert.equal(memoryHeadroomMb(MATCH3_BUDGET), 100);
  assert.equal(memoryHeadroomMb(ARK_BUDGET), 300);
});

test("resource ratios and rate conversions use integers", () => {
  assert.equal(resourceToFirstPermille(MATCH3_BUDGET), 4_000);
  assert.equal(resourceToFirstPermille(ARK_BUDGET), 5_142);

  assert.equal(jankFramesPerThousand(MATCH3_BUDGET), 21);
  assert.equal(jankFramesPerThousand(ARK_BUDGET), 14);
  assert.equal(crashSessionsPerHundredThousand(MATCH3_BUDGET), 180);
  assert.equal(crashSessionsPerHundredThousand(ARK_BUDGET), 120);
  assert.equal(anrSessionsPerHundredThousand(MATCH3_BUDGET), 50);
  assert.equal(anrSessionsPerHundredThousand(ARK_BUDGET), 30);
});

test("the constraint list has one line per checked dimension", () => {
  const keys = constraintLines(MATCH3_BUDGET).map((line) => line.key);
  assert.deepEqual(keys, [
    "frameBudgetMicros",
    "memoryWatermarkMb",
    "memoryHeadroomMb",
    "firstPackageMb",
    "resourceToFirstPermille",
    "lowEndSharePercent",
    "jankFramesPerThousand",
    "crashSessionsPerHundredThousand",
    "anrSessionsPerHundredThousand",
  ]);
});

test("comparison reports which product sits higher", () => {
  const deltas = compareConstraints(MATCH3_BUDGET, ARK_BUDGET);
  const byKey = new Map(deltas.map((delta) => [delta.key, delta]));

  assert.equal(byKey.get("memoryHeadroomMb")?.higher, "next");
  assert.equal(byKey.get("lowEndSharePercent")?.higher, "base");
  assert.equal(byKey.get("frameBudgetMicros")?.higher, "base");
  assert.equal(byKey.get("firstPackageMb")?.higher, "next");
  assert.equal(byKey.get("memoryWatermarkMb")?.higher, "next");
});

测试里的关键断言不是「函数能跑」,而是几个容易算错的边界:30fps 必须是 33,333us 而不是 33,300us;1,800MB 除以 350MB 必须向下取整为 5,142‰;2.1% 必须换算成每千帧 21 次,而不是 2.1 次。

最后一条比较断言也有意覆盖了「方向相反」的情况:内存余量是方舟更高,低端机占比是消消更高。如果比较函数偷偷把某个字段当成「越大越好」,这两条断言会同时失败,问题会在测试阶段暴露,而不是在评审会上被产品经理发现。

与相邻章节的关系

本节只给出约束的口径,不展开每一项的优化手段。帧率分级、动态分辨率和每帧预算的切分在第 9 章;内存水位、资源句柄与泄漏定位在第 6 章;机型分档在第 8 章;弱网缓存与离线优先可以参考离线优先缓存 ,但那套 Web 缓存策略不能直接搬到强联网战斗,本节只借用它「先定义失败语义,再谈缓存」的思路。

电量与发热的验收方法会在第 9 章单独讨论,并且必须配合真机实验。本节坚持不给出任何未经测量的功耗数字,是为了避免把代理指标包装成事实。

小结

  • 移动端约束要写成带单位、带基线、带耦合关系的清单,而不是形容词。
  • 时间用微秒、内存用 MB、比例用基点,能避免四舍五入在边界上改变结论。
  • 双案例的差异不只在数值大小,还在余量、比例和低端机权重;同一张表要放回各自的帧预算里读。
  • 电量与发热只能用代理建模,不能在本机冒充设备测量;真正的验收在第 9 章。

阅读导航:上一节:1.1 一个 IP 两款产品:为什么客户端架构不能按品类复制 · 下一节:1.3 mga-lab 的分层骨架与验收口径 。

常见错误与边界

  • 用平均帧率代替帧预算。平均 60fps 可能由大量 8ms 帧和少量 40ms 帧组成,后者才是玩家感知到的卡顿。
  • 先把百分比四舍五入再换算。0.18% 写成 0.2%,每十万会话就从 180 变成 200;报告一旦这样写,基线就无法追溯。
  • 只审首包,不审总资源。首包压小、运行期下载变多,弱网和存储不足的失败率会上升,首启指标好看但整体体验变差。
  • 用高端机数据推断低端机。低端机占比 35% 的产品,必须把 4GB 以下设备当成主路径来设计,而不是上线后再补降级。
  • 把电量写成一个没有测量方法的形容词。没有设备、场景、时长和采样口径的「功耗优化 30%」不是结论,只是口号。
  • 把清单当验收通过线。清单是决策输入,阈值需要产品、发行和客户端共同确认;本节给出的是口径,不是标准答案。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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