9.3 弱机适配与降级策略

对象池解决「少分配」,但弱机的内存与帧耗时本身就不够用。这一节把弱机识别做成整数判据:按可用内存、帧耗时 P95、CPU 核心数把设备分成 high/mid/low 三档,每档给出粒子数、渲染分辨率千分比、动效开关与目标帧率,并支持运行时主动降一档。

前两节解决了「少分配」:9.1 用对象池 把稳态分配压到零,9.2 用解析模型 证明了它省掉多少 GC 触发。但分配为零不等于跑得动——一台可用内存只剩 256 MB、帧耗时长期 30 ms 的老机器,即使一个对象都不新建,画面照样会卡。这一节解决的是另一半:识别弱机,然后主动降档。

降级最大的误区是「靠感觉」——看到卡了就随手关掉粒子,或者在低端机上硬扛满画质。这两种做法都不可复现、不可回归测试。本节把降级做成一个可断言的 TypeScript 内核:判据是整数比较,档位是有限的几档,每个档位对应一套明确的参数。这样「这台设备应该是什么档」就能写成测试用例。

弱机识别不是猜:三个整数判据

识别弱机要看三个信号,它们都能在不接触平台后台的前提下拿到。可用内存(deviceMemMb)是最直接的容量信号——内存不够,池子、图集、音效缓存都会挤在一起。帧耗时 P95(frameMsP95)是比「平均帧率」更诚实的信号:平均值会被偶尔的快帧拉高,P95 反映的是「最慢的那 5% 帧有多慢」,而卡顿恰恰藏在那里。CPU 核心数(cpuCores)是算力下限的代理——单核或双核机器即使内存够,也扛不住密集的逻辑计算。

三个信号各自有高档线和低档线,判据用整数比较,先判硬地板(低档),再判中档,都不触发就是高档。这个顺序很重要:低档判据是「与」的关系,任何一个信号触底就直接落低档,不会被其他信号的高分救回来。

D 层:判据阈值与档位参数

下面这段是 D 层配置表。它把两件事写在一起:判据阈值(内存高/中/低线、卡顿上限、核心数线)和档位参数(每档的粒子数、渲染分辨率千分比、目标帧率)。

{
  "version": 1,
  "game": "merge-island",
  "seed": 20261012,
  "note": "弱机降级的判据与档位。内存 / 帧耗时 / 核心数全为整数阈值;档位决定粒子数、渲染分辨率千分比、动效开关与目标帧率。禁浮点。",
  "fps": 60,
  "frameBudgetMs": 16,
  "jankMs": 18,
  "memHighMb": 2048,
  "memMidMb": 1024,
  "memLowMb": 512,
  "coresHigh": 8,
  "coresMid": 4,
  "coresLow": 2,
  "particleFull": 240,
  "particleMid": 120,
  "particleLow": 48,
  "resFullPermille": 1000,
  "resMidPermille": 850,
  "resLowPermille": 650,
  "fpsMid": 60,
  "fpsLow": 30
}

jankMs = 18 是单帧预算(约 16 ms)加上一点余量后的卡顿阈值:帧耗时超过 18 ms 记为「超预算」,超过 36 ms(即 jankMs × 2)记为「卡顿」。分辨率用千分比表示,1000 是原生、650 是只渲染 65% 的像素再放大——这是弱机上最有效的一招,因为渲染开销大致随像素数走。

P 层:降级判据与档位选择

下面这段是 P 层内核,core/src/e09/degrade.ts。它有五个函数:classifyTier 判档、capabilityScore 算能力分、planForTier 取档位参数、decide 出完整决策、stepDown 运行时降一档。全整数、无浮点,node 能直接跑。

// file: core/src/e09/degrade.ts
// tier: P
// verify: node --test 'core/test/e09_*.test.ts'
//
// 弱机适配与降级策略:按「可用内存 / 帧耗时 / CPU 核心数」三个整数判据把设备分成 high / mid / low
// 三档,每档给一套降级参数(粒子数、渲染分辨率千分比、动效开关、目标帧率)。全整数、无浮点。

