前两节把「接哪个渠道」和「钱怎么分」讲完了,这一节处理最后一件事:包怎么发。一个游戏同时上微信、抖音、开放 Web,每个渠道的包并不一样——appId 不同、要挂的 SDK 不同、能开的特性也不同。这和《上线变现与长线运营》 里讲的「一套内容发多个平台」是同一类问题,但那一卷聚焦内容与运营节奏,本节聚焦配置层:怎么用「基础配置 + 渠道覆盖」把每个渠道的包解析出来,并保证它不会漏掉必填项。上一节9.2 联运分成与结算周期 讲的是钱,这一节讲的是产物;两者都属渠道合作的落地环节,和买量渠道与投放机制 里的「多渠道分发」互为前后。
一套代码、多个渠道包:问题出在差异
如果你的游戏只发一个渠道,配置就一份,没什么可讲的。但现实是要同时发多个:微信小游戏有自己的支付与登录、抖音小游戏有自己的视频与分享能力、开放 Web 走的是广告网络与自己的账号体系。差异集中在四处:
| 差异项 | 说明 |
|---|---|
| appId | 每个渠道后台生成不同的应用标识 |
| SDK 清单 | 有的渠道要多挂支付/视频 SDK,有的要卸掉内购 SDK |
| 特性开关 | 同一功能在不同渠道可能开或关(如内购、虚拟支付) |
| 最低版本 | 渠道要求的最低客户端版本可能不同 |
如果每个渠道都手抄一份完整配置,迟早会漂移:基础配置改了一个值,忘了同步到某个渠道包,线上就出现「这个渠道的行为和别人不一样」的诡异 bug。正确的做法是只维护一份基础配置 + 每个渠道一小段覆盖,最终包配置由代码解析出来。
解析规则:覆盖优先,SDK 可增可删
解析规则要写死,不能靠约定:
appId = appIdPrefix + "." + appIdSuffix
sdk = base.sdk 去掉 removeSdk,再追加 addSdk 中未出现过的项(保持顺序)
features = { **base.features, **featureOverrides } # 覆盖优先
minVer = max(base.minVersion, channel.minVersion)
valid = 所有 requiredFeatures 在 features 里均为 1 且 sdk 非空且 appId 唯一
两个关键点:一是覆盖优先——渠道的 featureOverrides 覆盖基础值,基础配置是「默认值」,渠道是「例外」;二是SDK 既增也删——有的渠道要多挂一个 SDK,有的渠道(比如开放 Web)要卸掉内购 SDK。解析完之后还要做必填特性校验:有些特性是所有渠道包都必须开的(比如激励视频、分享、云存档),漏了就是线上事故。
S 层仿真:解析 + 校验,产出对拍摘要
下面这段是 S 层仿真脚本 sim/o09_pkg_validate.py。它读 config/o09_channel_pkg.json,把基础配置与三个渠道的覆盖解析成最终包配置,做必填特性校验,并产出与 TypeScript 内核逐位一致的摘要,输出 JSON + CSV + SVG。
#!/usr/bin/env python3
# file: sim/o09_pkg_validate.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/o09_pkg_validate.py --config config/ --out out/
"""渠道包差异化配置校验仿真:读 config/o09_channel_pkg.json,把「基础配置 + 渠道覆盖」
解析成每个渠道的最终包配置,并做必填特性校验,产出与 core/src/o09/pkg.ts 逐位一致的摘要。
对每个渠道:
appId = appIdPrefix + "." + appIdSuffix
sdk = base.sdk 去掉 removeSdk,再追加 addSdk 中未出现过的项(保持顺序)
features = { **base.features, **featureOverrides }
minVer = max(base.minVersion, channel.minVersion)
valid = 所有 requiredFeatures 在 features 里均为 1 且 sdk 非空且 appId 唯一
featSig = 按键名排序的 "key=value",用 "|" 连接
digest = 每渠道 "id:appId:minVersion:sharePermille:sdkCount:sdk(+连接):featSig",用 "," 连接。
产出 out/o09_pkg_validate.json + .csv + .svg。全整数、无浮点。
真实渠道 appId 与 SDK 清单由各家后台生成,命令行不可复现(X)。
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET
def load(d: str) -> dict:
with open(os.path.join(d, "o09_channel_pkg.json"), encoding="utf-8") as f:
return json.load(f)
def resolve_pkg(base: dict, ch: dict) -> dict:
app_id = base["appIdPrefix"] + "." + ch["appIdSuffix"]
sdk = [s for s in base["sdk"] if s not in ch["removeSdk"]]
for s in ch["addSdk"]:
if s not in sdk:
sdk.append(s)
features = {**base["features"], **ch["featureOverrides"]}
return {"id": ch["id"], "name": ch["name"], "appId": app_id,
"minVersion": max(base["minVersion"], ch["minVersion"]),
"sharePermille": ch["sharePermille"], "sdk": sdk, "features": features}
def feature_sig(features: dict) -> str:
return "|".join(f"{k}={features[k]}" for k in sorted(features))
def validate(cfg: dict) -> list:
seen = set()
out = []
for ch in cfg["channels"]:
r = resolve_pkg(cfg["base"], ch)
missing = [k for k in cfg["requiredFeatures"] if r["features"].get(k, 0) != 1]
dup = r["appId"] in seen
seen.add(r["appId"])
r["missingFeatures"] = missing
r["valid"] = (not missing) and len(r["sdk"]) > 0 and not dup
out.append(r)
return out
def draw_svg(path: str, rows: list) -> int:
"""每个渠道一根横条:SDK 数量,标注 appId、分成与校验结果。"""
W, PAD, ROW = 760, 230, 40
H = 90 + ROW * len(rows)
svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W), "height": str(H),
"viewBox": f"0 0 {W} {H}"})
ET.SubElement(svg, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
ET.SubElement(svg, "text", {"x": "16", "y": "28", "font-size": "15",
"fill": "#111827"}).text = "渠道包解析结果:SDK 数量与校验"
span = W - PAD - 40
hi = max(len(r["sdk"]) for r in rows) or 1
for i, r in enumerate(rows):
y = 54 + i * ROW
w = span * len(r["sdk"]) // hi
color = "#16a34a" if r["valid"] else "#ef4444"
ET.SubElement(svg, "text", {"x": "16", "y": str(y + 16), "font-size": "12",
"fill": "#374151"}).text = f'{r["name"]} · {r["appId"]}'
ET.SubElement(svg, "rect", {"x": str(PAD), "y": str(y + 4), "width": str(w), "height": "18",
"rx": "3", "fill": color})
ET.SubElement(svg, "text", {"x": str(PAD + w + 6), "y": str(y + 16), "font-size": "11",
"fill": "#6b7280"}).text = (
f'{len(r["sdk"])} 个 SDK · 分成 {r["sharePermille"]}‰ · '
f'{"通过" if r["valid"] else "缺 " + ",".join(r["missingFeatures"])}')
os.makedirs(os.path.dirname(path), exist_ok=True)
ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)
return len(ET.tostring(svg))
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
a = ap.parse_args()
cfg = load(a.config)
rows = validate(cfg)
valid_count = sum(1 for r in rows if r["valid"])
digest = ",".join(f'{r["id"]}:{r["appId"]}:{r["minVersion"]}:{r["sharePermille"]}:'
f'{len(r["sdk"])}:{"+".join(r["sdk"])}:{feature_sig(r["features"])}'
for r in rows)
os.makedirs(a.out, exist_ok=True)
svg = draw_svg(os.path.join(a.out, "o09_pkg_validate.svg"), rows)
with open(os.path.join(a.out, "o09_pkg_validate.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["id", "name", "appId", "minVersion", "sharePermille", "sdkCount",
"sdk", "featureSig", "valid", "missingFeatures"])
for r in rows:
w.writerow([r["id"], r["name"], r["appId"], r["minVersion"], r["sharePermille"],
len(r["sdk"]), "+".join(r["sdk"]), feature_sig(r["features"]),
r["valid"], ",".join(r["missingFeatures"])])
rep = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
"channelCount": len(rows), "validCount": valid_count,
"channels": [{"id": r["id"], "name": r["name"], "appId": r["appId"],
"minVersion": r["minVersion"], "sharePermille": r["sharePermille"],
"sdk": r["sdk"], "features": r["features"], "valid": r["valid"],
"missingFeatures": r["missingFeatures"]} for r in rows],
"digest": digest, "csv": os.path.join(a.out, "o09_pkg_validate.csv"),
"svg": os.path.join(a.out, "o09_pkg_validate.svg"), "svgBytes": svg}
with open(os.path.join(a.out, "o09_pkg_validate.json"), "w", encoding="utf-8") as f:
json.dump(rep, f, ensure_ascii=False, indent=2)
print(json.dumps({"digest": digest, "validCount": valid_count,
"channels": [(r["id"], r["appId"], len(r["sdk"]), r["valid"]) for r in rows]},
ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
参数放在 config/o09_channel_pkg.json:
{
"version": 1,
"game": "merge-island",
"observedAt": "2026-10",
"note": "渠道包差异化配置。口径是「公开可观察机制」的建模假设,不是任何渠道后台的实测配置。base=所有渠道共用的基础配置;channels=每个渠道的覆盖项(appIdSuffix 拼到 appIdPrefix 后、addSdk/removeSdk 增删 SDK、featureOverrides 覆盖特性开关、minVersion 取 max)。requiredFeatures=每个渠道包必须开启的特性。真实渠道 appId、真实 SDK 清单由各家后台生成,命令行不可复现(X)。全整数,无浮点。",
"base": {
"appIdPrefix": "com.pebble.mergeisland",
"minVersion": 3,
"sdk": ["core", "analytics", "iap"],
"features": { "rewardedAd": 1, "share": 1, "leaderboard": 1, "cloudSave": 1, "iap": 1, "virtualPay": 0 }
},
"requiredFeatures": ["rewardedAd", "share", "cloudSave"],
"channels": [
{ "id": "wechat", "name": "微信小游戏", "appIdSuffix": "wx", "sharePermille": 400, "minVersion": 3, "addSdk": ["wxPay"], "removeSdk": [], "featureOverrides": { "virtualPay": 1 } },
{ "id": "douyin", "name": "抖音小游戏", "appIdSuffix": "dy", "sharePermille": 200, "minVersion": 4, "addSdk": ["dyVideo"], "removeSdk": [], "featureOverrides": { "leaderboard": 0 } },
{ "id": "web", "name": "开放 Web", "appIdSuffix": "web", "sharePermille": 0, "minVersion": 2, "addSdk": ["adsense"], "removeSdk": ["iap"], "featureOverrides": { "iap": 0, "virtualPay": 0 } }
]
}
在本机跑,三个渠道解析出来的包配置是(同机相对量级,绝对数值不可移植):
| 渠道 | appId | 最低版本 | 分成 | SDK 清单 | 校验 |
|---|---|---|---|---|---|
| 微信小游戏 | com.pebble.mergeisland.wx | 3 | 40% | core+analytics+iap+wxPay | 通过 |
| 抖音小游戏 | com.pebble.mergeisland.dy | 4 | 20% | core+analytics+iap+dyVideo | 通过 |
| 开放 Web | com.pebble.mergeisland.web | 3 | 0% | core+analytics+adsense | 通过 |
三处细节值得看:最低版本——抖音渠道要求 4,高于基础配置的 3,解析后取 4;开放 Web 只要求 2,低于基础值,解析后仍是 3(取 max,不是覆盖)。SDK——开放 Web 从基础清单里卸掉了 iap(无内购),追加了 adsense。特性——开放 Web 的 iap 与 virtualPay 都被覆盖成 0,但 requiredFeatures(rewardedAd / share / cloudSave)仍然齐全,所以校验通过。如果某个渠道漏开了激励视频,missingFeatures 会立刻把它标出来。
P 层内核:跨语言对拍,证明合并方向没写错
一张表看不出「是模型对」还是「碰巧看着对」。本套用一份 P 层 TypeScript 内核 core/src/o09/pkg.ts 复算同一套解析逻辑:同样的覆盖优先、同样的 SDK 增删、同样的必填校验。它读同一份 config/o09_channel_pkg.json,产出与 Python 逐字符一致的摘要。
// file: core/src/o09/pkg.ts
// tier: P
// verify: node --test 'core/test/o09_*.test.ts'
//
// 渠道包差异化配置内核:与 sim/o09_pkg_validate.py 逐位等价的 TypeScript 对偶实现。
// 读同一份 config/o09_channel_pkg.json;把「基础配置 + 渠道覆盖」解析成渠道包配置,
// 并做必填特性校验。全整数、无浮点、无 Math.random(只用可擦除语法,禁 enum / namespace)。
export interface BasePkg {
readonly appIdPrefix: string; readonly minVersion: number;
readonly sdk: readonly string[]; readonly features: Readonly<Record<string, number>>;
}
export interface ChannelOverride {
readonly id: string; readonly name: string; readonly appIdSuffix: string;
readonly sharePermille: number; readonly minVersion: number;
readonly addSdk: readonly string[]; readonly removeSdk: readonly string[];
readonly featureOverrides: Readonly<Record<string, number>>;
}
export interface PkgConfig {
readonly version: number; readonly game: string; readonly base: BasePkg;
readonly requiredFeatures: readonly string[]; readonly channels: readonly ChannelOverride[];
}
export interface ResolvedPkg {
readonly id: string; readonly name: string; readonly appId: string;
readonly minVersion: number; readonly sharePermille: number; readonly sdk: readonly string[];
readonly features: Record<string, number>; readonly valid: boolean;
readonly missingFeatures: readonly string[];
}
/** 把「基础配置 + 渠道覆盖」解析成渠道包配置。 */
export function resolvePkg(base: BasePkg, ch: ChannelOverride): Omit<ResolvedPkg, "valid" | "missingFeatures"> {
const appId = `${base.appIdPrefix}.${ch.appIdSuffix}`;
const sdk = base.sdk.filter((s) => !ch.removeSdk.includes(s));
for (const s of ch.addSdk) if (!sdk.includes(s)) sdk.push(s);
const features: Record<string, number> = { ...base.features, ...ch.featureOverrides };
return { id: ch.id, name: ch.name, appId, minVersion: Math.max(base.minVersion, ch.minVersion),
sharePermille: ch.sharePermille, sdk, features };
}
/** 特性签名:按键名排序的 key=value,用 "|" 连接。 */
export function featureSig(features: Readonly<Record<string, number>>): string {
return Object.keys(features).sort().map((k) => `${k}=${features[k]}`).join("|");
}
/** 逐渠道解析并校验:必填特性齐全、SDK 非空、appId 唯一。 */
export function validate(cfg: PkgConfig): ResolvedPkg[] {
const seen = new Set<string>();
return cfg.channels.map((ch) => {
const r = resolvePkg(cfg.base, ch);
const missing = cfg.requiredFeatures.filter((k) => (r.features[k] ?? 0) !== 1);
const dupApp = seen.has(r.appId);
seen.add(r.appId);
const valid = missing.length === 0 && r.sdk.length > 0 && !dupApp;
return { ...r, valid, missingFeatures: missing };
});
}
/** 跨语言对拍摘要:与 sim/o09_pkg_validate.py 的 digest 逐字符一致。 */
export function digest(rows: readonly ResolvedPkg[]): string {
return rows.map((r) => `${r.id}:${r.appId}:${r.minVersion}:${r.sharePermille}:${r.sdk.length}`
+ `:${r.sdk.join("+")}:${featureSig(r.features)}`).join(",");
}
配套测试 core/test/o09_pkg.test.ts 把 Python 落盘的摘要写成金标准,断言 TS 内核复算结果逐字符相等,并顺手验合并方向、SDK 增删与校验规则。在本机跑 node --test 'core/test/o09_*.test.ts',6 个用例全过;两套独立实现给出同一个摘要:
digest = wechat:com.pebble.mergeisland.wx:3:400:4:core+analytics+iap+wxPay:cloudSave=1|iap=1|leaderboard=1|rewardedAd=1|share=1|virtualPay=1,douyin:com.pebble.mergeisland.dy:4:200:4:core+analytics+iap+dyVideo:cloudSave=1|iap=1|leaderboard=0|rewardedAd=1|share=1|virtualPay=0,web:com.pebble.mergeisland.web:3:0:3:core+analytics+adsense:cloudSave=1|iap=0|leaderboard=1|rewardedAd=1|share=1|virtualPay=0
只要有一边的合并方向、SDK 去重顺序或必填校验写错,摘要立刻分叉。这和卷二「一份逻辑、两种宿主」 是同一个信念:同一份配置,在 Python 仿真、TS 内核、真实打包脚本里必须长得一模一样。
X 层:真实渠道 appId 与 SDK 清单不可复现
边界必须划清。上面算的是模型侧:给定基础配置与覆盖规则,解析结果是什么。下面这些属于 X 层,需真实业务环境,命令行不可复现:
- 真实的渠道 appId(由各家后台生成,且常与签名、包名绑定);
- 真实的 SDK 清单与版本(各家 SDK 的接口、初始化时机、隐私声明都不同,且随版本变化);
- 真实的渠道最低版本要求与提审规则(渠道侧可随时调整)。
工程上正确的做法是:把可复现的解析规则与不可复现的真实清单分开——解析规则用来保证「基础配置改一处、所有渠道包同步更新」,真实清单靠渠道后台反标定(把真实 appId 与 SDK 版本填回 config,解析结果立刻反映现实)。第 11 章讲资质时会再次用到这套「配置驱动」的思路。
自检表
| 检查项 | 合格标准 |
|---|---|
| 单一基础配置 | 只有一份基础配置,渠道只写覆盖,不复制整份 |
| 覆盖方向明确 | 渠道覆盖优先于基础值,minVersion 取 max |
| SDK 可增可删 | 支持 addSdk 与 removeSdk,且去重保序 |
| 必填可校验 | requiredFeatures 漏开能被解析器标出 |
| 跨语言一致 | Python 与 TS 两套实现的摘要逐字符相等 |
| 边界清晰 | 真实 appId 与 SDK 清单标 X,不写成「实测」 |
渠道包最危险的错觉,是把「解析出来的配置」当成「渠道后台接受的配置」。解析只保证你的包内部一致,渠道后台是否接受要靠提审去证伪。下一章我们处理渠道合作之上更大的一层决策——自发行还是找代理发行。
阅读导航:上一节:9.2 联运分成与结算周期 · 下一节:10.1 自发行 vs 代理发行的决策 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。