《移动游戏产品的架构·商业化与发行工程》3.1 渠道矩阵:国内安卓、TapTap 与海外商店

渠道不是越全越好。本节以《潮汐消消》9 个渠道、11 个渠道包与国内安卓 55%、iOS 30%、TapTap 8%、海外 7% 的构成为样本,把分成、审核时长与包体增量压成一张可比矩阵,给出覆盖目标下的渠道裁剪规则与包数预算,并说明国内安卓与海外商店的取舍边界。

渠道矩阵要回答的两个问题

《潮汐消消》接入了 9 个渠道,最终产出 11 个渠道包;《潮汐方舟》接入了 6 个渠道,产出 7 个渠道包。这两个数字放在一起,会让第一次做发行的人产生一个直觉:渠道越多,包越多,覆盖越全,收入越高。

这个直觉只对了一半。渠道矩阵真正要回答的是两个问题:

  1. 要接哪些渠道。 不是把应用商店列表抄一遍,而是判断哪个渠道带来的新增用户值得为它维护一条独立的接入、审核与结算链路。
  2. 要出多少个包。 渠道数不等于包数,国内安卓一个渠道可能对应多个变体;包数直接决定构建时间、审核排队和后续每一次热更的验证成本。

把这两个问题混成一句「全渠道覆盖」,最后通常得到两种结果:要么接了一堆贡献个位数百分比的渠道,维护成本吃掉利润;要么为了省包,把所有渠道塞进同一个包,运行时到处判断渠道号,合规清单也说不清。

本节先把渠道压成矩阵,再讨论裁剪规则。3.2 处理这些渠道背后的 SDK 以什么顺序初始化,3.3 处理具体怎么产出并检查每一个包。

四个平台桶与它们的真实差异

渠道名录很长,但决策只需要四个平台桶:国内安卓、iOS、TapTap、海外商店。案例事实表给出的份额如下。

平台桶消消份额消消渠道/包方舟份额方舟渠道/包分成口径审核时长
国内安卓55%5 / 740%4 / 530%–50%,案例按平均 40% 计3–5 天
iOS30%1 / 145%1 / 130%消消 24h,方舟 48h
TapTap8%1 / 1——15%按国内安卓口径
海外商店7%2 / 215%1 / 130%案例未单列

这张表有三个容易被忽略的细节。

第一,渠道数与包数不是一一对应。消消的国内安卓是 5 个渠道、7 个包,多出来的两个包来自同一渠道下的变体;方舟的国内安卓是 4 个渠道、5 个包,同样多一个。变体的原因可能是 ABI、渠道特性开关或商店要求的独立包名,具体怎么生成放到 3.3。

第二,分成口径必须写清楚是区间还是均值。国内安卓写「30%–50%,案例按平均 40% 计」,是因为不同渠道的谈判结果不同;如果只写「安卓 40%」,到了对账环节会发现单渠道账单对不上。TapTap 的 15% 与 iOS、Google Play 的 30% 也不是同一量级,混在一起算平均会失真。

第三,审核时长是节奏约束,不是成本项。iOS 的 24 小时与国内安卓的 3–5 天,决定了同样一个修复,iOS 可以当天发,安卓要提前排期。方舟的 iOS 是 48 小时,原因不在代码,而在中重度产品的审核材料与包体检查更复杂。

把矩阵压成三个加权值

有了分桶和份额,就可以算三个值:加权分成、加权审核时长、覆盖率。它们分别回答「这门生意让出去多少」「一次发版要等多久」「这套矩阵覆盖了多少用户」。

加权分成的公式是各桶份额乘分成比例后求和,再除以总份额:

加权分成 = Σ(份额 × 分成) / Σ份额

消消代入后是 (55×40 + 30×30 + 8×15 + 7×30) / 100 = 34.3%。方舟是 (45×30 + 40×40 + 15×30) / 100 = 34.0%。两个产品的加权分成几乎一样,但结构完全不同:消消的国内安卓占大头且分成最高,方舟的 iOS 占大头且分成固定 30%。

