《移动游戏产品的架构·客户端与移动运行时》8.2 机型分档与画质策略配置化

机型分档不是一张机型名单,而是 RAM、GPU、刷新率与 OS 版本的评分,加上不可被高分覆盖的硬门槛。本节把分档、画质预设、灰度桶、护栏回滚与 35% 低端机占比放进同一套可校验策略,并给出可回滚的版本注册表、可运行模型与真实设备验收边界。

事故:一次活动配置绕过了分档

《潮汐消消》的家园装饰活动曾经把「高清粒子」预设直接写进活动配置,只按 RAM 是否大于 4GB 做判断。上线后,一批刚好 4GB、GPU 档位较低但系统版本较新的设备被分进高档:稳态内存从案例设定的 420MB 逼近 520MB 水位,Jank 率随之抬头。回滚活动配置后问题消失,但根因没有消失——分档逻辑散落在业务 if 里,任何新入口都可以绕开它。

第二次事故更隐蔽:系统升级后 OS 大版本变化,客户端缓存的档位没有失效,同一台设备继续使用旧预设。两次事故指向同一个结论:分档必须是统一输入,画质必须是可校验配置,变更必须是可灰度、可回滚的流程。

分档不是机型名单

机型名单的维护成本随长尾增长,而且新机永远比名单快。可维护的做法是四维评分加硬门槛。

维度分档规则(案例设定)分值说明
RAM<4096MB / 4096–6143MB / >=6144MB0 / 1 / 2低于 3072MB 直接判低档
GPU 档位0 / 1–2 / 30 / 1 / 2由平台适配层给出,不由业务猜
屏幕刷新率<90Hz / 90–119Hz / >=120Hz0 / 1 / 2高刷设备通常有更强的持续性能
OS 版本Android <9、iOS <14硬门槛不参与加分,直接判低档并记录原因

总分 0–6:<=2 为低档,3–4 为中档,>=5 为高档。硬门槛优先于总分:系统不受支持或 RAM 低于 3072MB 时,即使 GPU 与刷新率都是高分,也判低档。反过来,不允许业务用「这台机器跑分很高」覆盖硬门槛。

未知值必须保守处理。GPU 档位读不到时按 0 分计并记录 unknown,不能默认成高档;刷新率读不到时按 60Hz 计。分档结果要带原因列表,否则线上只能看到「他被分到低档」,无法判断是硬件、系统还是读取失败。

策略表:预设、映射与版本

分档只回答「这台设备属于哪一档」,画质由策略表决定。策略表包含三部分:预设、档位到预设的映射、版本号。

预设帧率渲染比例粒子等级阴影纹理等级
low-3030fps0.750关0
mid-3030fps1.01关1
high-6060fps1.02开2

表中的数值是 mga-lab 的比较参数,不是消消的线上配置。案例设定只提供默认 30fps、高档 60fps 与低端机 35% 这三项事实;具体渲染比例要由项目自己的性能预算决定。

策略表必须可校验:每个档位都要有映射,映射必须指向存在的预设,帧率只能是 30 或 60,渲染比例必须在 [0.5, 1],灰度桶的百分比必须在 [0, 100]。校验失败时拒绝整份配置,而不是跳过坏字段继续加载——半份配置比没有配置更难排查。

可配置、可灰度、可回滚

灰度用确定性分桶:把设备 id 与灰度盐做哈希,取模 100,落在百分比内即命中。确定性保证同一台设备每次启动都进入同一组,不会因为重开游戏在两组预设之间跳变。灰度还必须限定目标档位,避免把低档预设误推给高档设备。

变更类型判据动作
配置非法校验返回非空错误拒绝应用,保留当前版本
灰度命中桶号小于百分比且档位匹配使用灰度预设并记录桶号
指标越线会话数达标且 Jank 或崩溃超阈值回滚到上一版本
手动回滚运营或值班决定从版本历史恢复上一版

