《移动游戏产品的架构·网络、资源与热更新》10.1 商店允许更新什么

方舟每周平均 45MB 的热更里,哪些内容可以直接下发,哪些必须等商店整包?本节从应用商店审核的对象出发,把变更分成资源、配置、脚本与原生四类,给出支付、概率、玩法规则、权限隐私五条红线,以及 15 分钟回滚预算下的可执行通道判定与批次预算核算。

一次被拦下的支付配置

《潮汐方舟》的版本节奏是每 6 周发一次商店整包,两次整包之间每周下发平均 45MB 的增量热更,整包差量在 60 到 90MB 之间。首包 350MB,总资源 1,840MB,资源文件数 86,000。这样一套节奏意味着,绝大多数线上变化都发生在两次商店审核之间,热更是常态而不是例外。

一次周更评审里,运营提出用配置热更调整首充礼包的触发条件。改动只有一个配置块,体积远小于一次周更,按技术标准完全可以在 15 分钟内回滚。评审仍然把它拦下了:支付流程属于商店审核时确认过的产品定义,用热更改支付触发条件,等于让线上行为与审核材料不一致。

这个例子说明,热更的边界不由文件大小或回滚速度决定,而由「这次改动是否改变了商店已经审核过的产品」决定。技术团队习惯问「能不能热更」,政策团队必须先问「允不允许热更」。

审核对象是已提交的产品定义

应用商店审核的不是某一个文件,而是提交的二进制、随包资源,以及开发者声明的功能、付费方式与数据处理行为。热更新通常被允许用于修正缺陷、补充内容,但不能被用来绕过审核发布新功能,或改变已经声明的商业行为。

把这句话落到工程上,可以得到三层判断:

层次审核关心什么允许的热更典型越界
内容层是否新增未声明的功能活动、关卡、皮肤、文本、语音用活动配置放出新的付费点
行为层玩法与付费是否与声明一致数值修正、缺陷修复改抽奖概率、改核心玩法规则
原生层权限、SDK、隐私声明不允许动态下发权限申请或数据采集逻辑

三层里最容易误判的是行为层。一段脚本、一个数值表、一个开关,在文件层面都是数据;但只要它改变了玩家已经付费购买或商店已经审核的行为,就属于产品定义的变化。判断标准不是「有没有下发可执行文件」,而是「玩家看到的行为是否与审核时一致」。

这也是为什么「资源热更」相对安全:换一张图、换一段语音、补一句文本,通常不改变玩法与付费。相反,「配置热更」的风险取决于配置能表达什么。如果配置只能调活动时间与展示文案,风险低;如果配置可以改价格、概率、掉落或规则分支,它和代码热更没有本质区别。

方舟的四条更新通道

方舟把线上变更分成四条通道,每条通道对应不同的载体、节奏与回滚能力。

通道承载内容平台节奏回滚能力
商店整包原生代码、权限、SDK、引擎升级Android、iOS每 6 周整包不可回滚,只能发新版本覆盖
资源热更UI、模型、动画、语音Android、iOS每周,平均 45MB可回退到上一份已校验资源
配置热更战斗数值、赛季配置、活动Android、iOS每周,随批次战斗数值 15 分钟内可回滚
代码热更脚本与逻辑补丁仅 Android每周,随批次随配置回滚,窗口 15 分钟

代码热更只开在 Android,iOS 只走资源与配置。这个差异不是团队偏好,而是两个平台对动态下发可执行逻辑的容忍度不同。方舟的 Android 占比 65%,iOS 占 35%,也就是说任何一次「Android 先热更」的决定,都会让三分之一的玩家暂时停留在旧行为上。

四条通道不能互相替代。商店整包解决原生层问题,但周期长、差量大;资源热更覆盖面广,但改不了行为;配置热更灵活,但能力必须被配置 schema 限制;代码热更能力最强,但平台受限、风险最高。通道设计的目标,是让每一类变更走成本最低且合规的那条路。

判定一个变更该走哪条通道

评审时按顺序回答六个问题,顺序不能颠倒。

  1. 是否改动支付流程、抽奖概率、核心玩法规则、原生权限或隐私声明? 任意一项为是,直接阻断,不进入通道选择。
  2. 是否包含原生二进制、SDK 或权限声明? 是则只能走商店整包。
  3. 是否包含可执行脚本? 是则检查目标平台是否开放代码热更;iOS 只能改为资源或配置实现,否则等商店。
  4. 回滚能否在 15 分钟内完成? 不能则不能走热更,降级为商店版本。
  5. 变更体积是否落在本周 45MB 的批次预算内? 超出则拆批或顺延,不能靠压缩蒙混。
  6. 是否需要与商店版本同时生效? 需要则先发商店,再用热更打开开关。