加权审核时长只对「有明确审核时长」的桶计算,避免把未知当成零:

加权审核 = Σ(份额 × 时长,仅已知桶) / Σ(已知桶份额)

消消只有国内安卓与 iOS 有明确时长,样本份额 85%,算出来是 (55×96 + 30×24) / 85 = 70.6h。这里的 96 小时是 3–5 天的中位折算,属于案例设定值,不是外部统计口径。方舟的 iOS 是 48 小时、国内安卓仍是 96 小时,样本份额同样是 85%,结果也落在 70.6h。两个产品在这个指标上相同,纯属结构互补的巧合,不能据此认为它们的发行节奏一样。

覆盖率就是各桶份额之和。消消与方舟都按 100% 计算,因为矩阵已经覆盖了全部已知来源。覆盖率的价值不在当前值,而在裁剪时作为约束:你可以接受覆盖从 100% 降到 93%,但不能在没有记录的情况下悄悄降。

代码:把矩阵压成可比较的摘要

下面的产物把四个桶作为输入,输出渠道数、包数、加权分成、加权审核时长与覆盖率,并提供一个按覆盖目标裁剪的函数。所有输入都来自案例事实表,代码本身不藏第二套数字。

// file: src/biz/channel-matrix.ts
// tier: L1
// verify: node --test test/biz/channel-matrix.test.ts

export type Platform = "android-cn" | "ios" | "taptap" | "overseas";

export interface ChannelGroup {
  readonly id: string;
  readonly platform: Platform;
  readonly channelCount: number;
  readonly packageCount: number;
  readonly sharePct: number;
  readonly commissionPct: number;
  readonly reviewHours: number | null;
}

export interface MatrixSummary {
  readonly groups: number;
  readonly channels: number;
  readonly packages: number;
  readonly coveragePct: number;
  readonly weightedCommissionPct: number;
  readonly weightedReviewHours: number | null;
  readonly reviewSamplesPct: number;
}

export interface TrimResult {
  readonly kept: readonly ChannelGroup[];
  readonly dropped: readonly ChannelGroup[];
  readonly coveragePct: number;
  readonly droppedChannels: number;
  readonly droppedPackages: number;
}

function round1(value: number): number {
  return Math.round(value * 10) / 10;
}

function assertGroup(group: ChannelGroup): void {
  if (typeof group.id !== "string" || group.id.length === 0) {
    throw new Error("group id is required");
  }
  if (!Number.isInteger(group.channelCount) || group.channelCount <= 0) {
    throw new RangeError(`channelCount must be a positive integer: ${group.channelCount}`);
  }
  if (!Number.isInteger(group.packageCount) || group.packageCount < group.channelCount) {
    throw new RangeError(`packageCount must be >= channelCount for ${group.id}`);
  }
  if (!Number.isFinite(group.sharePct) || group.sharePct < 0 || group.sharePct > 100) {
    throw new RangeError(`sharePct out of range for ${group.id}`);
  }
  if (!Number.isFinite(group.commissionPct) || group.commissionPct < 0 || group.commissionPct > 100) {
    throw new RangeError(`commissionPct out of range for ${group.id}`);
  }
  if (group.reviewHours !== null && (!Number.isFinite(group.reviewHours) || group.reviewHours < 0)) {
    throw new RangeError(`reviewHours invalid for ${group.id}`);
  }
}