护栏阈值要高于基线又低于不可接受值。案例设定中消消 Jank 率 2.1%、崩溃率 0.18%;可以把灰度护栏设为 Jank 3.0%、崩溃 0.2%,并要求至少 5000 个会话才判定,样本不足时继续观察而不是宣布成功。这里的阈值是策略示例,不是线上阈值。

回滚有两层:配置非法的「拒绝即回滚」,以及指标越线的「运行期回滚」。两层都要留下版本号与原因,否则回滚之后无法回答「当时到底跑了哪一版」。

35% 低端机对策略的影响

案例设定中消消 DAU 80 万,低端机(4GB RAM 以下)占 35%,也就是约 28 万台设备每天至少一次会话。这不是长尾,而是策略的主要受众。

分档占比口径命中预设说明
低档案例设定 35%low-30不可被活动配置绕过
中档实验参数 50%mid-30默认画质
高档实验参数 15%high-60高刷与强 GPU

中高档的 50% / 15% 是 mga-lab 的实验参数,案例圣经只给出低端机 35%。这张表的用途不是预测收入,而是提醒:任何提高低档成本的改动都会作用在约 28 万台设备上,灰度和回滚必须默认覆盖这批人,而不是等高档用户先验证。

低端机还有一层现实约束:它们更容易触发系统省电与后台回收。档位判定应在启动早期完成并缓存,但 OS 大版本变化、RAM 读取失败、GPU 信息缺失时必须重新判定;缓存的是结论与原因,不是「永远不会变」的标签。

可运行模型:device-tiers.ts

下面的模型把四维评分、硬门槛、策略校验、确定性灰度、版本回滚、车队影响与护栏判定放在同一个文件里。设备参数通过 DeviceProfile 注入,不依赖真实机型。

// file: src/input/device-tiers.ts
// tier: L1
// verify: node --test test/input/device-tiers.test.ts

export type Tier = "low" | "mid" | "high";
export type Platform = "android" | "ios";
export type GpuTier = 0 | 1 | 2 | 3;

export interface DeviceProfile {
  readonly id: string; readonly platform: Platform; readonly ramMb: number;
  readonly gpuTier: GpuTier; readonly refreshHz: number; readonly osMajor: number;
}

export interface TierThresholds {
  readonly ramMb: readonly [number, number]; readonly gpuTier: readonly [number, number];
  readonly refreshHz: readonly [number, number]; readonly minRamMb: number;
  readonly minOsMajor: Readonly<Record<Platform, number>>;
  readonly lowMaxScore: number; readonly highMinScore: number;
}

export const DEFAULT_TIER_THRESHOLDS: TierThresholds = {
  ramMb: [4096, 6144], gpuTier: [1, 3], refreshHz: [90, 120],
  minRamMb: 3072, minOsMajor: { android: 9, ios: 14 }, lowMaxScore: 2, highMinScore: 5,
};

export interface TierDecision {
  readonly tier: Tier; readonly score: number;
  readonly factors: Readonly<Record<"ram" | "gpu" | "refresh", number>>;
  readonly cappedBy: "ram-floor" | "unsupported-os" | null; readonly reasons: readonly string[];
}

function band(value: number, floor: number, ceiling: number): number {
  if (value < floor) {
    return 0;
  }
  return value < ceiling ? 1 : 2;
}

