《移动游戏产品的架构·客户端与移动运行时》6.2 内存水位与低端机降级

水位不应该是产品级常量,而应由设备内存与分档比例算出整数 MB 预算。本节把低端 35% 预算、70/85/95 三档压力线与无感优先的降级阶梯组合成一份可执行计划,并给出消消与方舟在 2GB、3GB、4GB、6GB 设备上的完整水位对照。

事故:告警响了,但没有人知道先关什么

《潮汐消消》的低端机占比是 35%,活动版本上线后,客服侧出现一类「玩到第三关闪退」的反馈,机型集中在 2GB 与 3GB 内存的 Android 设备。崩溃平台看不到 Java 异常,只有系统日志里的进程回收记录。

客户端其实有水位告警:阈值 520MB,超过就上报一条埋点。问题有两个。第一,520MB 是写死的产品级常量,在 2GB 设备上按 35% 预算只有 716MB,520MB 已经占到 72.6%,触发告警时早已越过 70% 的目标线,留给降级的时间窗口很窄。第二,告警只上报,不动作;值班同学看到曲线后手动调用了一次「清理所有纹理」,画面瞬间变糊、背景音乐断裂,内存却只降了几十 MB,因为真正占大头的是被引用的图集和预加载音频。

这次复盘之后,团队把「内存管理」拆成三个可分别验证的问题:预算怎么算、什么时候算超标、超标后按什么顺序降级。三者缺一,告警就只是一个延迟的事故通知。

预算用整数 MB,按设备内存分档

预算的第一个原则是单位统一。成本、内存、流量一律用整数单位,本节统一用 MB,所有比例计算向下取整。浮点预算会在多机型、多语言、多轮降级后产生无法对账的尾差,最后没人说得清「到底超了 0.4MB 还是 4MB」。

第二个原则是分档。案例设定中,4GB RAM 以下算低端机,预算取设备内存的 35%;4GB 到 6GB 取 45%;6GB 及以上取 50%。这三个比例是策略参数,不是案例指标,真实项目要按平台版本与机型分布校准,但「低端 35%」是本节要固定的口径。

设备内存档位预算比例预算 MB说明
2048MBlow35%716716.8 向下取整
3072MBlow35%10751075.2 向下取整
4095MBlow35%1433仍属「4GB 以下」
4096MBmid45%1843恰好 4GB,进入中档
6143MBmid45%27642764.35 向下取整
6144MBhigh50%3072恰好 6GB,进入高档

边界必须写清楚:4096 不属于低端。很多团队把「4GB 以下」口头理解成「4GB 及以下」,结果同一台 4GB 设备在两个版本里拿到不同预算,水位曲线出现无法解释的跳变。mga-lab 用严格小于判断,测试里专门锁住 4095 与 4096 两个点。

水位是预算的比例,不是产品的常量

有了预算,水位就可以用比例表达。mga-lab 取三档:70% 为正常上限,85% 为告警线,95% 为临界线。超过 85% 必须降级,超过 95% 必须放弃非关键资源,否则系统很可能先一步回收进程。

这个模型解释了一个常见矛盾:产品级告警值 520MB 在低端机上偏晚,在高档机上又偏早。消消的稳态内存是 420MB,告警是 520MB;方舟分别是 1,150MB 与 1,450MB。把绝对值直接套到所有设备,会得到两张互相矛盾的曲线。

设备内存档位预算 MB消消告警 520MB 占比方舟告警 1,450MB 占比结论
2048MBlow71672.6%202.5%方舟不可运行,消消告警即越线
3072MBlow107548.4%134.9%方舟不可运行,消消需提前降级
4096MBmid184328.2%78.7%消消宽松,方舟进入 elevated
6144MBhigh307216.9%47.2%两者均在正常区

表格里的占比由案例设定值与预算公式算出,不是设备上的测量。它给出的决策很直接:方舟在 4GB 以下设备上,基线水位已经超过设备预算,不能靠「运行时再降级」兜底,必须在机型准入或画质预设上先做选择。案例设定里方舟低端机占比 15%,这 15% 就是准入策略要覆盖的人群。

消消则相反:2GB 设备上预算 716MB,520MB 告警占 72.6%,刚好越过 70% 的正常上限。它需要把告警线改成「预算的 85%」,也就是 608MB,并在进入关卡前就完成一次预算检查,而不是等运行到一半再降。

降级顺序:先做无感回收,最后动纹理

降级不是「把能关的都关掉」,而是一条有代价排序的阶梯。mga-lab 的顺序是:场景外资源卸载、图集裁剪、音频码率、纹理分辨率。排序依据是玩家可感知度,不是实现难度。

顺序步骤预计回收比例玩家可感知度可逆
1scene-unload:卸载非当前场景与未引用资源8%无是
2atlas-trim:合并重复图集、裁掉空白页6%低是
3audio-bitrate:降低音乐与音效码率3%低是
4texture-scale:降低纹理采样分辨率12%高否

