《移动游戏产品的架构·客户端与移动运行时》13.2 性能预算与机型分档基线

性能预算不是一句「要流畅」,而是帧预算、内存水位、启动时间与机型分档四组可校验的数字。本节把双案例的基线写成可运行校验:水位必须留出 20% 余量,30fps 与 60fps 的 Jank 率不能直接横比,低端机预算按 RAM 分档计算,并给出跨产品比较前的判断顺序。

两份都「达标」的性能报告

季度评审时,消消和方舟各交了一份性能报告。消消写着「Jank 率 2.1%,达标」;方舟写着「Jank 率 1.4%,优于消消」。两份报告都附了截图,也都由性能组签字。

问题出在第三页:消消跑在 30fps,方舟跑在 60fps。同样数量的长帧,在 60fps 下要除以约两倍的总帧数,算出来的 Jank 率天然更低。1.4% 并不比 2.1% 好,它只是分母更大。

这就是性能预算最容易失效的地方:数字都在,口径不对。本节把基线整理成四组可校验的量——帧预算、内存水位、启动时间、机型分档——并给出一个能自动检查口径的脚本。

预算的四个层次

层次单位由什么决定超标的直接后果
帧预算毫秒/帧目标帧率掉帧、操作延迟
内存水位MB机型分档与系统余量被系统杀进程
启动预算秒首包体积与初始化链路首启流失
机型分档RAM 区间低端机占比低端机大面积不可玩

四层之间存在硬约束:帧率越高,单帧预算越小;内存水位越接近系统上限,降级空间越小;首包越大,启动预算越难守住。不能四项同时取最优,这正是基线要写清楚取舍的原因。

把口径写成校验

下面的脚本不测性能,它只做一件事:检查一份基线表的口径是否自洽。包括帧预算换算、水位余量、Jank 率范围,以及跨产品比较是否成立。

// file: src/resource/perf-baseline.ts
// tier: L1
// verify: node --test test/resource/perf-baseline.test.ts

import { TIER_RULES } from "./memory-budget.ts";
import type { DeviceTier } from "./memory-budget.ts";

export type ProductId = "match3" | "ark";

export interface ProductBaseline {
  readonly product: ProductId;
  readonly targetFps: 30 | 60;
  readonly steadyLowEndMb: number;
  readonly alarmMb: number;
  readonly jankRatePercent: number;
  readonly lowEndSharePercent: number;
}

export const BASELINES: readonly ProductBaseline[] = [
  {
    product: "match3",
    targetFps: 30,
    steadyLowEndMb: 420,
    alarmMb: 520,
    jankRatePercent: 2.1,
    lowEndSharePercent: 35,
  },
  {
    product: "ark",
    targetFps: 60,
    steadyLowEndMb: 1150,
    alarmMb: 1450,
    jankRatePercent: 1.4,
    lowEndSharePercent: 15,
  },
];

export function frameBudgetMs(fps: number): number {
  return Math.round((1000 / fps) * 10) / 10;
}

export function validateBaseline(baseline: ProductBaseline): readonly string[] {
  const problems: string[] = [];
  if (baseline.targetFps !== 30 && baseline.targetFps !== 60) {
    problems.push(`${baseline.product}: target fps must be 30 or 60`);
  }
  if (baseline.alarmMb <= baseline.steadyLowEndMb) {
    problems.push(`${baseline.product}: alarm must be above steady memory`);
  }
  if (baseline.alarmMb < baseline.steadyLowEndMb * 1.2) {
    problems.push(`${baseline.product}: alarm headroom below 20 percent`);
  }
  if (baseline.jankRatePercent < 0 || baseline.jankRatePercent > 100) {
    problems.push(`${baseline.product}: jank rate out of range`);
  }
  if (baseline.lowEndSharePercent < 0 || baseline.lowEndSharePercent > 100) {
    problems.push(`${baseline.product}: low-end share out of range`);
  }
  return problems;
}

export interface Comparison {
  readonly comparableJank: boolean;
  readonly budgetRatio: number;
  readonly note: string;
}

export function compareJank(a: ProductBaseline, b: ProductBaseline): Comparison {
  const comparableJank = a.targetFps === b.targetFps;
  const budgetRatio = Math.round((b.targetFps / a.targetFps) * 100) / 100;
  const note = comparableJank
    ? "same target fps: jank rates share the same denominator rule"
    : "different target fps: long frames are counted over different totals, do not compare directly";
  return { comparableJank, budgetRatio, note };
}