export type DeviceTier = "high" | "mid" | "low";

export interface DegradeConfig {
  readonly fps: number;
  readonly frameBudgetMs: number;
  readonly jankMs: number;
  readonly memHighMb: number;
  readonly memMidMb: number;
  readonly memLowMb: number;
  readonly coresHigh: number;
  readonly coresMid: number;
  readonly coresLow: number;
  readonly particleFull: number;
  readonly particleMid: number;
  readonly particleLow: number;
  readonly resFullPermille: number;
  readonly resMidPermille: number;
  readonly resLowPermille: number;
  readonly fpsMid: number;
  readonly fpsLow: number;
}

/** 设备探针:可用内存(MB)、最近窗口帧耗时的 95 分位(ms)、CPU 核心数。 */
export interface DeviceProbe {
  readonly deviceMemMb: number;
  readonly frameMsP95: number;
  readonly cpuCores: number;
}

export interface TierPlan {
  readonly maxParticles: number;
  readonly resolutionPermille: number; // 1000 = 原生分辨率,650 = 只渲染 65%
  readonly effectsOn: boolean;
  readonly targetFps: number;
}

export interface DegradePlan extends TierPlan {
  readonly tier: DeviceTier;
  readonly capabilityScore: number; // 0..100,整数
  readonly reasons: readonly string[];
}

/** 三档判据:先看硬地板(低档),再看中档,都不触发即高档。 */
export function classifyTier(p: DeviceProbe, cfg: DegradeConfig): DeviceTier {
  if (p.deviceMemMb < cfg.memLowMb || p.frameMsP95 > cfg.jankMs * 2 || p.cpuCores < cfg.coresLow) return "low";
  if (p.deviceMemMb < cfg.memMidMb || p.frameMsP95 > cfg.jankMs || p.cpuCores < cfg.coresMid) return "mid";
  return "high";
}

/** 设备能力分 0..100:内存 40 + 帧耗时 40 + 核心数 20,全整数地板除。 */
export function capabilityScore(p: DeviceProbe, cfg: DegradeConfig): number {
  const mem = Math.min(p.deviceMemMb, cfg.memHighMb);
  const memPart = Math.floor((mem * 40) / cfg.memHighMb);
  let framePart = 40;
  if (p.frameMsP95 > cfg.jankMs) {
    framePart = Math.max(0, 40 - Math.floor(((p.frameMsP95 - cfg.jankMs) * 40) / cfg.jankMs));
  }
  const cores = Math.min(p.cpuCores, cfg.coresHigh);
  return memPart + framePart + Math.floor((cores * 20) / cfg.coresHigh);
}

/** 档位 → 降级参数。降粒子数、降分辨率、关动效、必要时降目标帧率。 */
export function planForTier(tier: DeviceTier, cfg: DegradeConfig): TierPlan {
  if (tier === "low") {
    return { maxParticles: cfg.particleLow, resolutionPermille: cfg.resLowPermille, effectsOn: false, targetFps: cfg.fpsLow };
  }
  if (tier === "mid") {
    return { maxParticles: cfg.particleMid, resolutionPermille: cfg.resMidPermille, effectsOn: true, targetFps: cfg.fpsMid };
  }
  return { maxParticles: cfg.particleFull, resolutionPermille: cfg.resFullPermille, effectsOn: true, targetFps: cfg.fps };
}

