《移动游戏产品的架构·网络、资源与热更新》10.2 热更的内容边界与红线

消消双周 12MB 的热更只动活动、关卡、皮肤与文本,方舟每周 45MB 的热更会碰战斗数值、赛季配置与 Android 脚本;两者共用同一组红线。本节把热更内容分成允许、复核、阻断、商店四区,用复核人与回滚方案卡住灰名单,并给出可执行的批量审计规则。

两套热更清单,同一组红线

《潮汐消消》的总资源是 480MB,资源文件数 12,800;热更双周一次,平均 12MB,内容只有活动配置、关卡、皮肤与文本,不改玩法规则与付费点,配置回滚窗口 30 分钟。

《潮汐方舟》的总资源是 1,840MB,资源文件数 86,000;热更每周一次,平均 45MB,内容覆盖战斗数值、赛季配置、活动、UI 资源与语音。代码热更只开在 Android,iOS 只走资源与配置;战斗数值的回滚窗口是 15 分钟。

两个产品的体量、节奏与内容都不同,但共享同一组红线:不得通过热更改支付流程、抽奖概率、核心玩法规则,也不得热更原生权限与隐私声明相关逻辑。体量决定「一次能发多少」,内容决定「能不能发」,红线决定「永远不能发」。

四个区:允许、复核、阻断、商店

把内容判定分成四个区,比「能热更 / 不能热更」的二元判断更可操作。

区含义消消例子方舟例子
允许 allow内容变更,不改变玩法与付费活动配置、关卡、皮肤、文本UI 资源、语音
复核 review可热更,但必须有人签字并有回滚方案无代码热更,极少使用战斗数值、赛季配置、Android 脚本
阻断 blocked红线内容,不得通过热更下发支付流程、抽奖概率、核心规则同左,另含权限与隐私逻辑
商店 store技术上必须随整包发布原生 SDK原生代码、权限、iOS 脚本

四区的关键区别在失败后果。允许区出错,回退资源即可;复核区出错,要在 15 分钟或 30 分钟内回滚;阻断区不是「回滚得快就能做」,而是根本不能进入热更通道;商店区不是「更安全」,而是「更慢、差量更大」,所以不能把所有东西都推给商店。

消消的白名单为什么窄

消消没有任何代码热更能力,脚本随商店整包发布。这个选择让白名单很窄,但也让边界非常清楚:线上热更只能改内容,不能改行为。

活动配置调的是开放时间、展示文案与奖励展示;关卡调的是布局、怪物配置与掉落表;皮肤与文本是纯表现层。它们共同的属性是:即使配置写错,最坏结果是活动开错时间或关卡难度异常,不会改变「玩家怎么玩」与「玩家怎么付费」。

最容易被误判的是关卡。改一张地图的布局、怪物数量与掉落,属于内容;改「什么条件判定通关」「失败是否扣体力」「奖励如何计算」,属于核心玩法规则。前者可以热更,后者必须走商店。判断时不要看文件放在哪个目录,要看这段配置是否参与规则判定。

30 分钟的回滚窗口对消消是够用的,因为变更集中在配置与资源,回滚只需要切回上一份已校验清单并让客户端重新拉取。窗口不是凭空承诺的,它由变更类型决定;如果消消也开放代码热更,30 分钟就未必成立。

方舟的灰名单:数值、赛季与脚本

方舟的热更内容比消消宽,但宽出来的部分几乎都落在复核区。战斗数值、赛季配置与 Android 脚本都可以热更,前提是有人签字、有回滚方案、能在 15 分钟内退回去。

战斗数值是典型的灰名单。它必须能快速调整,否则一次平衡性问题要等 6 周的商店整包;但它又直接影响付费角色的价值与对战公平,不能让数值策划单独决定。复核人必须是能对结果负责的人,不能填一个团队别名。

赛季配置的风险在时间边界。赛季切换、奖励结算、段位重置都发生在固定时刻,配置错误会让一整个赛季的开局异常。复核时要额外确认生效时间、时区与结算顺序,不能只看数值本身。

Android 脚本进入复核区,iOS 脚本直接进入商店区。同一段逻辑在两个平台走不同通道,评审记录必须写清楚差异,否则下次维护时无法解释为什么 Android 已经修了、iOS 还没修。