前两个问题处理红线与原生层,中间两个处理平台能力与回滚能力,最后两个处理节奏与一致性问题。把顺序固定下来,评审就不会陷入「这个改动很小,应该可以吧」的讨论。

同一份变更可能在两个平台走不同通道

方舟的一次战斗脚本修正,在 Android 上可以走代码热更,在 iOS 上只能等商店整包。这时同一条变更会得到「拆分」的结果:Android 热更,iOS 随下个整包发布。工程上必须承认这种拆分,而不是假装两个平台行为一致。

拆分带来三个约束。第一,服务端不能假设所有客户端都具备新行为,匹配、结算与活动判定要按客户端上报的能力位分支。第二,Android 的新行为必须能被远程关闭,否则 iOS 玩家在跨平台玩法里会看到不一致的结果。第三,版本收敛要以商店版本为准,热更只是提前量,不是替代品。

如果一项变更无法在平台差异下保持公平,正确做法是等 iOS 版本就绪后一起打开,而不是让 Android 先跑一周。强联网产品的公平性优先于更新速度;方舟的 DAU 是 25 万,跨平台匹配随时发生,任何一侧的临时行为都会被玩家立刻感知。

批次预算与商店节奏

方舟每周平均 45MB 的增量热更,是一个运营节奏,也是一条预算线。批次预算要同时计入直接热更与拆分热更的体积:拆分意味着 Android 先下,iOS 等整包,但 Android 的下载量已经发生,不能因为「另一半还没发」就不计账。

商店整包每 6 周一次,整包差量 60 到 90MB。这个差量决定了「等商店」不是免费的:一次整包更新对玩家的下载成本,远高于一次 45MB 的周更。所以通道选择不是「能热更就热更」,而是把必须在商店解决的问题集中起来,减少无谓的整包次数。

作为对照,消消的热更是双周一次、平均 12MB,回滚窗口 30 分钟,内容只覆盖活动配置、关卡、皮肤与文本,不改玩法规则与付费点。体量更小、内容更窄,通道判定也就更简单。

批次预算超限时,优先拆批,其次顺延,最后才考虑把非红线内容并入商店整包。把内容塞进商店换来的不是合规,而是更长的等待与更大的差量。

实现

下面的文件把通道判定写成可执行规则。输入是一批变更请求与一份通道策略,输出每条变更的路由、可热更平台、需走商店的平台、原因与批次预算结果。

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

export type Platform = "android" | "ios";
export type ChangeSurface = "resource" | "config" | "script" | "native";
export type UpdateRoute = "hotfix" | "split" | "store" | "blocked";

export interface ChangeRequest {
  readonly id: string;
  readonly surface: ChangeSurface;
  readonly platforms: readonly Platform[];
  readonly bytes: number;
  readonly rollbackMinutes: number;
  readonly touchesPaymentFlow?: boolean;
  readonly changesGachaOdds?: boolean;
  readonly changesCoreRules?: boolean;
  readonly changesPermissions?: boolean;
  readonly changesPrivacyDeclaration?: boolean;
}

export interface StorePolicy {
  readonly name: string;
  readonly codeHotfixPlatforms: readonly Platform[];
  readonly rollbackBudgetMinutes: number;
  readonly batchBudgetBytes: number;
}

export interface PolicyDecision {
  readonly id: string;
  readonly route: UpdateRoute;
  readonly hotfixPlatforms: readonly Platform[];
  readonly storePlatforms: readonly Platform[];
  readonly reasons: readonly string[];
  readonly withinRollbackBudget: boolean;
}

export interface BatchReport {
  readonly policy: string;
  readonly decisions: readonly PolicyDecision[];
  readonly hotfixBytes: number;
  readonly withinBatchBudget: boolean;
  readonly counts: Readonly<Record<UpdateRoute, number>>;
}

export const MB = 1024 * 1024;

export const TIDE_ARK_POLICY: StorePolicy = {
  name: "潮汐方舟",
  codeHotfixPlatforms: ["android"],
  rollbackBudgetMinutes: 15,
  batchBudgetBytes: 45 * MB,
};

export const TIDE_MATCH3_POLICY: StorePolicy = {
  name: "潮汐消消",
  codeHotfixPlatforms: [],
  rollbackBudgetMinutes: 30,
  batchBudgetBytes: 12 * MB,
};

interface RedLine {
  readonly flag: keyof ChangeRequest;
  readonly label: string;
}

