冷启动时最先发生的不是登录
渠道 SDK 接入最常见的写法,是在启动脚本里按「先登录、再支付、再广告」的经验顺序依次调用。这个顺序在只有一个渠道时通常不会出问题,到了 6 个 SDK、多个渠道、多个平台之后,问题会以三种形式出现:支付先于登录导致订单绑错账号,广告先于统计导致收入事件丢失,任何 SDK 先于隐私同意导致合规检查直接失败。
《潮汐消消》接入 6 个 SDK:登录、支付、广告、统计、推送、分享。《潮汐方舟》接入 5 个,少了分享。数量只差一个,但初始化链路不是简单少一步:每个 SDK 都带着自己的依赖、超时与失败策略,任何一个顺序假设写错,都会在冷启动的最初几秒暴露。
本节把这些依赖写成一份可校验的契约:输入是每个 SDK 的能力、依赖、超时与失败策略,输出是一个确定的初始化顺序、一份阻塞预算和一组可退化能力。3.1 决定接哪些渠道,本节决定这些渠道的 SDK 以什么顺序进入运行时,3.3 再决定怎么把它们打进不同的包。
依赖不是一种:门禁、硬依赖、软依赖与偏好
把所有「A 要在 B 前面」当成同一种依赖,是初始化顺序失控的起点。实际至少有三类,再加一个排序偏好。
| 类型 | 例子 | 违反后的后果 | 处理方式 |
|---|---|---|---|
| 门禁 | 隐私同意先于任何 SDK 初始化 | 合规检查失败,包可能被拒 | 作为独立前置条件,缺失直接抛错 |
| 硬依赖 | 支付依赖登录、推送依赖登录、分享依赖登录 | 订单无账号、推送无目标、分享无身份 | 拓扑排序强制满足,不允许降级 |
| 软依赖 | 广告依赖统计 | 广告能展示,但收入事件缺上下文 | 允许降级,记录退化能力 |
| 排序偏好 | 登录优先于统计、支付优先于广告 | 不违反正确性,但每次启动顺序可能不同 | 用优先级固定顺序,便于复现与排查 |
门禁与硬依赖的区别在于「谁负责失败」。门禁失败是产品不能启动,硬依赖失败是某个能力不能启用;把门禁写成硬依赖,会在隐私弹窗还没出现时就去初始化 SDK,顺序上看起来对了,合规上仍然错了。
软依赖最容易被误判成硬依赖。广告 SDK 依赖统计,指的是广告展示后要把曝光与收入事件写进统计通道;如果统计初始化失败,广告仍然可以展示,只是这段收入暂时记不上。把它当硬依赖,会让统计的一次超时拖垮整个广告能力;把它当无依赖,又会在事后对账时发现收入缺口。
排序偏好不是正确性问题,但它是可运维性问题。同样一组就绪节点,如果没有固定优先级,不同实现可能给出不同顺序;运行时出现问题时,两份日志的顺序对不上,排查会先花时间在「为什么这次先初始化了推送」上。
初始化契约要写进类型
契约不应该只写在接入文档里。文档不会参与构建,也不会在 CI 里失败。下面的产物把契约写成 SdkSpec:每个 SDK 声明自己提供什么能力、依赖哪些 SDK、超时多久、失败后是阻塞、退化还是重试。
SdkSpec = id + priority + provides + requires + timeoutMs + policy
这六个字段各自解决一个问题:
id是依赖图的节点名,必须唯一。priority只在多个节点同时就绪时决定先后,不替代依赖。provides记录这个 SDK 对外提供的能力,用于生成退化清单。requires是硬依赖,必须指向已声明的 SDK,不能指向自己。timeoutMs是单个 SDK 的初始化预算,是契约配置值而不是案例统计值,也不是整条链路的预算。policy决定失败后是阻塞启动、退化能力还是重试。
把这些字段放进类型之后,构建阶段就能检查重复 id、缺失依赖、自依赖与非法超时;运行时只需要按契约返回的顺序执行,不再在业务代码里散落 if 判断。
代码:用拓扑排序固定初始化顺序
下面的实现只依赖标准库,做三件事:校验契约、按依赖与优先级排序、把顺序压成带阻塞预算与退化能力的计划。
// file: src/biz/sdk-contract.ts
// tier: L1
// verify: node --test test/biz/sdk-contract.test.ts
export type FailurePolicy = "block" | "degrade" | "retry";
export interface SdkSpec {
readonly id: string;
readonly priority: number;
readonly provides: readonly string[];
readonly requires: readonly string[];
readonly timeoutMs: number;
readonly policy: FailurePolicy;
}
export interface InitStep {
readonly index: number;
readonly id: string;
readonly timeoutMs: number;
readonly policy: FailurePolicy;
readonly requires: readonly string[];
}
export interface InitPlan {
readonly order: readonly string[];
readonly steps: readonly InitStep[];
readonly blockingMs: number;
readonly degradableCapabilities: readonly string[];
readonly blockingCapabilities: readonly string[];
}
export interface OrderViolation {
readonly sdk: string;
readonly missingBefore: readonly string[];
}
function compareSpecs(a: SdkSpec, b: SdkSpec): number {
return a.priority - b.priority || a.id.localeCompare(b.id);
}
function validateSpecs(specs: readonly SdkSpec[]): Map<string, SdkSpec> {
if (specs.length === 0) {
throw new Error("at least one sdk spec is required");
}
const byId = new Map<string, SdkSpec>();
for (const spec of specs) {
if (typeof spec.id !== "string" || spec.id.length === 0) {
throw new Error("sdk id is required");
}
if (byId.has(spec.id)) {
throw new Error(`duplicate sdk id: ${spec.id}`);
}
if (!Number.isFinite(spec.priority)) {
throw new RangeError(`priority must be finite for ${spec.id}`);
}
if (!Number.isInteger(spec.timeoutMs) || spec.timeoutMs <= 0) {
throw new RangeError(`timeoutMs must be a positive integer for ${spec.id}`);
}
if (spec.provides.length === 0) {
throw new Error(`sdk must provide at least one capability: ${spec.id}`);
}
if (spec.policy !== "block" && spec.policy !== "degrade" && spec.policy !== "retry") {
throw new Error(`unknown failure policy for ${spec.id}: ${spec.policy}`);
}
byId.set(spec.id, spec);
}
for (const spec of specs) {
for (const dependency of spec.requires) {
if (dependency === spec.id) {
throw new Error(`sdk cannot depend on itself: ${spec.id}`);
}
if (!byId.has(dependency)) {
throw new Error(`unknown dependency ${dependency} required by ${spec.id}`);
}
}
}
return byId;
}
export function orderSdks(specs: readonly SdkSpec[]): readonly string[] {
const byId = validateSpecs(specs);
const remaining = new Map<string, Set<string>>();
const dependents = new Map<string, string[]>();
for (const spec of specs) {
remaining.set(spec.id, new Set(spec.requires));
for (const dependency of spec.requires) {
const list = dependents.get(dependency) ?? [];
list.push(spec.id);
dependents.set(dependency, list);
}
}
const ready = specs.filter((spec) => remaining.get(spec.id)?.size === 0);
const order: string[] = [];
while (ready.length > 0) {
ready.sort(compareSpecs);
const spec = ready.shift() as SdkSpec;
order.push(spec.id);
for (const dependentId of dependents.get(spec.id) ?? []) {
const dependencies = remaining.get(dependentId);
if (dependencies === undefined) {
continue;
}
dependencies.delete(spec.id);
if (dependencies.size === 0) {
ready.push(byId.get(dependentId) as SdkSpec);
}
}
}
if (order.length !== specs.length) {
const unresolved = specs.filter((spec) => !order.includes(spec.id)).map((spec) => spec.id);
throw new Error(`sdk dependency cycle: ${unresolved.join(", ")}`);
}
return order;
}
export function checkOrder(order: readonly string[], specs: readonly SdkSpec[]): readonly OrderViolation[] {
const byId = validateSpecs(specs);
if (order.length !== specs.length) {
throw new Error(`order must contain exactly ${specs.length} sdk ids`);
}
const seen = new Set<string>();
const violations: OrderViolation[] = [];
for (const id of order) {
const spec = byId.get(id);
if (spec === undefined) {
throw new Error(`unknown sdk in order: ${id}`);
}
if (seen.has(id)) {
throw new Error(`duplicate sdk in order: ${id}`);
}
const missingBefore = spec.requires.filter((dependency) => !seen.has(dependency));
if (missingBefore.length > 0) {
violations.push({ sdk: id, missingBefore });
}
seen.add(id);
}
return violations;
}
export function planInit(specs: readonly SdkSpec[], consentGranted: boolean): InitPlan {
if (!consentGranted) {
throw new Error("privacy consent must be granted before sdk init");
}
const byId = validateSpecs(specs);
const order = orderSdks(specs);
const steps: InitStep[] = order.map((id, index) => {
const spec = byId.get(id) as SdkSpec;
return {
index,
id,
timeoutMs: spec.timeoutMs,
policy: spec.policy,
requires: [...spec.requires],
};
});
let blockingMs = 0;
const degradable: string[] = [];
const blocking: string[] = [];
for (const spec of specs) {
if (spec.policy === "block") {
blockingMs += spec.timeoutMs;
blocking.push(...spec.provides);
}
if (spec.policy === "degrade") {
degradable.push(...spec.provides);
}
}
return {
order,
steps,
blockingMs,
degradableCapabilities: [...new Set(degradable)],
blockingCapabilities: [...new Set(blocking)],
};
}
export function formatInitPlan(plan: InitPlan): string {
const degrade = plan.degradableCapabilities.join(",") || "none";
return `order=${plan.order.join(">")} blocking=${plan.blockingMs}ms degrade=${degrade}`;
}
这段代码有四个值得说明的选择。
第一,拓扑排序的结果必须确定。就绪集合每次按 priority 升序、再按 id 字典序排序,保证同一份契约在任何机器上得到同一个顺序。没有这个约束,依赖图只能保证「不违反依赖」,不能保证「每次一样」。
第二,循环依赖直接失败,而不是截断。如果登录依赖推送、推送又依赖登录,任何「先随便选一个」的处理都会把错误推迟到运行时;这里在排序阶段就抛出 sdk dependency cycle,并列出未解析的节点。
第三,阻塞预算只累加 block 策略的超时。degrade 与 retry 不应该被算进「启动必须等待」的时间,否则预算会被大量可退化能力撑大,排期时看不出真正的关键路径。
第四,退化能力按 provides 汇总并去重。同一个能力可能由多个 SDK 提供,日志里重复列出会让读的人误以为有多个独立退化点。
把消消的 6 个 SDK 喂进去,输出是:
order=login>stats>pay>ad>push>share blocking=16000ms degrade=analytics,rewarded-ad,share
这行文本包含三种信息:顺序、阻塞预算、退化能力。它不解释业务,但足以在冷启动日志里判断「这次启动等了什么、哪些能力可能不可用」。
读输出:16 秒预算不是让你等 16 秒
blocking=16000ms 来自登录与支付两个 block SDK 的超时之和,每个 8 秒。它是最坏情况,不是正常启动时间。正常路径下两个 SDK 各自在几百毫秒内返回,16 秒只在两次超时同时发生时出现。
这个数字的用途是排期与降级设计,不是让首帧等待 16 秒。正确的做法是把阻塞 SDK 的初始化放在加载流程里,用进度条承接最坏情况;如果 16 秒真的发生,产品应该已经准备好「登录失败、支付不可用」的提示,而不是白屏。
degrade=analytics,rewarded-ad,share 表示这三个能力允许失败。统计失败时广告仍可展示,只是收入事件要进本地缓冲;分享失败时玩家不能分享,但主流程不受影响。支付与登录没有出现在退化清单里,因为它们的策略是 block,失败就必须让用户重试或退出。
失败策略:block、degrade 与 retry 的分界
| 策略 | 适用能力 | 失败后的动作 | 不应使用的场景 |
|---|---|---|---|
block | 登录、支付 | 停止流程,提示重试或退出 | 分享、广告这类可缺省能力 |
degrade | 统计、广告、分享 | 关闭对应能力,主流程继续 | 支付、登录这类身份与收入前提 |
retry | 推送 | 后台退避重试,不阻塞主流程 | 需要立即结果的前台操作 |
分界线的判断标准不是「重不重要」,而是「失败后玩家还能不能继续」。登录失败,玩家没有身份,继续玩下去的数据无法归属;支付失败,订单状态不明,继续可能产生重复扣款;推送失败,玩家只是晚一点收到消息,主流程完全不受影响。
retry 与 degrade 的差别在于是否还有恢复可能。推送的失败通常是通道暂时不可达,退避后还能成功;分享的失败可能是渠道未安装或用户取消,重试没有意义,直接退化为「不可分享」更合适。
顺序错了会怎样
四个典型错误,后果都可以在契约里提前拦住:
- 支付先于登录。 支付 SDK 需要账号 id 作为订单归属,顺序反了只能拿到游客 id 或空 id,后续对账时订单与账号对不上。
- 广告先于统计。 广告可以展示,但曝光与收入事件缺少统计上下文,事后只能按渠道总量估算,无法回到具体广告位。
- 分享先于登录。 分享链接带的是匿名身份,回流用户无法与邀请人绑定,分享活动的归因链断在第一跳。
- 任何 SDK 先于隐私同意。 这不是顺序问题,而是门禁问题;即使 SDK 初始化本身不采集数据,提前加载也可能触发权限或网络请求,合规检查不会接受「还没开始采集」的解释。
这四个错误里有三个可以在 requires 里表达,第四个必须用独立的 consentGranted 参数拦。把门禁混进依赖图,会让「隐私同意」变成一个可以被其他 SDK 依赖的普通节点,反而弱化了它的前置地位。
双案例对照
消消的 6 个 SDK 里有分享,退化清单因此多一项 share;登录与支付仍是双阻塞,最坏阻塞预算 16 秒,广告依赖统计,统计失败时广告降级。
方舟的 5 个 SDK 没有分享,初始化链路少一个退化点;登录与支付同样阻塞,统计与广告的依赖关系不变,推送仍走后台重试;差别不在谁更复杂,而在哪些能力可以缺省。
与账号体系的关系
登录 SDK 不是「拉起一个登录框」这么简单。它要把渠道账号映射到产品账号,把设备标识、会话令牌与多端策略一起带进来。消消的多端登录用户占 18%,策略是后登录顶掉先登录;登录成功后返回的不只是用户 id,还包括这次登录是否踢掉了其他端。
这些策略不属于本节,但它们决定了登录 SDK 的契约边界:登录成功后返回的不只是一个用户 id,还包括这次登录是否踢掉了其他端、当前端是否被允许继续。SDK 契约只负责「登录在支付之前完成」,账号合并、令牌刷新与互踢规则由第 2 章的账号体系负责。
代价与取舍
串行、确定的初始化顺序牺牲的是启动速度。统计与登录本来可以并行,广告与推送也可以在登录完成后并发拉起;为了得到一份可复现、可测试的顺序,本节选择串行执行,把并行化的收益让给了确定性。
这个取舍在 SDK 数量少时几乎无感,到了 6 个以上、每个都有超时与重试时,串行的最坏情况会累加。缓解方式不是把顺序改回「谁先返回算谁」,而是把可退化的能力移出阻塞路径:统计、广告、分享失败都不应该让首帧等待,真正需要串行的只有登录与支付这条身份与收入的主链。
另一个代价是契约维护成本。每接一个渠道 SDK,都要更新 SdkSpec 并补测试;如果渠道只在一个包里出现,还要确认这个包没有把未声明的依赖带进来。这部分工作会在 3.3 的构建检查里再出现一次。
边界:契约不决定什么
初始化契约只回答「谁先谁后、失败怎么办」,不回答「卖什么、卖多少钱、订单怎么发货」。商品目录与定价属于第 4 章,订单状态机与幂等发货属于第 5 章,广告位设计与填充兜底属于第 6 章。
它也不替代合规清单。契约能保证 SDK 在隐私同意之后初始化,但不能证明权限申请、第三方 SDK 列表与隐私政策版本是对的;这些要在 3.3 的构建门禁与第 12 章的合规材料里检查。
小结
- 初始化顺序是契约,不是经验值;6 个 SDK 与 5 个 SDK 的差别在能力边界,不在调用行数。
- 依赖至少分四类:门禁、硬依赖、软依赖与排序偏好,混成一种会让失败责任说不清。
- 拓扑排序必须确定:同一份契约在任意机器上得到同一个顺序。
- 阻塞预算只累加
block策略的超时,消消的 16 秒是最坏情况,不是正常启动时间。 - 登录与支付适合
block,统计、广告、分享适合degrade,推送适合retry。 - 隐私同意是门禁,不是普通依赖;它必须独立于依赖图,先于所有 SDK。
- 契约只负责顺序与失败策略,商品、订单、广告位与合规清单分别属于后续章节。
阅读导航:上一节:3.1 渠道矩阵:国内安卓、TapTap 与海外商店 · 下一节:3.3 渠道包构建:变体、签名与合规检查 。
常见错误与边界
- 把初始化顺序写死在启动脚本里。 新增渠道时只能复制粘贴,顺序错误无法被构建或测试发现。
- 把隐私同意当成一个普通 SDK 节点。 门禁被降级成依赖后,其他 SDK 可能先于弹窗初始化,合规检查直接失败。
- 让支付先于登录。 订单缺少账号归属,后续对账只能按渠道总量估算,无法定位到具体用户。
- 把软依赖当硬依赖。 统计超时拖垮广告,广告本可以降级展示,结果变成整条商业化链路不可用。
- 把硬依赖当软依赖。 分享先于登录,邀请关系断链,活动归因从第一跳就丢失。
- 拓扑排序不固定优先级。 同一份契约在不同实现里得到不同顺序,运行日志无法逐行对比。
- 循环依赖只在运行时才发现。 登录与推送互相依赖时,启动会卡在「谁先初始化」,而不是给出明确错误。
- 把阻塞预算当成正常启动时间。 16 秒是最坏情况,正常路径通常远低于此;按最坏情况设计首帧会让体验变差。
- 所有失败都重试。 支付失败重试可能造成重复扣款,分享取消重试没有意义;重试只适合通道暂时不可达的场景。
- 契约里塞业务逻辑。 把商品判断、价格计算写进 SDK 初始化,会让契约随业务频繁变更,失去可测试性。
- 只测正常顺序,不测错误顺序。 没有
checkOrder这类校验,顺序被改错时只能靠真实事故发现。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。