UI 资源与语音属于允许区。它们改变的是表现,不改变规则与付费;即使资源损坏,也可以回退到上一份已校验版本。这也是资源热更成为周更主体的原因:量大、风险低、回滚简单。

共同红线:四类内容不可热更

四条红线不是技术限制,而是产品与合规约束。

支付流程。价格、币种、订单状态机、发货条件都属于商店审核与消费者权益保护的范围。用热更改支付触发条件,会让线上行为与审核材料不一致,也会让退款与客诉失去可追溯性。

抽奖概率。概率公示是监管要求,也是玩家信任的基础。哪怕只是把概率表从一个配置文件换成另一个,也构成对已公示内容的改变,必须走商店版本并重新公示。

核心玩法规则。胜负判定、匹配规则、经济产出结构属于产品身份。修一个数值 bug 与改一套规则是两件事:前者可以热更,后者必须让玩家在版本说明里看到。

原生权限与隐私声明。权限申请时机、SDK 初始化、数据采集范围都写在隐私声明与商店数据安全表单里。动态下发这类逻辑,等于绕过用户同意与平台审查。

红线对配置同样有效。一张概率表、一个支付开关、一段规则脚本,在文件层面都是数据;只要它承载上述四类语义,就不能进入热更通道。判断依据是语义,不是扩展名。

审批与回滚方案是边界的一部分

复核区不是「填个名字就能发」。两个条件缺一不可:具名复核人,以及可执行的回滚方案。

具名复核人意味着责任可追溯。团队别名看起来更整齐,但出问题时找不到是谁判断的。复核人不需要是最高负责人,但必须理解这次变更对玩法、付费与玩家的影响。

回滚方案要能回答四个问题:回退到哪个版本、由谁触发、触发后多久生效、怎么确认已经恢复。只写「可回滚」不算方案;没有上一份已校验版本的指针,回滚时就只能重新下载,15 分钟窗口会被下载时间吃掉。

这两个条件也是审计字段。发布单里保留复核人与回滚方案,事后复盘时才能区分「判断错误」与「流程缺失」。前者需要改规则,后者需要改工具。

实现

下面的文件把四区判定写成可执行审计。输入是一批内容条目与一份产品策略,输出每条内容的区、回滚分钟数与原因;缺少复核人或回滚方案的复核项会被降级为阻断。

// file: src/hotfix/boundary.ts
// tier: L1
// verify: node --test test/hotfix/boundary.test.ts

export type Platform = "android" | "ios";
export type Surface = "config" | "asset" | "script" | "native";
export type Domain =
  | "activity"
  | "level"
  | "cosmetic"
  | "text"
  | "battle-numbers"
  | "season"
  | "ui"
  | "voice"
  | "payment"
  | "gacha-odds"
  | "core-rules"
  | "privacy";
export type Zone = "allow" | "review" | "blocked" | "store";

export interface BoundaryEntry {
  readonly id: string;
  readonly domain: Domain;
  readonly surface: Surface;
  readonly platform: Platform;
  readonly reviewer?: string;
  readonly hasRollbackPlan: boolean;
}

export interface BoundaryPolicy {
  readonly name: string;
  readonly scriptPlatforms: readonly Platform[];
  readonly defaultRollbackMinutes: number;
  readonly rollbackMinutes: Readonly<Record<string, number>>;
}

export interface ZoneDecision {
  readonly id: string;
  readonly zone: Zone;
  readonly rollbackMinutes: number;
  readonly reasons: readonly string[];
}

export interface BoundaryAudit {
  readonly policy: string;
  readonly decisions: readonly ZoneDecision[];
  readonly counts: Readonly<Record<Zone, number>>;
  readonly blockedIds: readonly string[];
  readonly releaseBlocked: boolean;
}

export const TIDE_MATCH3_BOUNDARY: BoundaryPolicy = {
  name: "潮汐消消",
  scriptPlatforms: [],
  defaultRollbackMinutes: 30,
  rollbackMinutes: { activity: 30, level: 30, cosmetic: 30, text: 30 },
};

export const TIDE_ARK_BOUNDARY: BoundaryPolicy = {
  name: "潮汐方舟",
  scriptPlatforms: ["android"],
  defaultRollbackMinutes: 15,
  rollbackMinutes: { "battle-numbers": 15, season: 15, ui: 15, voice: 15 },
};