const RED_LINES: readonly RedLine[] = [
  { flag: "touchesPaymentFlow", label: "改动支付流程" },
  { flag: "changesGachaOdds", label: "改动抽奖概率" },
  { flag: "changesCoreRules", label: "改动核心玩法规则" },
  { flag: "changesPermissions", label: "改动原生权限" },
  { flag: "changesPrivacyDeclaration", label: "改动隐私声明" },
];

function validateRequest(request: ChangeRequest): void {
  if (request.id.trim() === "") throw new Error("change id is required");
  if (!Number.isInteger(request.bytes) || request.bytes < 0) {
    throw new Error(`invalid bytes for ${request.id}: ${request.bytes}`);
  }
  if (!Number.isInteger(request.rollbackMinutes) || request.rollbackMinutes < 0) {
    throw new Error(`invalid rollback minutes for ${request.id}: ${request.rollbackMinutes}`);
  }
  if (request.platforms.length === 0) {
    throw new Error(`no platforms for ${request.id}`);
  }
  if (new Set(request.platforms).size !== request.platforms.length) {
    throw new Error(`duplicate platform in ${request.id}`);
  }
}

export function classifyChange(
  request: ChangeRequest,
  policy: StorePolicy,
): PolicyDecision {
  validateRequest(request);

  const red = RED_LINES.filter((line) => request[line.flag] === true);
  if (red.length > 0) {
    return {
      id: request.id,
      route: "blocked",
      hotfixPlatforms: [],
      storePlatforms: [...request.platforms],
      reasons: red.map((line) => `红线:${line.label}`),
      withinRollbackBudget: false,
    };
  }

  if (request.surface === "native") {
    return {
      id: request.id,
      route: "store",
      hotfixPlatforms: [],
      storePlatforms: [...request.platforms],
      reasons: ["原生代码与权限只能随商店整包发布"],
      withinRollbackBudget: false,
    };
  }

  const withinRollbackBudget = request.rollbackMinutes <= policy.rollbackBudgetMinutes;
  const reasons: string[] = [];
  let hotfixPlatforms: Platform[];

  if (request.surface === "script") {
    hotfixPlatforms = request.platforms.filter((platform) =>
      policy.codeHotfixPlatforms.includes(platform),
    );
    if (hotfixPlatforms.length < request.platforms.length && withinRollbackBudget) {
      reasons.push("该平台只接受资源与配置热更");
    }
  } else {
    hotfixPlatforms = [...request.platforms];
  }

  if (!withinRollbackBudget) {
    hotfixPlatforms = [];
    reasons.push(
      `回滚 ${request.rollbackMinutes} 分钟超出 ${policy.rollbackBudgetMinutes} 分钟预算`,
    );
  }

  const hotfixSet = new Set(hotfixPlatforms);
  const storePlatforms = request.platforms.filter((platform) => !hotfixSet.has(platform));
  const route: UpdateRoute =
    hotfixPlatforms.length === 0
      ? "store"
      : storePlatforms.length === 0
        ? "hotfix"
        : "split";

  return {
    id: request.id,
    route,
    hotfixPlatforms,
    storePlatforms,
    reasons,
    withinRollbackBudget,
  };
}

export function planBatch(
  requests: readonly ChangeRequest[],
  policy: StorePolicy,
): BatchReport {
  const seen = new Set<string>();
  for (const request of requests) {
    if (seen.has(request.id)) throw new Error(`duplicate change id: ${request.id}`);
    seen.add(request.id);
  }

  const decisions = requests.map((request) => classifyChange(request, policy));
  const counts: Record<UpdateRoute, number> = {
    hotfix: 0,
    split: 0,
    store: 0,
    blocked: 0,
  };
  let hotfixBytes = 0;

  for (let index = 0; index < requests.length; index += 1) {
    const decision = decisions[index];
    counts[decision.route] += 1;
    if (decision.route === "hotfix" || decision.route === "split") {
      hotfixBytes += requests[index].bytes;
    }
  }

  return {
    policy: policy.name,
    decisions,
    hotfixBytes,
    withinBatchBudget: hotfixBytes <= policy.batchBudgetBytes,
    counts,
  };
}

