一次被拦下的支付配置
《潮汐方舟》的版本节奏是每 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 限制;代码热更能力最强,但平台受限、风险最高。通道设计的目标,是让每一类变更走成本最低且合规的那条路。
判定一个变更该走哪条通道
评审时按顺序回答六个问题,顺序不能颠倒。
- 是否改动支付流程、抽奖概率、核心玩法规则、原生权限或隐私声明? 任意一项为是,直接阻断,不进入通道选择。
- 是否包含原生二进制、SDK 或权限声明? 是则只能走商店整包。
- 是否包含可执行脚本? 是则检查目标平台是否开放代码热更;iOS 只能改为资源或配置实现,否则等商店。
- 回滚能否在 15 分钟内完成? 不能则不能走热更,降级为商店版本。
- 变更体积是否落在本周 45MB 的批次预算内? 超出则拆批或顺延,不能靠压缩蒙混。
- 是否需要与商店版本同时生效? 需要则先发商店,再用热更打开开关。
前两个问题处理红线与原生层,中间两个处理平台能力与回滚能力,最后两个处理节奏与一致性问题。把顺序固定下来,评审就不会陷入「这个改动很小,应该可以吧」的讨论。
同一份变更可能在两个平台走不同通道
方舟的一次战斗脚本修正,在 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 热更的内容边界与红线 。
常见错误与边界
- 用文件大小判断风险。一个 1KB 的支付配置比 40MB 的语音包危险得多,风险来自语义而不是体积。
- 把「能回滚」当成「可以发」。回滚能力只说明失败后能补救,不说明变更本身合规。
- 用热更绕过审核。通过活动配置放出新付费点,本质仍是未审核功能。
- 忽略配置的表达能力。配置能改价格、概率与规则分支时,它和代码热更没有区别。
- 假设两个平台能力一致。iOS 只走资源与配置,Android 的代码热更不能当成通用能力。
- 拆分后不做能力位。服务端按新行为结算,旧客户端会得到无法解释的结果。
- 把热更当版本收敛手段。热更只是提前量,最终仍要以商店版本为准。
- 批次预算漏算拆分体积。Android 已经下载的字节不会因为 iOS 未发而消失。
- 超限就压缩资源。压缩改变的是体积,不改变发布节奏与合规判断。
- 边界:代码不替代法务判断。平台审核有裁量权,规则引擎只能执行团队已确认的政策。
- 边界:阻断不等于永久禁止。红线内容可以走商店,但必须重新提交并接受审核。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。