const RED_DOMAINS: readonly Domain[] = [
  "payment",
  "gacha-odds",
  "core-rules",
  "privacy",
];

const REVIEW_DOMAINS: readonly Domain[] = ["battle-numbers", "season"];

const DOMAINS: readonly Domain[] = [
  "activity",
  "level",
  "cosmetic",
  "text",
  "battle-numbers",
  "season",
  "ui",
  "voice",
  "payment",
  "gacha-odds",
  "core-rules",
  "privacy",
];

const SURFACES: readonly Surface[] = ["config", "asset", "script", "native"];

function validateEntry(entry: BoundaryEntry): void {
  if (entry.id.trim() === "") throw new Error("entry id is required");
  if (!DOMAINS.includes(entry.domain)) {
    throw new Error(`unknown domain: ${String(entry.domain)}`);
  }
  if (!SURFACES.includes(entry.surface)) {
    throw new Error(`unknown surface: ${String(entry.surface)}`);
  }
  if (entry.platform !== "android" && entry.platform !== "ios") {
    throw new Error(`unknown platform: ${String(entry.platform)}`);
  }
}

export function classifyBoundary(
  entry: BoundaryEntry,
  policy: BoundaryPolicy,
): ZoneDecision {
  validateEntry(entry);

  if (RED_DOMAINS.includes(entry.domain)) {
    return {
      id: entry.id,
      zone: "blocked",
      rollbackMinutes: 0,
      reasons: [`红线内容:${entry.domain} 不得通过热更下发`],
    };
  }

  if (entry.surface === "native") {
    return {
      id: entry.id,
      zone: "store",
      rollbackMinutes: 0,
      reasons: ["原生代码与权限只能随商店整包发布"],
    };
  }

  if (entry.surface === "script" && !policy.scriptPlatforms.includes(entry.platform)) {
    return {
      id: entry.id,
      zone: "store",
      rollbackMinutes: 0,
      reasons: ["该平台只接受资源与配置热更"],
    };
  }

  const rollbackMinutes =
    policy.rollbackMinutes[entry.domain] ?? policy.defaultRollbackMinutes;
  const reasons: string[] = [];
  let needsReview = false;

  if (entry.surface === "script") {
    needsReview = true;
    reasons.push("代码热更需要复核");
  }
  if (REVIEW_DOMAINS.includes(entry.domain)) {
    needsReview = true;
    reasons.push("影响战斗或赛季数值,需要复核与回滚方案");
  }

  if (!needsReview) {
    return { id: entry.id, zone: "allow", rollbackMinutes, reasons };
  }

  if (entry.reviewer === undefined || entry.reviewer.trim() === "") {
    reasons.push("缺少复核人");
  }
  if (!entry.hasRollbackPlan) {
    reasons.push("缺少回滚方案");
  }

  const zone: Zone = reasons.some((line) => line.startsWith("缺少"))
    ? "blocked"
    : "review";

  return { id: entry.id, zone, rollbackMinutes, reasons };
}

export function auditBoundary(
  entries: readonly BoundaryEntry[],
  policy: BoundaryPolicy,
): BoundaryAudit {
  const seen = new Set<string>();
  const decisions: ZoneDecision[] = [];
  for (const entry of entries) {
    if (seen.has(entry.id)) throw new Error(`duplicate entry id: ${entry.id}`);
    seen.add(entry.id);
    decisions.push(classifyBoundary(entry, policy));
  }

  const counts: Record<Zone, number> = {
    allow: 0,
    review: 0,
    blocked: 0,
    store: 0,
  };
  for (const decision of decisions) counts[decision.zone] += 1;

  const blockedIds = decisions
    .filter((decision) => decision.zone === "blocked")
    .map((decision) => decision.id);

  return {
    policy: policy.name,
    decisions,
    counts,
    blockedIds,
    releaseBlocked: blockedIds.length > 0,
  };
}

