《移动游戏产品的架构·商业化与发行工程》3.2 渠道 SDK 接入契约与初始化顺序

渠道 SDK 的接入顺序不是经验值。本节以《潮汐消消》6 个 SDK 与《潮汐方舟》5 个 SDK 为样本,把隐私同意、登录、支付、广告、统计、推送、分享之间的依赖写成可校验的初始化契约,给出拓扑排序、失败策略与退化能力的判定方法,并说明哪些能力允许降级、哪些必须阻塞。

冷启动时最先发生的不是登录

渠道 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 的差别在于是否还有恢复可能。推送的失败通常是通道暂时不可达,退避后还能成功;分享的失败可能是渠道未安装或用户取消,重试没有意义,直接退化为「不可分享」更合适。

顺序错了会怎样

四个典型错误,后果都可以在契约里提前拦住:

  1. 支付先于登录。 支付 SDK 需要账号 id 作为订单归属,顺序反了只能拿到游客 id 或空 id,后续对账时订单与账号对不上。
  2. 广告先于统计。 广告可以展示,但曝光与收入事件缺少统计上下文,事后只能按渠道总量估算,无法回到具体广告位。
  3. 分享先于登录。 分享链接带的是匿名身份,回流用户无法与邀请人绑定,分享活动的归因链断在第一跳。
  4. 任何 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 渠道包构建:变体、签名与合规检查 。

常见错误与边界

  1. 把初始化顺序写死在启动脚本里。 新增渠道时只能复制粘贴,顺序错误无法被构建或测试发现。
  2. 把隐私同意当成一个普通 SDK 节点。 门禁被降级成依赖后,其他 SDK 可能先于弹窗初始化,合规检查直接失败。
  3. 让支付先于登录。 订单缺少账号归属,后续对账只能按渠道总量估算,无法定位到具体用户。
  4. 把软依赖当硬依赖。 统计超时拖垮广告,广告本可以降级展示,结果变成整条商业化链路不可用。
  5. 把硬依赖当软依赖。 分享先于登录,邀请关系断链,活动归因从第一跳就丢失。
  6. 拓扑排序不固定优先级。 同一份契约在不同实现里得到不同顺序,运行日志无法逐行对比。
  7. 循环依赖只在运行时才发现。 登录与推送互相依赖时,启动会卡在「谁先初始化」,而不是给出明确错误。
  8. 把阻塞预算当成正常启动时间。 16 秒是最坏情况,正常路径通常远低于此;按最坏情况设计首帧会让体验变差。
  9. 所有失败都重试。 支付失败重试可能造成重复扣款,分享取消重试没有意义;重试只适合通道暂时不可达的场景。
  10. 契约里塞业务逻辑。 把商品判断、价格计算写进 SDK 初始化,会让契约随业务频繁变更,失去可测试性。
  11. 只测正常顺序,不测错误顺序。 没有 checkOrder 这类校验,顺序被改错时只能靠真实事故发现。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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