export function formatBatchReport(report: BatchReport): string {
  const lines = ["change\troute\thotfix\tstore\tnotes"];
  for (const decision of report.decisions) {
    const hotfix =
      decision.hotfixPlatforms.length > 0 ? decision.hotfixPlatforms.join(",") : "-";
    const store =
      decision.storePlatforms.length > 0 ? decision.storePlatforms.join(",") : "-";
    const notes = decision.reasons.length > 0 ? decision.reasons.join("; ") : "-";
    lines.push(`${decision.id}\t${decision.route}\t${hotfix}\t${store}\t${notes}`);
  }
  lines.push(
    `summary\thotfix=${report.counts.hotfix}\tsplit=${report.counts.split}\tstore=${report.counts.store}\tblocked=${report.counts.blocked}\tbytes=${(report.hotfixBytes / MB).toFixed(2)}MB\tbudget=${report.withinBatchBudget ? "ok" : "exceeded"}`,
  );
  return lines.join("\n");
}

classifyChange 先检查五条红线,任意命中就直接阻断;原生层变更直接路由到商店;脚本按策略里的 codeHotfixPlatforms 拆分平台;最后用回滚预算过滤,超时的热更一律降级为商店。planBatch 负责批次级校验:重复 id 直接报错,热更与拆分体积累加后与 45MB 预算比较。formatBatchReport 输出可直接贴进评审单的表格。

运行结果与边界

用一组小样本运行,输出如下。样本体积是示例输入,不是方舟的真实单条变更体积:

change	route	hotfix	store	notes
ui	hotfix	android,ios	-	-
battle-script	split	android	ios	该平台只接受资源与配置热更
ios-native	store	-	android,ios	原生代码与权限只能随商店整包发布
first-charge	blocked	-	android,ios	红线:改动支付流程
summary	hotfix=1	split=1	store=1	blocked=1	bytes=2.00MB	budget=ok

这张表回答了评审最关心的三件事:哪些能直接发、哪些要等商店、哪些根本不能发。原因列不是备注,而是审计证据;发布后回查时,能直接看到当时依据的是哪条规则。

边界要写清楚。第一,代码只编码团队已经确认的政策,不替代法务与平台沟通;平台审核具有裁量权,同一行为在不同时间可能得到不同结论。第二,blocked 表示「不得通过热更下发」,不表示「永远不能做」;红线内容可以走商店版本,但必须重新提交审核。第三,回滚预算是运营承诺,不是代码能保证的事实;代码只能拒绝超出预算的热更,不能缩短实际回滚时间。

与相邻章节的关系

10.2 会把通道判定细化到内容层面,回答「哪些活动、数值与资源属于允许、复核与红线」。10.3 讨论失败与回滚,回答「热更发出去了但没成功怎么办」。9.3 的首包账给出了 120MB 与 350MB 两个体积基线,本节沿用它解释商店整包的成本。11.2 的灰度发布则负责把「允许热更」进一步收窄到「先发给谁」。

本节只回答「走哪条通道」,不回答「怎么打包」「怎么差量」「怎么灰度」。通道是政策判断,差量与灰度是执行手段,两者不能混为一谈。

小结

  • 商店审核的是已提交的产品定义,不是文件类型;热更不能改变审核时声明的功能与付费行为。
  • 方舟有四条通道:商店整包、资源热更、配置热更、代码热更;代码热更只开在 Android。
  • 判定顺序固定:红线、原生层、脚本平台能力、15 分钟回滚、45MB 批次预算、与商店版本的一致性。
  • 同一变更可以拆分平台通道,但必须用能力位与远程开关保证跨平台公平。
  • 批次预算要计入拆分热更的 Android 体积;超限先拆批,再顺延,最后才并入商店。

阅读导航:上一节:9.3 双案例的首包账:120MB 与 350MB · 下一节:10.2 热更的内容边界与红线 。

常见错误与边界

  1. 用文件大小判断风险。一个 1KB 的支付配置比 40MB 的语音包危险得多,风险来自语义而不是体积。
  2. 把「能回滚」当成「可以发」。回滚能力只说明失败后能补救,不说明变更本身合规。
  3. 用热更绕过审核。通过活动配置放出新付费点,本质仍是未审核功能。
  4. 忽略配置的表达能力。配置能改价格、概率与规则分支时,它和代码热更没有区别。
  5. 假设两个平台能力一致。iOS 只走资源与配置,Android 的代码热更不能当成通用能力。
  6. 拆分后不做能力位。服务端按新行为结算,旧客户端会得到无法解释的结果。
  7. 把热更当版本收敛手段。热更只是提前量,最终仍要以商店版本为准。
  8. 批次预算漏算拆分体积。Android 已经下载的字节不会因为 iOS 未发而消失。
  9. 超限就压缩资源。压缩改变的是体积,不改变发布节奏与合规判断。
  10. 边界:代码不替代法务判断。平台审核有裁量权,规则引擎只能执行团队已确认的政策。
  11. 边界:阻断不等于永久禁止。红线内容可以走商店,但必须重新提交并接受审核。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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