export function formatBoundaryAudit(audit: BoundaryAudit): string {
  const lines = ["entry\tzone\trollback\treasons"];
  for (const decision of audit.decisions) {
    const reasons = decision.reasons.length > 0 ? decision.reasons.join("; ") : "-";
    lines.push(
      `${decision.id}\t${decision.zone}\t${decision.rollbackMinutes}min\t${reasons}`,
    );
  }
  lines.push(
    `summary\tallow=${audit.counts.allow}\treview=${audit.counts.review}\tblocked=${audit.counts.blocked}\tstore=${audit.counts.store}\trelease=${audit.releaseBlocked ? "blocked" : "ok"}`,
  );
  return lines.join("\n");
}

classifyBoundary 先判红线,再判原生层与平台脚本能力,最后判复核区。复核区要求 reviewer 非空且 hasRollbackPlan 为真,任一缺失就变成 blocked。auditBoundary 汇总四区数量,并把 blocked 的 id 单独列出;只要存在阻断项,releaseBlocked 就为真。formatBoundaryAudit 输出可贴进发布单的文本。

运行结果与边界

用一组小样本运行,输出如下。样本只演示分区,不代表两个产品的真实条目:

entry	zone	rollback	reasons
activity	allow	30min	-
level	allow	30min	-
battle-numbers	review	15min	影响战斗或赛季数值,需要复核与回滚方案
gacha	blocked	0min	红线内容:gacha-odds 不得通过热更下发
native-sdk	store	0min	原生代码与权限只能随商店整包发布
summary	allow=2	review=1	blocked=1	store=1	release=blocked

这张表把「能不能发」拆成了四个动作:直接发、签字后发、不许发、等商店。发布门禁读 releaseBlocked,评审人读 reasons,两条信息服务的是不同角色。

边界要写清楚。第一,分区是团队政策的编码,不是平台规则的原文;平台审核仍可能对所有热更内容提出额外要求。第二,store 不等于安全,只代表通道更慢、差量更大;原生层变更同样需要测试与回滚预案。第三,回滚分钟数是策略配置,不是实测值;它应该来自演练与运维承诺,并在每次复盘后校准。

与相邻章节的关系

10.1 回答「商店允许更新什么」,给出了通道与平台能力;本节把通道落到内容语义,回答「哪些内容属于允许、复核、阻断与商店」。10.3 接着讨论失败与回滚,回答「复核过的热更发出去但失败了怎么办」。11.2 的灰度发布会把复核区的范围进一步收窄到「先发给哪些分组」。

本节不讨论差量算法、下载器与 CDN,那些属于 11.x 与 12.x;也不讨论活动运营的具体配置格式。内容边界只回答「这段内容能不能通过热更改变玩家看到的行为」。

小结

  • 两个产品体量与节奏不同,但共享四条红线:支付、概率、核心规则、权限隐私。
  • 四区模型比二元判断可操作:允许、复核、阻断、商店,对应四种失败后果。
  • 消消的白名单窄,因为线上只能改内容;关卡布局可热更,胜负规则不可热更。
  • 方舟的战斗数值、赛季配置与 Android 脚本属于复核区,必须有具名复核人与回滚方案。
  • 复核条件缺失应降级为阻断;发布门禁只认可追溯的签字与方案。

阅读导航:上一节:10.1 商店允许更新什么 · 下一节:10.3 热更失败与回滚兜底 。

常见错误与边界

  1. 按文件类型判风险。JSON 配置也可能是支付开关或概率表,风险在语义不在扩展名。
  2. 把核心规则拆成数值热更。一点点改判定条件,累计起来就是另一套玩法。
  3. 复核人填团队别名。责任无法追溯,复盘时找不到判断依据。
  4. 回滚方案只写「可回滚」。没有版本指针与触发条件,窗口内根本退不回去。
  5. 把商店区当成安全区。整包同样会出错,只是回滚成本更高。
  6. 忽略赛季生效时间。数值正确但时区或结算顺序错误,照样造成整季异常。
  7. 概率表走配置热更。已公示概率的任何变化都必须重新公示并走商店。
  8. 权限逻辑动态下发。绕过用户同意与商店数据安全表单,属于明确越界。
  9. 允许区不做资源校验。表现层资源损坏也会造成白屏与卡顿,回退能力必须真实可用。
  10. 边界:分区不替代平台审核。代码只执行团队政策,平台仍可提出额外要求。
  11. 边界:回滚分钟数需要演练校准。配置里的 15 分钟不是承诺,实际能力要靠演练证明。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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