9.3 渠道包与差异化配置

渠道包差异化配置解决的是「一套代码发多个渠道,每个渠道的包该长什么样」:把基础配置与渠道覆盖(appId 后缀、SDK 增删、特性开关)解析成最终包配置,并做必填特性校验。这一节用一份 Python 仿真与一份 TypeScript 内核跨语言逐位对拍,证明合并方向与校验规则没有写错。

前两节把「接哪个渠道」和「钱怎么分」讲完了,这一节处理最后一件事:包怎么发。一个游戏同时上微信、抖音、开放 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.wx340%core+analytics+iap+wxPay通过
抖音小游戏com.pebble.mergeisland.dy420%core+analytics+iap+dyVideo通过
开放 Webcom.pebble.mergeisland.web30%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 代理发行的决策 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

  1. 附录 F:全书公式、参数与数据集索引
  2. 9.3 同意管理与数据出境
  3. 9.2 COPPA 与儿童向游戏的限制