export function decideTier(
  profile: DeviceProfile,
  thresholds: TierThresholds = DEFAULT_TIER_THRESHOLDS,
): TierDecision {
  const ram = band(profile.ramMb, thresholds.ramMb[0], thresholds.ramMb[1]);
  const gpu = band(profile.gpuTier, thresholds.gpuTier[0], thresholds.gpuTier[1]);
  const refresh = band(profile.refreshHz, thresholds.refreshHz[0], thresholds.refreshHz[1]);
  const score = ram + gpu + refresh;
  const reasons = [
    `ram=${profile.ramMb}->${ram}`, `gpu=${profile.gpuTier}->${gpu}`,
    `hz=${profile.refreshHz}->${refresh}`, `os=${profile.platform}-${profile.osMajor}`,
  ];
  const factors = { ram, gpu, refresh };
  if (profile.osMajor < thresholds.minOsMajor[profile.platform]) {
    return { tier: "low", score, factors, cappedBy: "unsupported-os", reasons };
  }
  if (profile.ramMb < thresholds.minRamMb) {
    return { tier: "low", score, factors, cappedBy: "ram-floor", reasons };
  }
  const tier: Tier = score <= thresholds.lowMaxScore ? "low"
    : score >= thresholds.highMinScore ? "high" : "mid";
  return { tier, score, factors, cappedBy: null, reasons };
}

export interface QualityPreset {
  readonly id: string; readonly targetFps: 30 | 60; readonly renderScale: number;
  readonly particleLevel: 0 | 1 | 2; readonly shadow: boolean; readonly textureLevel: 0 | 1 | 2;
}

export interface CanaryConfig {
  readonly percent: number; readonly presetId: string;
  readonly tiers: readonly Tier[]; readonly salt: string;
}

export interface QualityPolicy {
  readonly version: string;
  readonly tierPresets: Readonly<Record<Tier, string>>;
  readonly presets: Readonly<Record<string, QualityPreset>>;
  readonly canary: CanaryConfig | null;
}

export const DEFAULT_POLICY: QualityPolicy = {
  version: "2026.10.31-1",
  tierPresets: { low: "low-30", mid: "mid-30", high: "high-60" },
  presets: {
    "low-30": { id: "low-30", targetFps: 30, renderScale: 0.75, particleLevel: 0, shadow: false, textureLevel: 0 },
    "mid-30": { id: "mid-30", targetFps: 30, renderScale: 1, particleLevel: 1, shadow: false, textureLevel: 1 },
    "high-60": { id: "high-60", targetFps: 60, renderScale: 1, particleLevel: 2, shadow: true, textureLevel: 2 },
  },
  canary: null,
};

export interface QualityDecision {
  readonly tier: Tier; readonly score: number; readonly presetId: string;
  readonly preset: QualityPreset; readonly canary: boolean; readonly bucket: number;
  readonly policyVersion: string; readonly reasons: readonly string[];
}

export function bucketOf(deviceId: string, salt: string): number {
  let hash = 0x811c9dc5;
  const text = `${deviceId}:${salt}`;
  for (let index = 0; index < text.length; index += 1) {
    hash ^= text.charCodeAt(index);
    hash = Math.imul(hash, 0x01000193);
  }
  return (hash >>> 0) % 100;
}

export function resolveQuality(
  profile: DeviceProfile,
  policy: QualityPolicy = DEFAULT_POLICY,
  thresholds: TierThresholds = DEFAULT_TIER_THRESHOLDS,
): QualityDecision {
  const tierDecision = decideTier(profile, thresholds);
  const baseId = policy.tierPresets[tierDecision.tier];
  const base = policy.presets[baseId];
  if (!base) {
    throw new Error(`policy ${policy.version} has no preset ${baseId}`);
  }
  const canary = policy.canary;
  const bucket = canary ? bucketOf(profile.id, canary.salt) : 100;
  const canaryHit = canary !== null && canary.tiers.includes(tierDecision.tier)
    && bucket < canary.percent && policy.presets[canary.presetId] !== undefined;
  const preset = canaryHit ? policy.presets[canary.presetId] : base;
  const reasons = canaryHit
    ? [...tierDecision.reasons, `canary=${bucket}<${canary.percent}`]
    : tierDecision.reasons;
  return {
    tier: tierDecision.tier, score: tierDecision.score, presetId: preset.id, preset,
    canary: canaryHit, bucket, policyVersion: policy.version, reasons,
  };
}

