一个包变十一个包
《潮汐消消》接入 9 个渠道,最终要交付 11 个包;《潮汐方舟》接入 6 个渠道,交付 7 个包。到了发版日,构建机要同时产出这些包,每一个都要签名、过合规检查、上传到对应商店,并在审核被拒时能定位到是哪一个变体。
这件事最容易出的问题不是编译失败,而是「包都构建成功了,但其中几个不能发」:权限多申请了一项、第三方 SDK 数量对不上隐私政策、某个变体的隐私版本还是上一版、签名用了调试证书。这些问题在构建日志里通常只是一行警告,到了审核环节才变成驳回。
本节把渠道包构建拆成四个检查面:变体、签名、合规、审核节奏。3.1 决定接哪些渠道与出多少包,3.2 决定 SDK 以什么顺序初始化,本节决定这些包怎么生成、怎么证明每一个都能发。
变体:包数从渠道数与包体预算里长出来
渠道数与包数的关系在 3.1 已经出现,这里看具体差异。
| 指标 | 消消 | 方舟 |
|---|---|---|
| 接入渠道数 | 9 | 6 |
| 渠道包数量 | 11 | 7 |
| 首包大小 | 120MB | 350MB |
| 包体差异(最大 / 最小) | +18MB / -0 | +42MB / -0 |
| 最大包体 | 138MB | 392MB |
消消的 11 个包由 9 个渠道加 2 个国内安卓变体组成;方舟的 7 个包由 6 个渠道加 1 个国内安卓变体组成。变体的来源可能是 ABI、渠道特性开关、商店要求的独立包名,也可能是为了把某些渠道不需要的资源裁掉。
包体差异的「最小 -0」不是笔误,而是说最小的包与首包一样大:没有渠道能在基础包之外再减资源,所有变体都是「基础包 + 渠道增量」。消消最大 +18MB,意味着最大包 138MB;方舟最大 +42MB,最大包 392MB。这个增量必须作为构建预算固定下来,而不是每次发版后看产物才知道。
变体命名要能回推到渠道与平台。包名后缀、构建变体 id、签名 key、合规清单四项必须使用同一套标识;如果构建脚本用 channel-a、上传清单用 渠道A、签名材料用另一个编号,出问题时无法在三条记录之间对齐。
签名:每个平台一把钥匙,每个渠道一组材料
签名不是构建的最后一步,而是构建的一部分。一个包构建成功但签名失败,产物就是不可交付的;一个包用了错误的 key 签名,即使上传成功,后续升级也会因为签名不一致被商店拒绝。
案例的构建规则是:
- Android 与 iOS 使用不同的签名身份,不能共用一套材料。
- 每个变体的
signingKeyId必须非空,且能追溯到对应的证书与密码管理记录。 - 调试证书只允许出现在本地调试构建,不得进入渠道包清单。
- 签名失败是阻塞项,不允许「先上传再补签」。
签名隔离的代价是密钥管理成本。11 个包不等于 11 把钥匙,但至少是「Android 一组、iOS 一组」,加上不同渠道可能要求的独立证书;每多一把钥匙,就多一份轮换、备份与权限控制的工作。把钥匙数量压到最少,换来的是单点泄露的影响面变大;渠道包构建选择的是按平台隔离,而不是所有渠道共用一把钥匙。
合规门禁:权限、第三方 SDK 与隐私版本
渠道包最容易在合规上出问题,因为每个变体都可能引入不同的渠道 SDK 与权限。事实表给出的基线是:
| 指标 | 消消 | 方舟 |
|---|---|---|
| 第三方 SDK 数 | 14 | 11 |
| 申请权限数 | 8 | 10 |
| 隐私政策版本 | v3.2 | v2.8 |
构建门禁把这三个数变成可执行的规则:
- 权限预算。 每个变体的权限数不得超过产品的权限预算;消消按 8 项、方舟按 10 项检查。渠道 SDK 多申请一项权限,构建就失败,而不是等到审核驳回。
- 第三方 SDK 预算。 每个变体声明的第三方 SDK 数不得超过基线;消消按 14 个、方舟按 11 个检查。新增渠道 SDK 必须同步更新隐私政策与清单,不能只改构建脚本。
- 隐私版本对齐。 每个变体的隐私政策版本必须与当前版本完全一致;消消是 v3.2,方舟是 v2.8。差一个小版本也算失败,因为用户看到的政策与实际采集行为可能不一致。
- 变体数量对齐。 实际产出的包数必须等于计划包数;消消 11 个、方舟 7 个。少一个包可能是构建脚本漏了渠道,多一个包可能是残留的旧变体。
这四条规则都采用「失败即阻塞」,而不是警告。原因是渠道包的合规问题不会在构建机上造成故障,只会在审核或抽查时造成退回;把失败推迟到审核,修复成本会从一次重新构建变成一次重新排期。
代码:把变体、签名与合规检查压成一份报告
下面的产物接收变体清单与构建规则,输出包数、最大/最小增量、最大包体、签名 key 数量,以及一份阻塞问题列表。规则里的预算直接取自案例基线,不另造阈值。
// file: src/biz/channel-build.ts
// tier: L1
// verify: node --test test/biz/channel-build.test.ts
export type BuildPlatform = "android" | "ios";
export interface VariantSpec {
readonly id: string;
readonly platform: BuildPlatform;
readonly extraSizeMB: number;
readonly signingKeyId: string;
readonly packageSuffix: string;
readonly thirdPartySdkCount: number;
readonly permissionCount: number;
readonly privacyVersion: string;
}
export interface BuildRules {
readonly baseSizeMB: number;
readonly maxExtraSizeMB: number;
readonly expectedVariants: number;
readonly maxThirdPartySdks: number;
readonly maxPermissions: number;
readonly requiredPrivacyVersion: string;
}
export interface ComplianceIssue {
readonly code: string;
readonly variantId: string;
readonly detail: string;
}
export interface BuildReport {
readonly packages: number;
readonly largestExtraMB: number;
readonly smallestExtraMB: number;
readonly largestPackageMB: number;
readonly androidKeys: number;
readonly iosKeys: number;
readonly issues: readonly ComplianceIssue[];
readonly blocking: boolean;
}
function validateRules(rules: BuildRules): void {
if (!Number.isFinite(rules.baseSizeMB) || rules.baseSizeMB <= 0) {
throw new RangeError("baseSizeMB must be positive");
}
if (!Number.isFinite(rules.maxExtraSizeMB) || rules.maxExtraSizeMB < 0) {
throw new RangeError("maxExtraSizeMB must be >= 0");
}
if (!Number.isInteger(rules.expectedVariants) || rules.expectedVariants <= 0) {
throw new RangeError("expectedVariants must be a positive integer");
}
if (!Number.isInteger(rules.maxThirdPartySdks) || rules.maxThirdPartySdks <= 0) {
throw new RangeError("maxThirdPartySdks must be a positive integer");
}
if (!Number.isInteger(rules.maxPermissions) || rules.maxPermissions <= 0) {
throw new RangeError("maxPermissions must be a positive integer");
}
if (typeof rules.requiredPrivacyVersion !== "string" || rules.requiredPrivacyVersion.length === 0) {
throw new Error("requiredPrivacyVersion is required");
}
}
export function buildReport(variants: readonly VariantSpec[], rules: BuildRules): BuildReport {
validateRules(rules);
if (variants.length === 0) {
throw new Error("buildReport needs at least one variant");
}
const issues: ComplianceIssue[] = [];
const seenIds = new Set<string>();
const seenSuffixes = new Set<string>();
const androidKeys = new Set<string>();
const iosKeys = new Set<string>();
let largestExtra = -Infinity;
let smallestExtra = Infinity;
if (variants.length !== rules.expectedVariants) {
issues.push({
code: "variant-count",
variantId: "*",
detail: `expected ${rules.expectedVariants} variants, got ${variants.length}`,
});
}
for (const variant of variants) {
if (seenIds.has(variant.id)) {
issues.push({ code: "duplicate-variant", variantId: variant.id, detail: "duplicate variant id" });
}
seenIds.add(variant.id);
if (!Number.isFinite(variant.extraSizeMB) || variant.extraSizeMB < 0) {
issues.push({ code: "negative-size", variantId: variant.id, detail: `extra size ${variant.extraSizeMB}MB` });
} else {
largestExtra = Math.max(largestExtra, variant.extraSizeMB);
smallestExtra = Math.min(smallestExtra, variant.extraSizeMB);
if (variant.extraSizeMB > rules.maxExtraSizeMB) {
issues.push({
code: "size-budget",
variantId: variant.id,
detail: `+${variant.extraSizeMB}MB exceeds +${rules.maxExtraSizeMB}MB`,
});
}
}
if (typeof variant.signingKeyId !== "string" || variant.signingKeyId.length === 0) {
issues.push({ code: "missing-signing-key", variantId: variant.id, detail: "signing key is empty" });
} else if (variant.platform === "android") {
androidKeys.add(variant.signingKeyId);
} else if (variant.platform === "ios") {
iosKeys.add(variant.signingKeyId);
} else {
issues.push({ code: "unknown-platform", variantId: variant.id, detail: String(variant.platform) });
}
if (typeof variant.packageSuffix !== "string" || variant.packageSuffix.length === 0) {
issues.push({ code: "missing-suffix", variantId: variant.id, detail: "package suffix is empty" });
} else if (seenSuffixes.has(variant.packageSuffix)) {
issues.push({ code: "duplicate-suffix", variantId: variant.id, detail: variant.packageSuffix });
} else {
seenSuffixes.add(variant.packageSuffix);
}
if (!Number.isInteger(variant.thirdPartySdkCount) || variant.thirdPartySdkCount < 0) {
issues.push({ code: "invalid-sdk-count", variantId: variant.id, detail: String(variant.thirdPartySdkCount) });
} else if (variant.thirdPartySdkCount > rules.maxThirdPartySdks) {
issues.push({
code: "sdk-budget",
variantId: variant.id,
detail: `${variant.thirdPartySdkCount} > ${rules.maxThirdPartySdks}`,
});
}
if (!Number.isInteger(variant.permissionCount) || variant.permissionCount < 0) {
issues.push({ code: "invalid-permission-count", variantId: variant.id, detail: String(variant.permissionCount) });
} else if (variant.permissionCount > rules.maxPermissions) {
issues.push({
code: "permission-budget",
variantId: variant.id,
detail: `${variant.permissionCount} > ${rules.maxPermissions}`,
});
}
if (variant.privacyVersion !== rules.requiredPrivacyVersion) {
issues.push({
code: "privacy-version",
variantId: variant.id,
detail: `${variant.privacyVersion} != ${rules.requiredPrivacyVersion}`,
});
}
}
const largestPackageMB = rules.baseSizeMB + (largestExtra === -Infinity ? 0 : largestExtra);
return {
packages: variants.length,
largestExtraMB: largestExtra === -Infinity ? 0 : largestExtra,
smallestExtraMB: smallestExtra === Infinity ? 0 : smallestExtra,
largestPackageMB,
androidKeys: androidKeys.size,
iosKeys: iosKeys.size,
issues,
blocking: issues.length > 0,
};
}
export function formatBuildReport(report: BuildReport): string {
return `packages=${report.packages} largest=+${report.largestExtraMB}MB smallest=+${report.smallestExtraMB}MB maxPackage=${report.largestPackageMB}MB keys=${report.androidKeys}android/${report.iosKeys}ios blocking=${report.blocking} issues=${report.issues.length}`;
}
export function formatIssues(report: BuildReport): string {
if (report.issues.length === 0) {
return "none";
}
return report.issues.map((issue) => `${issue.code}@${issue.variantId}:${issue.detail}`).join("; ");
}
代码有四个设计选择。
第一,合规问题全部收集,不在第一个错误处停止。一个变体可能同时有权限超预算与隐私版本过期,只报第一个会让修复变成多轮往返;构建报告应该一次列出所有阻塞项。
第二,问题带 code 与 variantId。code 用于机器聚合与门禁判断,variantId 用于定位到具体包;只输出一句「合规检查失败」,上传同学仍然要逐个包手工排查。
第三,包体增量允许为 0,但不允许为负。最小 -0 表示没有渠道能少于基础包;负数说明构建脚本把资源裁剪算错了,必须报错而不是接受。
第四,签名 key 按平台去重计数。报告里的 keys=1android/1ios 不是密钥总数,而是「Android 用了几把、iOS 用了几把」;这个数字突然变大,通常意味着有新渠道引入了独立证书,需要同步更新密钥管理记录。
把消消的 11 个变体喂进去,输出是:
packages=11 largest=+18MB smallest=+0MB maxPackage=138MB keys=1android/1ios blocking=false issues=0
如果某个变体的权限从 8 改成 9,输出会变成 blocking=true issues=1,问题列表里出现 permission-budget@android-b:9 > 8。这行文本可以直接作为构建门禁的失败原因,也可以进入发版记录,供下一次审核复盘。
构建顺序:先生成变体,再签名,最后过门禁
构建流水线的顺序不是随意的,它决定了失败时能保留多少可用的中间产物。
- 生成变体。 按渠道与平台产出基础包与增量资源,写入统一的变体 id 与包名后缀。
- 写入合规清单。 每个变体声明第三方 SDK、权限与隐私版本;这份清单是后续检查的输入,不是事后补的文档。
- 签名。 按平台与渠道选择 key,签名失败立即停止该变体,不进入上传队列。
- 过门禁。 检查权限、SDK、隐私版本、变体数量与包体预算;任何一项失败,整批构建标记为不可发布。
- 产出报告。 记录包数、最大/最小增量、最大包体、key 数量与问题列表,作为发版证据。
这个顺序的关键是「合规清单在签名之前」。如果先签名再补清单,清单就变成了对产物的描述,而不是对产物的约束;构建机无法证明清单与包内容一致,门禁也就失去意义。
另一个细节是「整批失败」而不是「单包失败」。渠道包通常按批次发版,一个包不合规,其他包即使构建成功也不应该单独上传,否则会出现同一版本在不同渠道行为不一致的情况。失败批次保留中间产物用于排查,但不进入发布队列。
审核节奏:24 小时、48 小时与 3–5 天
构建完成不等于可以发布。案例事实表给出的审核时长是:消消 iOS 24 小时、国内安卓 3–5 天;方舟 iOS 48 小时、国内安卓 3–5 天。首次过审提交次数,消消平均 1.8 次,方舟 2.4 次。
这三个数字要一起看:
- iOS 的 24 小时与 48 小时,决定 iOS 可以承担更晚的提审;但方舟的 48 小时说明中重度产品的审核材料更多,不能按消消的节奏排期。
- 国内安卓的 3–5 天是区间,不是一个确定值;排期要按 5 天留缓冲,而不是按 3 天。
- 首次过审 1.8 次与 2.4 次,意味着平均每个版本要准备约 1.8 次与 2.4 次提审;构建门禁能减少的是「材料性驳回」,不能消除「内容性驳回」。
商店素材的更新频率也影响构建节奏:消消每 2 周更新一次素材,方舟每 4 周更新一次。素材更新通常不需要重新构建包,但如果素材与包内配置耦合,就会把一次素材更新变成一次发版;构建清单应该把素材版本与包版本分开记录。
双案例对照
消消的 11 个包以 120MB 为基础,最大增量 +18MB,权限 8 项、第三方 SDK 14 个、隐私版本 v3.2,首次过审平均 1.8 次,构建门禁的重点是防止渠道 SDK 把权限推高。
方舟的 7 个包以 350MB 为基础,最大增量 +42MB,权限 10 项、第三方 SDK 11 个、隐私版本 v2.8,首次过审平均 2.4 次,门禁的重点是包体预算与审核材料的一致性;包数不同,但门禁结构相同。
代价与取舍
按变体逐个检查的代价是构建时间与维护成本。11 个变体就是 11 份合规清单、11 次签名、11 组检查;如果只维护一份共享清单,构建会快很多,但无法证明每个包的权限与 SDK 真的与清单一致。
签名隔离的代价是密钥管理。按平台分钥匙降低了单点泄露的影响面,但增加了轮换与备份的工作;所有渠道共用一把钥匙更简单,一旦泄露,所有包都要重新签名并重新提审。
失败即阻塞的代价是发版速度。一个变体的权限多一项,整批构建就不能发布;如果改成警告,发版会更快,但问题会推迟到审核,修复成本更高。渠道包构建选择的是「在构建机上失败」,而不是「在商店里失败」。
与 3.1、3.2 的分工
3.1 决定接哪些渠道、出多少包,输出是渠道矩阵与包数预算;3.2 决定 SDK 的初始化顺序与失败策略,输出是初始化契约;本节决定这些包怎么生成、怎么签名、怎么过合规检查,输出是一份可发布的构建报告。
三节的输出可以串成一条发布记录:矩阵给出「计划包数」,契约给出「每个包初始化哪些 SDK」,构建报告给出「实际包数、签名、权限、SDK 与隐私版本是否一致」。任何一环缺失,发版记录都无法自证。
小结
- 渠道包构建有四个检查面:变体、签名、合规、审核节奏,缺一不可。
- 消消 11 个包以 120MB 为基础、最大 +18MB;方舟 7 个包以 350MB 为基础、最大 +42MB。
- 包体差异最小为 -0,表示没有渠道能少于基础包,所有变体都是基础包加增量。
- 签名按平台隔离,调试证书不得进入渠道包;签名失败是阻塞项。
- 合规门禁检查权限、第三方 SDK、隐私版本与变体数量,全部失败即阻塞。
- 构建顺序是变体、清单、签名、门禁、报告;清单必须在签名之前,才能约束产物。
- 审核节奏按 iOS 24h/48h、国内安卓 3–5 天、首次过审 1.8/2.4 次排期,按区间上限留缓冲。
阅读导航:上一节:3.2 渠道 SDK 接入契约与初始化顺序 · 下一节:4.1 商品目录、定价档位与区域价格 。
常见错误与边界
- 把渠道包当成基础包复制。 复制出来的包没有渠道增量与独立清单,上传后会出现渠道 SDK 缺失或权限不一致。
- 包数按渠道数写死。 消消 9 个渠道要出 11 个包、方舟 6 个渠道要出 7 个包,漏算变体会让构建清单少产物。
- 用调试证书签渠道包。 商店会拒绝升级,或者要求重新签名后重新提审,整批包都要返工。
- 所有渠道共用一把签名钥匙。 一把钥匙泄露会影响全部包,轮换时所有渠道都要重新签名与提审。
- 权限预算只写在文档里。 渠道 SDK 多申请一项权限时构建不失败,问题会推迟到审核驳回。
- 第三方 SDK 清单与隐私政策不同步。 包内多了 SDK、政策没更新,合规抽查会认定为采集行为与声明不一致。
- 隐私版本差一个小版本就放行。 用户看到的政策与实际采集行为可能不一致,应按完全一致检查。
- 先签名再补合规清单。 清单变成对产物的描述而不是约束,门禁无法证明清单与包内容一致。
- 单包失败后其余包继续上传。 同一版本在不同渠道行为不一致,后续排查无法区分是渠道差异还是构建差异。
- 按审核时长下限排期。 国内安卓是 3–5 天,按 3 天排期会在第 4 天卡住,发版窗口直接错过。
- 把首次过审次数当成质量指标。 1.8 与 2.4 是案例设定值,受材料、内容与渠道策略影响,不能简单归因于构建质量。
- 素材更新与包版本混在一起记录。 消消每 2 周、方舟每 4 周更新素材,混记会让「包没变但素材变了」的版本无法追溯。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。