export function summarizeMatrix(groups: readonly ChannelGroup[]): MatrixSummary {
  if (groups.length === 0) {
    throw new Error("summarizeMatrix needs at least one group");
  }
  const seen = new Set<string>();
  let channels = 0;
  let packages = 0;
  let coverage = 0;
  let commissionWeighted = 0;
  let reviewWeighted = 0;
  let reviewSamples = 0;

  for (const group of groups) {
    assertGroup(group);
    if (seen.has(group.id)) {
      throw new Error(`duplicate group id: ${group.id}`);
    }
    seen.add(group.id);
    channels += group.channelCount;
    packages += group.packageCount;
    coverage += group.sharePct;
    commissionWeighted += group.sharePct * group.commissionPct;
    if (group.reviewHours !== null) {
      reviewWeighted += group.sharePct * group.reviewHours;
      reviewSamples += group.sharePct;
    }
  }

  if (coverage > 100.0001) {
    throw new RangeError(`sharePct sum exceeds 100: ${coverage}`);
  }
  if (coverage <= 0) {
    throw new RangeError("sharePct sum must be positive");
  }

  return {
    groups: groups.length,
    channels,
    packages,
    coveragePct: round1(coverage),
    weightedCommissionPct: round1(commissionWeighted / coverage),
    weightedReviewHours: reviewSamples === 0 ? null : round1(reviewWeighted / reviewSamples),
    reviewSamplesPct: round1(reviewSamples),
  };
}

export function trimToCoverage(groups: readonly ChannelGroup[], targetPct: number): TrimResult {
  if (!Number.isFinite(targetPct) || targetPct <= 0 || targetPct > 100) {
    throw new RangeError(`targetPct must be in (0, 100], got ${targetPct}`);
  }
  const total = summarizeMatrix(groups).coveragePct;
  if (total + 0.0001 < targetPct) {
    throw new Error(`coverage ${total}% cannot reach target ${targetPct}%`);
  }
  const ranked = [...groups].sort((a, b) => b.sharePct - a.sharePct || a.id.localeCompare(b.id));
  const kept: ChannelGroup[] = [];
  let coverage = 0;
  for (const group of ranked) {
    if (coverage + 0.0001 >= targetPct) {
      break;
    }
    kept.push(group);
    coverage += group.sharePct;
  }
  const keptIds = new Set(kept.map((group) => group.id));
  const dropped = ranked.filter((group) => !keptIds.has(group.id));
  return {
    kept,
    dropped,
    coveragePct: round1(coverage),
    droppedChannels: dropped.reduce((sum, group) => sum + group.channelCount, 0),
    droppedPackages: dropped.reduce((sum, group) => sum + group.packageCount, 0),
  };
}

export function formatMatrix(summary: MatrixSummary): string {
  const review = summary.weightedReviewHours === null ? "n/a" : `${summary.weightedReviewHours}h`;
  return `groups=${summary.groups} channels=${summary.channels} packages=${summary.packages} coverage=${summary.coveragePct}% commission=${summary.weightedCommissionPct}% review=${review} reviewSamples=${summary.reviewSamplesPct}%`;
}

export function formatTrim(result: TrimResult): string {
  const kept = result.kept.map((group) => group.id).join(",");
  const dropped = result.dropped.map((group) => group.id).join(",") || "none";
  return `kept=${kept} dropped=${dropped} coverage=${result.coveragePct}% channels-removed=${result.droppedChannels} packages-removed=${result.droppedPackages}`;
}

代码有四个刻意选择。

第一,份额之和允许小于 100,但绝不允许大于 100。小于 100 表示矩阵只覆盖了部分来源,这在裁剪分析里是正常状态;大于 100 说明录入重复或口径重叠,必须直接报错,而不是四舍五入掩盖过去。

第二,审核时长允许为空。TapTap 与海外商店在案例事实表里没有单列审核时长,填空比填一个猜出来的数字更诚实。加权时只对非空样本计算,并额外输出 reviewSamplesPct,让读日志的人知道这个均值覆盖了多少份额。

第三,包数必须大于等于渠道数。一个渠道至少产出一个包,多出来的才是变体;如果录入的包数小于渠道数,说明矩阵漏了渠道,应该在构建前就失败。