function collectReasons(p: DeviceProbe, cfg: DegradeConfig): string[] {
  const reasons: string[] = [];
  if (p.deviceMemMb < cfg.memLowMb) reasons.push(`可用内存 ${p.deviceMemMb}MB 低于低档线 ${cfg.memLowMb}MB`);
  else if (p.deviceMemMb < cfg.memMidMb) reasons.push(`可用内存 ${p.deviceMemMb}MB 低于中档线 ${cfg.memMidMb}MB`);
  if (p.frameMsP95 > cfg.jankMs * 2) reasons.push(`帧耗时 P95 ${p.frameMsP95}ms 超过卡顿上限 ${cfg.jankMs * 2}ms`);
  else if (p.frameMsP95 > cfg.jankMs) reasons.push(`帧耗时 P95 ${p.frameMsP95}ms 超过单帧预算 ${cfg.jankMs}ms`);
  if (p.cpuCores < cfg.coresLow) reasons.push(`CPU 核心 ${p.cpuCores} 低于低档线 ${cfg.coresLow}`);
  else if (p.cpuCores < cfg.coresMid) reasons.push(`CPU 核心 ${p.cpuCores} 低于中档线 ${cfg.coresMid}`);
  if (reasons.length === 0) reasons.push("内存 / 帧耗时 / 核心数均达标,维持满档");
  return reasons;
}

/** 完整决策:档位 + 降级参数 + 能力分 + 判定理由。 */
export function decide(p: DeviceProbe, cfg: DegradeConfig): DegradePlan {
  const tier = classifyTier(p, cfg);
  return { tier, ...planForTier(tier, cfg), capabilityScore: capabilityScore(p, cfg), reasons: collectReasons(p, cfg) };
}

/** 运行时持续卡顿时主动降一档(保留能力分与既有理由,追加一条运行时理由)。 */
export function stepDown(plan: DegradePlan, cfg: DegradeConfig): DegradePlan {
  const down: DeviceTier = plan.tier === "high" ? "mid" : "low";
  return {
    tier: down, ...planForTier(down, cfg), capabilityScore: plan.capabilityScore,
    reasons: [...plan.reasons, "运行时连续卡顿,主动降一档"],
  };
}

decide 返回的 reasons 是这个设计里最实用的一部分:它把「为什么落到这一档」写成可读的句子,日志里直接能看到「可用内存 256MB 低于低档线 512MB」。capabilityScore 是另一套口径——它把三个信号压成一个 0..100 的连续分,用于展示与排序,但不参与判档。这一点很关键:单核机器(1 核)在内存和帧耗时上都满分,能力分 82 不低,但核心数触底,档位仍是 low。

三档各降了什么

三档的降级是层层递进的:high 是满档(240 粒子、1000 分辨率、动效开、60 帧);mid 先砍粒子(240 → 120)与分辨率(1000 → 850),但保留动效与 60 帧——因为中档机的问题通常是「渲染有点重」,而不是「完全跑不动」;low 才是全面降级:粒子砍到 48、分辨率降到 650、关掉动效、目标帧率降到 30。

关动效与降帧率是低档的两个「重手段」。动效关掉后,飘字、缩放、缓动都变成瞬时切换——视觉上确实糙,但它把每帧的插值计算和绘制批次都省了。目标帧率降到 30 则是主动放弃流畅度换取稳定:与其在 60 帧的目标下反复掉帧(体验是「忽快忽慢」),不如锁定 30 帧跑稳(体验是「一直慢但均匀」)。哪一种更可接受,取决于玩法——对合成类这种不依赖精确操作的游戏,稳比快重要。

P 层测试:把判档写成断言

判据和档位都可以断言。下面这段是 P 层测试,core/test/e09_degrade.test.ts。除了逐个档位的参数,它还包含一条单调性扫描:内存更大、帧更快、核心更多的探针,档位不会更差。

// file: core/test/e09_degrade.test.ts
// tier: P
// verify: node --test 'core/test/e09_*.test.ts'
//
// 硬断言:三档降级判据、能力分与降级参数。含单调性扫描(探针越好,档位不会更差)。

import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { classifyTier, capabilityScore, planForTier, decide, stepDown, type DegradeConfig, type DeviceProbe } from "../src/e09/degrade.ts";

const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
const cfg: DegradeConfig = JSON.parse(readFileSync(join(ROOT, "config", "e09_degrade.json"), "utf8"));

