前两节解决了「少分配」: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 抽象:登录 / 支付 / 广告 / 分享 / 云存档 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。