export function deviceBudgetMb(deviceRamMb: number, tier: DeviceTier): number {
  const rule = TIER_RULES.find((item) => item.tier === tier);
  if (!rule) {
    throw new Error(`unknown device tier: ${tier}`);
  }
  return Math.floor((deviceRamMb * rule.budgetPercent) / 100);
}

export function headroomAgainstTier(
  baseline: ProductBaseline,
  deviceRamMb: number,
  tier: DeviceTier,
): number {
  return deviceBudgetMb(deviceRamMb, tier) - baseline.steadyLowEndMb;
}

export function formatBaselineTable(baselines: readonly ProductBaseline[]): string {
  const header = "product\tfps\tbudget_ms\tsteady_mb\talarm_mb\tjank_percent";
  const rows = baselines.map(
    (item) =>
      `${item.product}\t${item.targetFps}\t${frameBudgetMs(item.targetFps)}\t${
        item.steadyLowEndMb
      }\t${item.alarmMb}\t${item.jankRatePercent}`,
  );
  return [header, ...rows].join("\n");
}

validateBaseline 的四条规则对应四种真实事故:帧率不在 30/60 上,说明预算没有对齐目标设备;水位等于或低于稳态内存,说明告警永远不会触发;水位余量不足 20%,说明任何一次场景切换都可能越线;比例越界,说明统计口径写错了。

compareJank 是专门为「方舟优于消消」这类误读准备的。它会直接返回 comparableJank: false,并给出 budgetRatio: 2——方舟的单帧预算只有消消的一半,两者的长帧计数不能放在同一张表里比大小。

双案例基线

把两份基线放进同一张表,结构差异一目了然:

指标消消方舟读法
目标帧率30fps60fps单帧预算 33.3ms vs 16.7ms
低端机稳态内存420MB1,150MB方舟是消消的 2.7 倍
水位告警520MB1,450MB余量分别为 24% 与 26%
Jank 率2.1%1.4%分母不同,不可直接横比
低端机占比35%15%消消的低端机风险面更大

再看机型分档。按 4GB 设备、低端档 35% 的预算比例计算,可用预算约 1,433MB:

  • 消消稳态 420MB,余量 1,013MB,属于宽松区间;
  • 方舟稳态 1,150MB,余量 283MB,已经接近低端机上沿。

案例设定里方舟低端机只占 15%,这个余量勉强可接受;如果方舟的低端机占比涨到消消的水平,1,150MB 的稳态内存会在 4GB 设备上大面积触发强降。同一个 1,150MB,在 15% 占比下是基线,在 35% 占比下是事故。

消消的约束方向则相反:它的内存宽松,但 30fps 的帧预算只有 33.3ms,且低端机占比高达 35%。它的性能工作重点不在省内存,而在保证低端机的帧预算不被活动特效吃穿。

基线怎么用

基线有三类用法,顺序不能反:

  1. 验收:每个版本按 validateBaseline 跑一遍,任何一条规则失败就不发版。
  2. 比较:只在 comparableJank 为真时比较 Jank 率;否则比较「长帧数 ÷ 分钟」这类同分母指标。
  3. 预算分配:新功能立项时先领预算——占多少毫秒、多少 MB,再谈实现方案。没有预算的功能默认不做。

第三类最容易被跳过。团队常见的做法是先做完功能,再问「性能有没有问题」;这时预算已经花超,只能靠砍功能或降画质补。基线的作用是让「砍」发生在立项时,而不是上线前。

启动预算与包体的联动

启动不属于「每帧」预算,但它和包体、内存共用同一张基线表。原因是三者互相挤压:

阶段消消方舟被谁挤压
冷启动 P501.8s3.5s首包体积、初始化链路
冷启动 P953.2s6.8s低端机 I/O、系统调度
首帧时间(低端机 P95)2.6s5.4s资源加载与首屏绘制
首包120MB350MB资源拆分策略

方舟的冷启动 P95 是消消的两倍多,直接原因是首包 350MB 对 120MB。把首包压到 200MB 可能让启动变快,但会把压力推给首次下载 —— 这是第 6 章与卷二资源章节共同处理的取舍,本节只把它记进基线表,避免启动、内存、包体三张表各说各话。