前两步不改变玩家看到的画面,应该先做;音频降级只在长时间游玩时被察觉,排在第三步;纹理降级会让画面变糊且通常不可逆,必须最后做,并且只在临界线以上触发。比例是策略参数,实践中要按项目的资源构成测量后替换,但顺序不应随意调换。

阶梯还必须允许「降不下去」这个结果。若四步全部执行仍未回到目标水位,说明预算本身不成立,继续加压只会牺牲体验而不解决问题,正确动作是放弃本局或退出到低负载场景,并上报一次预算失败。

可运行模型:预算、压力与降级计划

下面的 memory-budget.ts 实现三件事:按设备内存分档并算出整数预算、按整数比较给出压力等级、按固定顺序生成降级计划。planDegradation 会记录每一步的降前与降后 MB,阶梯用尽仍未达标时返回 reachedTarget: false,而不是假装成功。

// file: src/resource/memory-budget.ts
// tier: L1
// verify: node --test test/resource/memory-budget.test.ts

export type DeviceTier = "low" | "mid" | "high";
export type ProductId = "match3" | "ark";
export type PressureLevel = "normal" | "elevated" | "high" | "critical";

export interface TierRule {
  readonly tier: DeviceTier;
  readonly belowMb: number;
  readonly budgetPercent: number;
}

export const TIER_RULES: readonly TierRule[] = [
  { tier: "low", belowMb: 4096, budgetPercent: 35 },
  { tier: "mid", belowMb: 6144, budgetPercent: 45 },
  { tier: "high", belowMb: Number.MAX_SAFE_INTEGER, budgetPercent: 50 },
];

export const PRESSURE_THRESHOLDS = {
  normal: 70,
  elevated: 85,
  high: 95,
} as const;

export interface ProductCase {
  readonly product: ProductId;
  readonly steadyLowEndMb: number;
  readonly alarmMb: number;
  readonly lowEndSharePercent: number;
}

export const PRODUCT_CASES: readonly ProductCase[] = [
  { product: "match3", steadyLowEndMb: 420, alarmMb: 520, lowEndSharePercent: 35 },
  { product: "ark", steadyLowEndMb: 1150, alarmMb: 1450, lowEndSharePercent: 15 },
];

export type DegradeStepId = "scene-unload" | "atlas-trim" | "audio-bitrate" | "texture-scale";

export interface DegradeStep {
  readonly id: DegradeStepId;
  readonly savingsPercent: number;
  readonly visibleToPlayer: "none" | "low" | "medium" | "high";
  readonly reversible: boolean;
}

export const DEGRADE_ORDER: readonly DegradeStep[] = [
  { id: "scene-unload", savingsPercent: 8, visibleToPlayer: "none", reversible: true },
  { id: "atlas-trim", savingsPercent: 6, visibleToPlayer: "low", reversible: true },
  { id: "audio-bitrate", savingsPercent: 3, visibleToPlayer: "low", reversible: true },
  { id: "texture-scale", savingsPercent: 12, visibleToPlayer: "high", reversible: false },
];

export interface AppliedStep {
  readonly id: DegradeStepId;
  readonly beforeMb: number;
  readonly afterMb: number;
  readonly savedMb: number;
}

export interface DegradePlan {
  readonly beforeMb: number;
  readonly targetMb: number;
  readonly afterMb: number;
  readonly applied: readonly AppliedStep[];
  readonly reachedTarget: boolean;
}

export interface CaseBudget {
  readonly product: ProductId;
  readonly tier: DeviceTier;
  readonly budgetMb: number;
  readonly alarmMb: number;
  readonly headroomMb: number;
  readonly fits: boolean;
}

function requireInteger(value: number, label: string): void {
  if (!Number.isInteger(value) || value < 0) {
    throw new RangeError(`${label} must be a non-negative integer, got ${value}`);
  }
}

export function tierFor(ramMb: number): DeviceTier {
  requireInteger(ramMb, "ramMb");
  for (const rule of TIER_RULES) {
    if (ramMb < rule.belowMb) {
      return rule.tier;
    }
  }
  return "high";
}

export function budgetMbFor(ramMb: number): number {
  const tier = tierFor(ramMb);
  const rule = TIER_RULES.find((item) => item.tier === tier);
  if (rule === undefined) {
    throw new Error(`no tier rule for ${tier}`);
  }
  return Math.floor((ramMb * rule.budgetPercent) / 100);
}