第四,裁剪按份额降序,份额相同按 id 排序。排序规则固定,才能保证同一份输入在不同机器、不同时间得到同一个保留集合;否则一次裁剪讨论会变成对排序稳定性的争论。

把消消的四个桶喂进去,输出是:

groups=4 channels=9 packages=11 coverage=100% commission=34.3% review=70.6h reviewSamples=85%

这行文本可以直接进发布记录。它不解释「为什么」,但把「这次矩阵是什么样」固定下来,后续任何一次渠道增减都可以逐字段对比。

读输出:93% 与 95% 是两个决定

裁剪函数接受一个覆盖目标。目标 90% 时,消消保留国内安卓、iOS、TapTap 三个桶,覆盖 93%,丢掉海外商店的 2 个渠道和 2 个包;目标 95% 时,93% 不够,必须把海外商店加回来,覆盖回到 100%。

覆盖目标保留桶丢掉桶实际覆盖省下的渠道/包
90%国内安卓、iOS、TapTap海外商店93%2 / 2
95%全部四个无100%0 / 0

这两个结果的差别不只是 2 个包。丢掉海外商店意味着放弃 7% 的份额,也意味着放弃海外商店的账号体系、支付通道与合规材料;如果产品计划下一季度出海,这个「省下来的 2 个包」会在两个月后以更高的接入成本还回来。

反过来,如果产品明确只做国内,95% 的目标就是过度覆盖:为了 7% 的份额维护一套海外构建链,包数、签名、审核排队都会增加。矩阵不能替产品做战略决定,但它能把「保留」和「丢掉」的代价写成同一张表。

这里还有一个口径问题:份额是用户份额,不是收入份额。消消的海外商店份额 7%,但海外内购与广告的 eCPM 结构可能与国内不同;用用户份额直接推断收入贡献,会低估或高估这条渠道。矩阵只负责用户覆盖,收入侧要在第 1 章的收入结构里单独算。

判断规则:新增一个渠道包前问四个问题

渠道矩阵不是一次性表格,每次新增渠道都要重新过一遍。下面四个问题按顺序问,任何一个答不上来就不应该进构建队列。

  1. 它带来的是新增用户,还是同一批用户的另一个入口? 如果渠道份额主要来自已有平台的重叠用户,新增包的边际收益会被高估。消消的自然量占 55%、买量占 45%,判断重叠时要先看新增用户里有多少来自已有平台。
  2. 分成后的收入能不能覆盖维护成本? 15% 的 TapTap 与 40% 的国内安卓不是同一个经济模型。一个份额 8% 的渠道,如果分成低、审核快、包体增量小,可能比份额更高但分成 50% 的渠道更值得做。
  3. 渠道 SDK 的能力是否与现有契约兼容? 消消有 6 个 SDK,方舟有 5 个;新增渠道如果要求额外的登录或支付能力,就要改 3.2 的初始化契约,而不是在业务代码里加一个 if。
  4. 包体增量是否在预算内? 消消的包体差异是最大 +18MB、最小 -0,方舟是 +42MB、最小 -0。新增渠道的 SDK 与资源如果让最大包继续变大,首包下载转化会先于收入给出反馈。

这四个问题对应四种成本:用户重叠、分成、接入复杂度、包体。只问其中一两个,矩阵就会退化成「渠道列表 + 份额」的静态报表。

双案例对照

消消的矩阵更分散:国内安卓 55%、iOS 30%、TapTap 8%、海外 7%,9 个渠道铺成 11 个包,最大包体增量 +18MB,适合用多包换取每个渠道的干净接入。

方舟的矩阵更集中:iOS 45%、国内安卓 40%、Google Play 15%,6 个渠道只出 7 个包,但首包 350MB、最大增量 +42MB,包体本身就是发行门槛;渠道数量与包数量不能直接比较。

代价与取舍