export function validatePolicy(policy: QualityPolicy): readonly string[] {
  const errors: string[] = [];
  if (policy.version.trim().length === 0) {
    errors.push("version is empty");
  }
  for (const tier of ["low", "mid", "high"] as const) {
    const id = policy.tierPresets[tier];
    if (!id) {
      errors.push(`tierPresets.${tier} is missing`);
    } else if (!policy.presets[id]) {
      errors.push(`tierPresets.${tier} references unknown preset ${id}`);
    }
  }
  for (const [id, preset] of Object.entries(policy.presets)) {
    if (preset.id !== id) {
      errors.push(`preset key ${id} does not match id ${preset.id}`);
    }
    if (preset.targetFps !== 30 && preset.targetFps !== 60) {
      errors.push(`preset ${id} targetFps must be 30 or 60`);
    }
    if (!(preset.renderScale >= 0.5 && preset.renderScale <= 1)) {
      errors.push(`preset ${id} renderScale must be within [0.5, 1]`);
    }
  }
  if (policy.canary) {
    if (!(policy.canary.percent >= 0 && policy.canary.percent <= 100)) {
      errors.push("canary.percent must be within [0, 100]");
    }
    if (!policy.presets[policy.canary.presetId]) {
      errors.push(`canary references unknown preset ${policy.canary.presetId}`);
    }
    if (policy.canary.tiers.length === 0) {
      errors.push("canary.tiers must not be empty");
    }
  }
  return errors;
}

export interface PolicyApplyResult {
  readonly ok: boolean; readonly active: QualityPolicy;
  readonly errors: readonly string[]; readonly rolledBack: boolean;
}

export class PolicyRegistry {
  #active: QualityPolicy;
  readonly #history: QualityPolicy[] = [];

  constructor(initial: QualityPolicy = DEFAULT_POLICY) {
    const errors = validatePolicy(initial);
    if (errors.length > 0) {
      throw new Error(`invalid initial policy: ${errors.join("; ")}`);
    }
    this.#active = initial;
  }

  get active(): QualityPolicy {
    return this.#active;
  }

