渠道矩阵要回答的两个问题
《潮汐消消》接入了 9 个渠道,最终产出 11 个渠道包;《潮汐方舟》接入了 6 个渠道,产出 7 个渠道包。这两个数字放在一起,会让第一次做发行的人产生一个直觉:渠道越多,包越多,覆盖越全,收入越高。
这个直觉只对了一半。渠道矩阵真正要回答的是两个问题:
- 要接哪些渠道。 不是把应用商店列表抄一遍,而是判断哪个渠道带来的新增用户值得为它维护一条独立的接入、审核与结算链路。
- 要出多少个包。 渠道数不等于包数,国内安卓一个渠道可能对应多个变体;包数直接决定构建时间、审核排队和后续每一次热更的验证成本。
把这两个问题混成一句「全渠道覆盖」,最后通常得到两种结果:要么接了一堆贡献个位数百分比的渠道,维护成本吃掉利润;要么为了省包,把所有渠道塞进同一个包,运行时到处判断渠道号,合规清单也说不清。
本节先把渠道压成矩阵,再讨论裁剪规则。3.2 处理这些渠道背后的 SDK 以什么顺序初始化,3.3 处理具体怎么产出并检查每一个包。
四个平台桶与它们的真实差异
渠道名录很长,但决策只需要四个平台桶:国内安卓、iOS、TapTap、海外商店。案例事实表给出的份额如下。
| 平台桶 | 消消份额 | 消消渠道/包 | 方舟份额 | 方舟渠道/包 | 分成口径 | 审核时长 |
|---|---|---|---|---|---|---|
| 国内安卓 | 55% | 5 / 7 | 40% | 4 / 5 | 30%–50%,案例按平均 40% 计 | 3–5 天 |
| iOS | 30% | 1 / 1 | 45% | 1 / 1 | 30% | 消消 24h,方舟 48h |
| TapTap | 8% | 1 / 1 | — | — | 15% | 按国内安卓口径 |
| 海外商店 | 7% | 2 / 2 | 15% | 1 / 1 | 30% | 案例未单列 |
这张表有三个容易被忽略的细节。
第一,渠道数与包数不是一一对应。消消的国内安卓是 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 章的收入结构里单独算。
判断规则:新增一个渠道包前问四个问题
渠道矩阵不是一次性表格,每次新增渠道都要重新过一遍。下面四个问题按顺序问,任何一个答不上来就不应该进构建队列。
- 它带来的是新增用户,还是同一批用户的另一个入口? 如果渠道份额主要来自已有平台的重叠用户,新增包的边际收益会被高估。消消的自然量占 55%、买量占 45%,判断重叠时要先看新增用户里有多少来自已有平台。
- 分成后的收入能不能覆盖维护成本? 15% 的 TapTap 与 40% 的国内安卓不是同一个经济模型。一个份额 8% 的渠道,如果分成低、审核快、包体增量小,可能比份额更高但分成 50% 的渠道更值得做。
- 渠道 SDK 的能力是否与现有契约兼容? 消消有 6 个 SDK,方舟有 5 个;新增渠道如果要求额外的登录或支付能力,就要改 3.2 的初始化契约,而不是在业务代码里加一个 if。
- 包体增量是否在预算内? 消消的包体差异是最大 +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 接入契约与初始化顺序 。
常见错误与边界
- 把渠道数直接当成包数。 消消 9 个渠道产出 11 个包,国内安卓的变体会被漏算,构建清单从第一步就少两个产物。
- 用平均分成代替分渠道分成。 国内安卓是 30%–50% 的区间,只写一个 40% 会让单渠道对账无法定位差异。
- 把 TapTap 的 15% 与 iOS 的 30% 混在一起算。 两者份额不同、结算周期不同,混算得到的是一个没有业务含义的均值。
- 把未知审核时长当成零。 这会把加权审核时长拉低,让排期误以为所有渠道都能当天发。
- 只按份额裁剪,不看战略计划。 海外商店只占 7%,但如果下季度要出海,裁掉它等于把接入成本推迟到更贵的时候支付。
- 把用户份额当成收入份额。 7% 的用户份额不等于 7% 的收入,广告与内购的结构会让两条曲线分叉。
- 新增渠道不检查 SDK 依赖。 渠道要求额外的登录或支付能力时,改业务代码而不改初始化契约,冷启动会出现时序错误。
- 忽略包体增量预算。 消消最大 +18MB、方舟最大 +42MB,新增 SDK 让最大包继续膨胀,首包转化会先于收入下降。
- 只用一张静态表管理渠道。 渠道份额、分成与审核时长都会变,矩阵必须能按版本重算,否则半年后没人知道数字是哪来的。
- 把矩阵当成渠道运营的全部。 矩阵只覆盖用户与成本,渠道关系、结算争议与素材审核仍在表外,不能用一个加权值替代。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。