矩阵方案牺牲的是简单性。11 个包意味着 11 次构建、11 份签名材料、11 条审核记录,任何一次热更都要确认所有变体都拿到了同一版本。一个统一包可以把这个数字降到 1,但代价是运行时渠道判断、合规清单混杂,以及无法针对单一渠道裁剪 SDK。

案例选择的是「按渠道分桶、按份额裁剪」,而不是「所有渠道一个包」。这个选择的隐含判断是:渠道之间的差异足够大,值得为每个渠道保留独立的构建与审核链路;同时份额足够集中,裁剪规则可以把长尾挡在门外。

如果产品只有 2–3 个渠道、份额高度集中,统一包可能更合适;如果渠道超过 10 个、每个渠道都有独立 SDK 要求,分桶几乎是唯一能说清合规边界的方式。矩阵不替你做这个选择,但它把两种选择的包数、分成与审核成本放在同一张表上。

与 3.2、3.3 的分工

本节回答「接哪些渠道、出多少包」,输出是一张带份额、分成、审核时长与包数的矩阵。

3.2 回答「这些渠道背后的 SDK 以什么顺序初始化」,重点是隐私门禁、登录依赖与失败策略;3.3 回答「这些包怎么生成、怎么签名、怎么过合规检查」,重点是变体、签名隔离与权限预算。三节合起来,才是一条从渠道选择到可交付包的完整链路。

小结

  • 渠道矩阵要回答两个问题:接哪些渠道、出多少个包;渠道数不等于包数。
  • 平台桶按国内安卓、iOS、TapTap、海外商店划分,份额、分成、审核时长必须分列,不能混算。
  • 加权分成消消为 34.3%、方舟为 34.0%,结构不同但结果接近,不能据此判断两者的发行难度相同。
  • 加权审核时长只对已知样本计算,并输出样本份额;未知不能当成零。
  • 覆盖目标是裁剪约束,90% 目标下消消会丢掉海外商店的 2 个渠道与 2 个包,实际覆盖 93%。
  • 新增渠道前问四个问题:用户是否重叠、分成是否覆盖成本、SDK 是否兼容、包体是否超预算。
  • 分桶方案用更多的包换取更干净的渠道边界,代价是构建、签名与审核的并行复杂度。

阅读导航:上一节:2.3 账号迁移、合并与风控 · 下一节:3.2 渠道 SDK 接入契约与初始化顺序 。

常见错误与边界

  1. 把渠道数直接当成包数。 消消 9 个渠道产出 11 个包,国内安卓的变体会被漏算,构建清单从第一步就少两个产物。
  2. 用平均分成代替分渠道分成。 国内安卓是 30%–50% 的区间,只写一个 40% 会让单渠道对账无法定位差异。
  3. 把 TapTap 的 15% 与 iOS 的 30% 混在一起算。 两者份额不同、结算周期不同,混算得到的是一个没有业务含义的均值。
  4. 把未知审核时长当成零。 这会把加权审核时长拉低,让排期误以为所有渠道都能当天发。
  5. 只按份额裁剪,不看战略计划。 海外商店只占 7%,但如果下季度要出海,裁掉它等于把接入成本推迟到更贵的时候支付。
  6. 把用户份额当成收入份额。 7% 的用户份额不等于 7% 的收入,广告与内购的结构会让两条曲线分叉。
  7. 新增渠道不检查 SDK 依赖。 渠道要求额外的登录或支付能力时,改业务代码而不改初始化契约,冷启动会出现时序错误。
  8. 忽略包体增量预算。 消消最大 +18MB、方舟最大 +42MB,新增 SDK 让最大包继续膨胀,首包转化会先于收入下降。
  9. 只用一张静态表管理渠道。 渠道份额、分成与审核时长都会变,矩阵必须能按版本重算,否则半年后没人知道数字是哪来的。
  10. 把矩阵当成渠道运营的全部。 矩阵只覆盖用户与成本,渠道关系、结算争议与素材审核仍在表外,不能用一个加权值替代。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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