  apply(next: QualityPolicy): PolicyApplyResult {
    const errors = validatePolicy(next);
    if (errors.length > 0) {
      return { ok: false, active: this.#active, errors, rolledBack: true };
    }
    this.#history.push(this.#active);
    this.#active = next;
    return { ok: true, active: this.#active, errors: [], rolledBack: false };
  }

  rollback(): PolicyApplyResult | null {
    const previous = this.#history.pop();
    if (!previous) {
      return null;
    }
    this.#active = previous;
    return { ok: true, active: previous, errors: [], rolledBack: true };
  }
}

export interface FleetImpact {
  readonly total: number; readonly byTier: Readonly<Record<Tier, number>>;
  readonly byPreset: Readonly<Record<string, number>>;
  readonly lowTierShare: number; readonly canaryShare: number;
}

export function fleetImpact(decisions: readonly QualityDecision[]): FleetImpact {
  const byTier: Record<Tier, number> = { low: 0, mid: 0, high: 0 };
  const byPreset: Record<string, number> = {};
  let canary = 0;
  for (const decision of decisions) {
    byTier[decision.tier] += 1;
    byPreset[decision.presetId] = (byPreset[decision.presetId] ?? 0) + 1;
    if (decision.canary) {
      canary += 1;
    }
  }
  const total = decisions.length;
  return {
    total, byTier, byPreset,
    lowTierShare: total === 0 ? 0 : byTier.low / total,
    canaryShare: total === 0 ? 0 : canary / total,
  };
}

export interface CanaryMetrics { readonly sessions: number; readonly jankRate: number; readonly crashRate: number; }
export interface Guardrail { readonly minSessions: number; readonly maxJankRate: number; readonly maxCrashRate: number; }

export const DEFAULT_GUARDRAIL: Guardrail = {
  minSessions: 5000, maxJankRate: 0.03, maxCrashRate: 0.002,
};

export interface RollbackVerdict { readonly rollback: boolean; readonly reasons: readonly string[]; }

export function shouldRollback(
  metrics: CanaryMetrics,
  guardrail: Guardrail = DEFAULT_GUARDRAIL,
): RollbackVerdict {
  if (metrics.sessions < guardrail.minSessions) {
    return { rollback: false, reasons: ["insufficient-sessions"] };
  }
  const reasons: string[] = [];
  if (metrics.jankRate > guardrail.maxJankRate) {
    reasons.push(`jank-rate=${metrics.jankRate}>${guardrail.maxJankRate}`);
  }
  if (metrics.crashRate > guardrail.maxCrashRate) {
    reasons.push(`crash-rate=${metrics.crashRate}>${guardrail.maxCrashRate}`);
  }
  return { rollback: reasons.length > 0, reasons };
}

配套测试 test/input/device-tiers.test.ts 覆盖 13 条路径:三档评分、RAM 边界、OS 硬门槛、RAM 地板、阈值替换、分桶确定性、10% 灰度分布、非目标档位不命中、配置校验、非法更新拒绝、版本回滚、35% 车队占比与护栏判定。运行命令是 node --test test/input/device-tiers.test.ts。

模型不测量真实 GPU 性能,也不推断厂商调度策略;GPU 档位是平台适配层输入,测试只验证「拿到什么就怎么判」。这里是模型,不是设备实测。真机上的持续性能、发热降频与内存水位仍要进入兼容性矩阵。

双案例:消消守下限,方舟守上限

消消的低端机占 35%,策略的第一目标是守住内存水位与 30fps 下限。低档预设必须由统一入口下发,活动、家园、商店都不能自带画质分支;灰度默认先覆盖低档,确认没有越线后再扩大。

方舟低端机只占 15%,但默认 60fps、首包 350MB,低档策略的代价更高。它的灰度需要更长的观察窗口和更严格的崩溃护栏,且低档预设不能简单照搬消消的渲染比例。

与相邻章节的关系

8.1 把输入归一化为意图,本节把设备差异归一化为档位与预设;8.3 处理屏幕形态与布局,不改变画质档位。策略表的结构与版本化可以参考配置驱动配置表 ,远程配置的下发与回滚可以参考远程热更 ,低端机降级的整体取舍可以参考低端机降级 。

小结

  • 分档是可解释的评分加硬门槛,不是机型名单;未知值必须保守,不能默认高档。
  • 画质是配置,不是散落在业务里的 if;配置必须整份校验,失败时拒绝加载。
  • 灰度用设备 id 加盐的确定性分桶,保证同一设备不会在两组预设之间跳变。
  • 回滚分两层:非法配置拒绝即回滚,指标越线运行期回滚;两层都要记录版本与原因。
  • 35% 低端机是主要受众,任何提高低档成本的策略都必须默认覆盖这批设备。

阅读导航:上一节:8.1 触控、陀螺仪与传感器抽象 · 下一节:8.3 平板、折叠屏与异形屏 。

常见错误与边界

  • 用机型名单代替评分。新机永远比名单快,长尾维护成本会吞掉收益;名单只能作为兜底覆盖,不能作为主规则。
  • 让业务入口自带画质判断。活动、商店、家园各写一套 if,迟早绕过统一分档;预设只能由策略入口下发。
  • 把未知 GPU 或未知刷新率当成高档。读取失败是常态,默认高档会把最不稳定的设备推进最重的配置。
  • 灰度用随机数而不是确定性哈希。同一台设备重开游戏就可能换组,指标无法归因,玩家也会感到画质闪烁。
  • 护栏样本不足就宣布灰度成功。5000 会话是示例下限;样本不足时只能继续观察,不能把「没出事」当成「不会出事」。
  • 边界:本节不测量真实 GPU 性能与发热降频。DeviceProfile 是模型输入,档位判定不能替代兼容性矩阵与真机压测。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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