export function pressureLevel(usedMb: number, budgetMb: number): PressureLevel {
  requireInteger(usedMb, "usedMb");
  requireInteger(budgetMb, "budgetMb");
  if (budgetMb === 0) {
    throw new RangeError("budgetMb must be positive");
  }
  const scaled = usedMb * 100;
  if (scaled <= budgetMb * PRESSURE_THRESHOLDS.normal) {
    return "normal";
  }
  if (scaled <= budgetMb * PRESSURE_THRESHOLDS.elevated) {
    return "elevated";
  }
  if (scaled <= budgetMb * PRESSURE_THRESHOLDS.high) {
    return "high";
  }
  return "critical";
}

export function needsAlarm(level: PressureLevel): boolean {
  return level === "high" || level === "critical";
}

export function planDegradation(
  usedMb: number,
  budgetMb: number,
  targetPercent = PRESSURE_THRESHOLDS.normal,
  steps: readonly DegradeStep[] = DEGRADE_ORDER,
): DegradePlan {
  requireInteger(usedMb, "usedMb");
  requireInteger(budgetMb, "budgetMb");
  requireInteger(targetPercent, "targetPercent");
  if (targetPercent > 100) {
    throw new RangeError("targetPercent must be <= 100");
  }

  const targetMb = Math.floor((budgetMb * targetPercent) / 100);
  let current = usedMb;
  const applied: AppliedStep[] = [];

  for (const step of steps) {
    if (current <= targetMb) {
      break;
    }
    const saved = Math.max(1, Math.floor((current * step.savingsPercent) / 100));
    const next = current - saved;
    applied.push({ id: step.id, beforeMb: current, afterMb: next, savedMb: saved });
    current = next;
  }

  return {
    beforeMb: usedMb,
    targetMb,
    afterMb: current,
    applied,
    reachedTarget: current <= targetMb,
  };
}

export function caseBudget(product: ProductId, ramMb: number): CaseBudget {
  const preset = PRODUCT_CASES.find((item) => item.product === product);
  if (preset === undefined) {
    throw new Error(`unknown product: ${product}`);
  }
  const budgetMb = budgetMbFor(ramMb);
  const headroomMb = budgetMb - preset.alarmMb;
  return {
    product,
    tier: tierFor(ramMb),
    budgetMb,
    alarmMb: preset.alarmMb,
    headroomMb,
    fits: headroomMb >= 0,
  };
}

export function watermarkRows(ramMb: number): readonly CaseBudget[] {
  return PRODUCT_CASES.map((item) => caseBudget(item.product, ramMb));
}

配套测试 test/resource/memory-budget.test.ts 覆盖分档边界、向下取整、压力阈值、降级顺序与提前停止、阶梯用尽、双案例水位,以及非整数输入被拒绝。运行命令是 node --test test/resource/memory-budget.test.ts。

这里是模型,不是设备上的测量。真实设备上系统缓存、显存、厂商定制 ROM 都会改变可用内存;模型负责把「预算与降级顺序」写成可回归的策略,设备矩阵负责验证策略在具体机型上的效果。

与相邻章节的关系

6.1 的资源图提供 stats().bytes 与 owner 视角,本节把它换算成设备预算下的压力等级;没有准确的字节账,水位只是猜测。6.3 会把降级后的快照与基线比较,确认降级没有引入新的泄漏。低端机降级的更多机型策略可以参考低端机降级 ,崩溃与卡顿的观测口径可以参考崩溃与卡顿 。

小结

  • 预算按设备内存分档,低端(小于 4GB)取 35%,计算一律用整数 MB 向下取整。
  • 4096MB 属于中档,不是低端;边界要写进测试,不能靠口头约定。
  • 水位用预算比例表达,70% 正常、85% 告警、95% 临界;产品级绝对值会随设备失真。
  • 方舟在 4GB 以下设备上基线水位已超预算,必须靠机型准入或画质预设解决,不能只靠运行时降级。
  • 降级按无感优先:场景卸载、图集裁剪、音频码率、纹理分辨率;阶梯用尽要如实报告失败。

阅读导航:上一节:6.1 资源句柄、引用计数与生命周期 · 下一节:6.3 资源泄漏定位与回归 。

常见错误与边界

  • 把水位写成产品级常量。同一阈值在 2GB 设备上占预算 72.6%,在 6GB 设备上只占 16.9%;水位必须由设备预算算出。
  • 用浮点 MB 做预算。多轮降级后会出现无法对账的小数尾差;预算与水位都用整数,比例计算向下取整。
  • 把 4096MB 当低端。「4GB 以下」是严格小于,边界机型要单独锁定,否则同一设备跨版本预算跳变。
  • 降级从纹理开始。纹理降级不可逆且最容易被玩家察觉,应先做场景卸载与图集裁剪,把纹理放在最后。
  • 降级计划没有「失败」状态。四步执行完仍超预算时继续加压只会牺牲体验;应放弃本局或退到低负载场景并上报。
  • 边界:本模型不模拟显存与系统缓存。GPU 纹理常驻、系统页缓存与厂商 ROM 的可用内存差异只能在机型矩阵上验证,模型只保证策略口径一致。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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