一个实用规则:冷启动 P95 超过 5 秒的产品,必须把「首帧已上屏」和「可交互」分开验收。 前者决定玩家会不会等,后者决定玩家能不能玩;两者混成一个指标,优化方向会完全错。

三行输出,一次评审

formatBaselineTable 的输出只有三行:

product  fps  budget_ms  steady_mb  alarm_mb  jank_percent
match3   30   33.3       420        520       2.1
ark      60   16.7       1150       1450      1.4

评审时按列读,不要按行读:先看 fps 是否与目标一致,再看 alarm_mb - steady_mb 的余量是否超过 20%,最后确认 jank_percent 是否在同帧率下比较。三行表放进版本周报,比十页性能报告更容易发现回归。

基线表怎么维护

基线不是发布时填一次的表,它随版本滚动:

  1. 每个大版本重跑一次,把新数值写进表,同时保留上一版,用来看趋势。
  2. 机型分布每季度更新一次,低端机占比变化会直接改变风险面;方舟低端机从 15% 涨到 25%,1,150MB 的结论就要重算。
  3. 任何一项变差都要写原因,不能只更新数字。原因可以是「新增活动特效」「资源包变大」「系统升级」,没有原因的变差通常意味着测量口径也变了。
  4. 跨产品比较前先对齐口径,尤其是帧率、分母与机型档位;对不齐就只做纵向比较,不做横向排名。

与相邻章节的关系

本节依赖第 6 章的内存水位、第 8 章的机型分档与第 9 章的帧预算。那三章分别给出单点规则,本节把它们合并成一张跨产品可复核的基线表。

上一节 13.1 回答「模块结构对不对」,本节回答「数值够不够」,下一节 13.3 把两者与评审清单合并成进入卷二的门禁。

小结

  • 性能预算有四层:帧预算、内存水位、启动预算、机型分档,四者不能同时取最优。
  • 水位必须高于稳态内存且留出至少 20% 余量,否则告警形同虚设。
  • 30fps 与 60fps 的 Jank 率分母不同,跨产品比较前必须先判断 comparableJank。
  • 低端机预算按 RAM 分档计算,方舟在 4GB 设备上的余量只有 283MB,远小于消消的 1,013MB。
  • 基线最重要的用法是立项时分配预算,而不是上线前验收。
  • 基线表随版本滚动,每个大版本重跑,任何一项变差都要写原因。
  • 启动、内存、包体共处一张表,避免三张表各说各话。

阅读导航:上一节:13.1 模块矩阵与依赖报告 · 下一节:13.3 卷一评审与进入卷二 。

常见错误与边界

  1. 用平均帧率代替帧预算。 平均 55fps 可能由大量 60fps 和少量 20fps 拼成;预算看的是每一帧是否超时,不是平均值。
  2. 把水位设成系统上限。 水位是产品可接受的上限,必须给系统和其它应用留余量;贴着系统杀进程线设置,等于把崩溃交给操作系统决定。
  3. 跨帧率比较 Jank 率。 这是本节反复强调的错误:分母不同,结论相反。要么对齐帧率,要么改用同分母指标。
  4. 只按机型平均值分档。 低端机内部也有差异;按 RAM 区间分档后,还要看同一档内的 P95,而不是只看均值。
  5. 新功能不领预算。 立项时不写「占多少毫秒、多少 MB」,实现阶段就会无限扩张,最后只能靠全局降质兜底。
  6. 把模型值当实测值。 本节所有数字都是案例设定,用于演练口径;真机上的内存与帧率必须自己测,且要区分设备型号与系统版本。
  7. 只校验单个产品。 基线表的另一半价值在跨产品比较;只跑 validateBaseline 而不跑 compareJank,会漏掉口径不一致的问题。
  8. 把低端机预算当成固定值。 预算比例随 RAM 档位变化,4GB、6GB、8GB 设备的可用内存不同;用同一个数字套所有机型,低端机会被高估。
  9. 忽略系统版本差异。 同一机型升级系统后,内存回收与调度策略都会变;基线表应记录系统版本区间,否则趋势会失真。
  10. 只看一次快照。 单次报告只能说明当时状态;没有历史曲线,就无法区分「一直如此」和「本版本刚变差」。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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