test("强机:内存 / 帧耗时 / 核心数全达标 → high,满档参数", () => {
  const d = decide({ deviceMemMb: 4096, frameMsP95: 10, cpuCores: 8 }, cfg);
  assert.equal(d.tier, "high");
  assert.equal(d.maxParticles, cfg.particleFull);
  assert.equal(d.resolutionPermille, cfg.resFullPermille);
  assert.equal(d.effectsOn, true);
  assert.equal(d.targetFps, cfg.fps);
  assert.equal(d.capabilityScore, 100);
  assert.equal(d.reasons.length, 1, "达标时给一条「维持满档」理由");
});

test("内存贴中档线(1024MB)但帧快核心足 → 仍是 high", () => {
  const d = decide({ deviceMemMb: 1024, frameMsP95: 10, cpuCores: 8 }, cfg);
  assert.equal(d.tier, "high");
  assert.equal(d.capabilityScore, 80);
});

test("帧耗时超预算(24ms)但未到卡顿上限 → mid,降粒子数与分辨率", () => {
  const d = decide({ deviceMemMb: 4096, frameMsP95: 24, cpuCores: 8 }, cfg);
  assert.equal(d.tier, "mid");
  assert.equal(d.maxParticles, cfg.particleMid);
  assert.equal(d.resolutionPermille, cfg.resMidPermille);
  assert.equal(d.effectsOn, true);
  assert.equal(d.capabilityScore, 87);
});

test("内存低于低档线(256MB)→ low,关动效、降目标帧率", () => {
  const d = decide({ deviceMemMb: 256, frameMsP95: 10, cpuCores: 8 }, cfg);
  assert.equal(d.tier, "low");
  assert.equal(d.maxParticles, cfg.particleLow);
  assert.equal(d.resolutionPermille, cfg.resLowPermille);
  assert.equal(d.effectsOn, false);
  assert.equal(d.targetFps, cfg.fpsLow);
  assert.ok(d.reasons.some((r) => r.includes("低于低档线")));
});

test("帧耗时超卡顿上限(50ms)→ low,即便内存与核心都很好", () => {
  const d = decide({ deviceMemMb: 8192, frameMsP95: 50, cpuCores: 16 }, cfg);
  assert.equal(d.tier, "low");
  assert.equal(d.effectsOn, false);
});

test("单核设备 → low,即使内存与帧耗时满分(能力分高但档位由判据决定)", () => {
  const d = decide({ deviceMemMb: 4096, frameMsP95: 10, cpuCores: 1 }, cfg);
  assert.equal(d.tier, "low");
  assert.equal(d.capabilityScore, 82);
  assert.ok(d.reasons.some((r) => r.includes("低于低档线")));
});

test("边界:内存正好等于中档线不降档,低一 MB 触发 mid;帧耗时正好等于预算不降档", () => {
  assert.equal(classifyTier({ deviceMemMb: 1024, frameMsP95: 10, cpuCores: 4 }, cfg), "high");
  assert.equal(classifyTier({ deviceMemMb: 1023, frameMsP95: 10, cpuCores: 4 }, cfg), "mid");
  assert.equal(classifyTier({ deviceMemMb: 4096, frameMsP95: 18, cpuCores: 8 }, cfg), "high");
  assert.equal(classifyTier({ deviceMemMb: 4096, frameMsP95: 19, cpuCores: 8 }, cfg), "mid");
});

test("单调性:内存越大、帧越快、核心越多,档位不会更差", () => {
  const order = { low: 0, mid: 1, high: 2 };
  for (const m of [256, 512, 1024, 2048, 4096]) {
    for (const f of [10, 18, 24, 40]) {
      for (const c of [1, 2, 4, 8]) {
        const base = order[classifyTier({ deviceMemMb: m, frameMsP95: f, cpuCores: c }, cfg)];
        const better = order[classifyTier({ deviceMemMb: m * 2, frameMsP95: Math.max(1, f - 5), cpuCores: c + 4 }, cfg)];
        assert.ok(better >= base, `更优探针 (${m * 2},${f - 5},${c + 4}) 档位不应低于 (${m},${f},${c})`);
      }
    }
  }
});

test("stepDown:从 high 降一档到 mid,理由追加一条,能力分保留;low 后再降仍是 low", () => {
  const d = decide({ deviceMemMb: 4096, frameMsP95: 10, cpuCores: 8 }, cfg);
  const down = stepDown(d, cfg);
  assert.equal(down.tier, "mid");
  assert.equal(down.maxParticles, cfg.particleMid);
  assert.equal(down.capabilityScore, d.capabilityScore, "能力分是探针属性,降档不改变它");
  assert.equal(down.reasons.length, d.reasons.length + 1);
  const low = decide({ deviceMemMb: 256, frameMsP95: 10, cpuCores: 8 }, cfg);
  assert.equal(low.tier, "low");
  assert.equal(stepDown(low, cfg).tier, "low");
  assert.equal(stepDown(low, cfg).maxParticles, cfg.particleLow);
});

test("planForTier 与 decide 参数一致", () => {
  const probes: Record<string, DeviceProbe> = {
    high: { deviceMemMb: 4096, frameMsP95: 10, cpuCores: 8 },
    mid: { deviceMemMb: 4096, frameMsP95: 24, cpuCores: 8 },
    low: { deviceMemMb: 256, frameMsP95: 10, cpuCores: 8 },
  };
  for (const t of ["high", "mid", "low"] as const) {
    const d = decide(probes[t], cfg);
    const plan = planForTier(t, cfg);
    assert.equal(d.tier, t);
    assert.deepEqual([d.maxParticles, d.resolutionPermille, d.effectsOn, d.targetFps],
      [plan.maxParticles, plan.resolutionPermille, plan.effectsOn, plan.targetFps]);
  }
});

在本机跑它(node --test 'core/test/e09_*.test.ts'),10 个断言全过:

✔ 强机:内存 / 帧耗时 / 核心数全达标 → high,满档参数
✔ 帧耗时超预算(24ms)但未到卡顿上限 → mid,降粒子数与分辨率
✔ 内存低于低档线(256MB)→ low,关动效、降目标帧率
✔ 单调性:内存越大、帧越快、核心越多,档位不会更差
ℹ tests 10
ℹ pass 10
ℹ fail 0

运行时降档:stepDown

上面的判档是开机时的一次性决策,但设备状态会变:手机发热降频、后台应用抢占内存、切回前台后帧耗时突然变差。stepDown 就是为这种情况准备的——它把当前档位主动降一级(high → mid、mid → low、low 保持),保留能力分与既有理由,再追加一条「运行时连续卡顿」。调用方在检测到连续 N 帧超预算时调一次,避免设备「温水煮青蛙」式地慢慢掉帧。

要注意 stepDown 是单向的:它只降不升。运行时升档需要一个更保守的判据(比如「连续 M 帧都很快」),否则会在边界反复横跳——升一档又卡、降一档又快,体验比一直待在低档还差。本节只给出降档,是因为降档是安全动作,升档是有风险动作,两者不该共用同一套阈值。

小结与下一步

这一节把弱机适配做成了可断言的整数判据:三个信号(可用内存、帧耗时 P95、核心数)分出 high / mid / low 三档,每档给一套明确参数(粒子数、分辨率千分比、动效开关、目标帧率),并支持运行时 stepDown 单向降档。10 个断言覆盖了判档、边界、单调性与降档,能力分与判档的口径分开——能力分用于展示,判据用于决策。

到这里,「对象池 + GC 压力 + 弱机降级」这一章就完整了:9.1 少分配,9.2 量化分配压力,9.3 在分配之外主动降画质与逻辑。下一节进入一个新的主题——把登录、支付、广告、分享、云存档这些平台 API 收敛到一个抽象层,让同一段逻辑既能跑在 mock 宿主上,也能跑在真机宿主上。

阅读导航:上一节:内存与 GC 压力 · 下一节:平台 API 抽象:登录 / 支付 / 广告 / 分享 / 云存档 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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