同一份 Host 契约,两个壳长出了 if-else
拾光工作室共用账号、支付与渠道构建中台,桥接层本来也打算共用。合并代码时发现:消消的 account.login 失败后返回游客态,方舟的同一个调用会一直重试直到成功;消消的支付回调允许延迟处理,方舟要求进入战斗前完成渠道校验。桥接层里于是出现了 if (product === "ark") 的分支,随后又出现了第二个、第三个。
这不是两个产品「需求不同」那么简单,而是契约只声明了能力名,没有声明失败时怎么办。account.login 在两个壳里都叫这个名字,但一个可以降级、一个不能,桥接层只能靠产品名猜。本节把这种差异从左倾的分支里抽出来,变成壳配置的三个字段:支持哪些能力、哪些必须阻塞启动、失败时回退到什么。
消消仍是主案例,因为它的轻壳更接近大多数休闲产品的默认形态;方舟作为对照,只用来说明同一契约如何容纳更长的关键路径。
厚薄由关键路径决定
「轻壳」和「重壳」常被理解成代码量或接入 SDK 的数量。这个理解会误导排期:一个接入了十个 SDK 但全部延迟初始化的壳,启动体验可能比只接三个 SDK 却全部阻塞的壳更轻。真正决定厚薄的是启动关键路径上有多少能力必须等待。
判断一个能力属于哪一档,看三件事。
第一,是否 backed:契约声明需要这项能力,壳是否真的提供了适配器。声明了却没有实现,用户会在第一次调用时才发现,这比不声明更糟。
第二,是否 blocking:进入主玩法之前是否必须完成。阻塞能力决定冷启动的下限,也决定失败时用户能不能进去。
第三,失败时 fallback 是什么:none 表示无法降级,必须重试或提示;guest 表示可以游客态继续;stub 表示用空实现占位,功能暂不可用;deferred 表示推迟到空闲时再初始化。四者没有优劣,只有是否与产品目标匹配。
| 维度 | 消消轻壳 | 方舟重壳 |
|---|---|---|
| 启动阻塞能力 | 生命周期、隐私弹窗 | 另加权限、账号、防沉迷、渠道 SDK、支付 |
| 账号失败回退 | 游客态继续 | 无法进入,必须重新登录 |
| 支付失败回退 | 空实现占位,稍后重试 | 无法进入渠道服,必须完成校验 |
| 推送与埋点 | 延迟初始化 | 延迟初始化 |
| 启动步骤数 | 7 | 10 |
| 首包与单局 | 120MB,90 秒 | 350MB,8–12 分钟 |
表格里的「启动步骤数」来自壳配置的步骤列表,是可数的结构,不是性能指标。它不能用来说明哪个壳更快,只能说明哪个壳在启动时等待更多能力。
轻壳:消消的主线
消消的首包是 120MB,单局 90 秒,冷启动 P50 1.8 秒、P95 3.2 秒,低端机首帧 P95 是 2.6 秒。这组基线口径决定了一个优先级:任何不能直接挡住首帧的能力,都不应该放进阻塞路径。玩家打开应用的目标是马上消一局,而不是先完成一套账号与渠道流程。
因此轻壳只让两项能力阻塞:生命周期和隐私弹窗。生命周期阻塞是因为引擎必须先拿到前台状态才能开始渲染;隐私弹窗阻塞是因为在用户同意之前不能采集设备信息,也不能初始化需要设备标识的广告与埋点。两者都是合规或正确性的前置条件,不是产品选择。
账号走游客态。登录失败时直接生成临时账号进入游戏,进度先落在本地,等玩家主动绑定时再合并。支付与广告用空实现占位:商店 SDK 没有初始化完成时,商城入口可以展示但下单按钮置灰,广告位返回空结果,游戏主循环不受影响。权限与埋点延迟到首局结束后初始化,避免在首帧路径上弹系统权限框。
这套配置的代价是:游客进度的合并逻辑、支付未就绪时的 UI 状态、广告空结果的占位样式,都需要产品与客户端一起设计。轻壳不是「少做」,而是把复杂度从启动路径挪到了运行时的降级路径。
重壳:方舟的对照
《潮汐方舟》是卡牌策略 RPG,单场 8–12 分钟,首包 350MB,冷启动 P95 6.8 秒。账号、防沉迷与渠道 SDK 必须在进入战斗前完成,否则存档归属与支付回调都找不到对应用户。
它的重不在代码量,而在关键路径长度:七项能力阻塞启动,推送与埋点可以延迟,广告只占收入 10%,因此允许 stub。这里只作对照,不改变消消的主案例口径。
方舟的选择不是「更严格所以更好」,而是由强联网与渠道分发决定的。卡牌 RPG 的存档、赛季和支付都绑定账号,游客态无法参与异步 PvP;防沉迷是合规前置;渠道 SDK 决定登录与支付走哪条通道。把这些能力延迟,换来的不是更快的首帧,而是进入游戏后无法归属的存档。
真正需要共用的是契约,不是阻塞策略。两个产品可以都实现 account、billing、consent,但一个把 account 标为可回退,另一个标为不可回退。桥接层只读配置,不读产品名。
同一契约如何容纳两种壳
下面的 ShellProfile 把壳描述成一张能力清单:每一步有 capability、blocking、fallback 和 adapter。backed 从步骤列表推导出来,避免「声明支持但忘了实现」;contractGaps() 拿壳配置和基础契约比对,缺一项就返回一项。
BASE_CONTRACT 只包含两个产品都必须具备的六项能力:生命周期、权限、隐私、账号、支付、埋点。广告、推送、防沉迷、渠道 SDK 属于产品特有,放在各自步骤里,不进入基础契约。这样桥接层可以对着基础契约编程,产品差异留在配置层。
createLightShell() 与 createHeavyShell() 的差别只在步骤表,不在类型。轻壳把账号标为 guest、支付标为 stub;重壳把账号、防沉迷、渠道 SDK 标为 none。如果未来消消要上渠道服,只需要改配置并补适配器,不需要在桥接层加分支。
// file: src/platform/shell-profiles.ts
// tier: L2
// verify: node --test test/platform/shell-profiles.test.ts
import type { Host, Platform } from "./host.ts";
import { createMockHost } from "./host-mock.ts";
export type Capability =
| "lifecycle"
| "permissions"
| "consent"
| "account"
| "billing"
| "ads"
| "analytics"
| "push"
| "anti-addiction"
| "channel-sdk";
export const BASE_CONTRACT: readonly Capability[] = [
"lifecycle",
"permissions",
"consent",
"account",
"billing",
"analytics",
];
export type Fallback = "none" | "guest" | "stub" | "deferred";
export interface BootStep {
readonly capability: Capability;
readonly blocking: boolean;
readonly fallback: Fallback;
readonly adapter: string;
}
export interface ShellProfile {
readonly name: string;
readonly weight: "light" | "heavy";
readonly platformHost: Host;
readonly backed: readonly Capability[];
readonly steps: readonly BootStep[];
}
export const LIGHT_SHELL_STEPS: readonly BootStep[] = [
{ capability: "lifecycle", blocking: true, fallback: "none", adapter: "ActivityLifecycleAdapter" },
{ capability: "consent", blocking: true, fallback: "none", adapter: "ConsentDialogAdapter" },
{ capability: "permissions", blocking: false, fallback: "deferred", adapter: "RuntimePermissionAdapter" },
{ capability: "account", blocking: false, fallback: "guest", adapter: "AccountBridge" },
{ capability: "billing", blocking: false, fallback: "stub", adapter: "StoreBillingAdapter" },
{ capability: "ads", blocking: false, fallback: "stub", adapter: "AdMediationAdapter" },
{ capability: "analytics", blocking: false, fallback: "deferred", adapter: "AnalyticsAdapter" },
];
export const HEAVY_SHELL_STEPS: readonly BootStep[] = [
{ capability: "lifecycle", blocking: true, fallback: "none", adapter: "ActivityLifecycleAdapter" },
{ capability: "consent", blocking: true, fallback: "none", adapter: "ConsentDialogAdapter" },
{ capability: "permissions", blocking: true, fallback: "none", adapter: "RuntimePermissionAdapter" },
{ capability: "account", blocking: true, fallback: "none", adapter: "ChannelAccountAdapter" },
{ capability: "anti-addiction", blocking: true, fallback: "none", adapter: "AntiAddictionAdapter" },
{ capability: "channel-sdk", blocking: true, fallback: "none", adapter: "ChannelSdkAdapter" },
{ capability: "billing", blocking: true, fallback: "none", adapter: "ChannelBillingAdapter" },
{ capability: "push", blocking: false, fallback: "deferred", adapter: "PushAdapter" },
{ capability: "analytics", blocking: false, fallback: "deferred", adapter: "AnalyticsAdapter" },
{ capability: "ads", blocking: false, fallback: "stub", adapter: "AdMediationAdapter" },
];
export function createShellProfile(
name: string,
weight: "light" | "heavy",
steps: readonly BootStep[],
platform: Platform = "android",
): ShellProfile {
return {
name,
weight,
platformHost: createMockHost({ platform }),
backed: steps.map((step) => step.capability),
steps,
};
}
export function createLightShell(platform: Platform = "android"): ShellProfile {
return createShellProfile("潮汐消消", "light", LIGHT_SHELL_STEPS, platform);
}
export function createHeavyShell(platform: Platform = "ios"): ShellProfile {
return createShellProfile("潮汐方舟", "heavy", HEAVY_SHELL_STEPS, platform);
}
export function contractGaps(
profile: ShellProfile,
required: readonly Capability[] = BASE_CONTRACT,
): readonly Capability[] {
return required.filter((capability) => !profile.backed.includes(capability));
}
export function blockingCapabilities(profile: ShellProfile): readonly Capability[] {
return profile.steps.filter((step) => step.blocking).map((step) => step.capability);
}
export function bootOrder(profile: ShellProfile): readonly Capability[] {
return profile.steps.map((step) => step.capability);
}
export function fallbackFor(profile: ShellProfile, capability: Capability): Fallback | "missing" {
const step = profile.steps.find((item) => item.capability === capability);
return step ? step.fallback : "missing";
}
createShellProfile() 内部调用 3.2 的 createMockHost(),所以两个壳的 Host 事实都来自 src/platform/host-mock.ts,而不是各自复制一份平台桩。测试覆盖三件事:两个壳都满足基础契约;轻壳只阻塞生命周期与隐私,重壳额外阻塞账号、防沉迷与渠道 SDK;重壳特有的能力不会泄漏到轻壳。第三条断言看似多余,实际上防止的是「为了复用把方舟的适配器塞进公共包」,那会让消消的包体被动变大。
// file: test/platform/shell-profiles.test.ts
// tier: L2
// verify: node --test test/platform/shell-profiles.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { BASE_CONTRACT } from "../../src/platform/shell-profiles.ts";
import {
blockingCapabilities,
bootOrder,
contractGaps,
createHeavyShell,
createLightShell,
fallbackFor,
} from "../../src/platform/shell-profiles.ts";
test("both shells satisfy the same base contract", () => {
const light = createLightShell("android");
const heavy = createHeavyShell("ios");
assert.deepEqual(contractGaps(light), []);
assert.deepEqual(contractGaps(heavy), []);
assert.equal(light.weight, "light");
assert.equal(heavy.weight, "heavy");
});
test("the light shell blocks only lifecycle and consent", () => {
const light = createLightShell("android");
assert.deepEqual(blockingCapabilities(light), ["lifecycle", "consent"]);
assert.equal(fallbackFor(light, "account"), "guest");
assert.equal(fallbackFor(light, "billing"), "stub");
assert.equal(light.platformHost.shell, "activity");
});
test("the heavy shell blocks account, anti-addiction and channel sdk", () => {
const heavy = createHeavyShell("ios");
const blocking = blockingCapabilities(heavy);
assert.ok(blocking.includes("account"));
assert.ok(blocking.includes("anti-addiction"));
assert.ok(blocking.includes("channel-sdk"));
assert.equal(fallbackFor(heavy, "account"), "none");
assert.equal(heavy.platformHost.shell, "ui-view-controller");
});
test("heavy-only capabilities do not leak into the light shell", () => {
const light = createLightShell("android");
const heavy = createHeavyShell("android");
for (const capability of ["push", "anti-addiction", "channel-sdk"] as const) {
assert.equal(light.backed.includes(capability), false);
assert.equal(heavy.backed.includes(capability), true);
}
});
test("contract gaps are reported per capability", () => {
const light = createLightShell("android");
const narrowed = { ...light, backed: light.backed.filter((item) => item !== "consent") };
assert.deepEqual(contractGaps(narrowed), ["consent"]);
assert.deepEqual(contractGaps(light, [...BASE_CONTRACT, "push"]), ["push"]);
});
test("boot order starts with lifecycle and every step has an adapter", () => {
for (const profile of [createLightShell("android"), createHeavyShell("ios")]) {
assert.equal(bootOrder(profile)[0], "lifecycle");
for (const step of profile.steps) {
assert.notEqual(step.adapter, "");
}
}
});
test("a capability outside the profile reports missing", () => {
const light = createLightShell("android");
assert.equal(fallbackFor(light, "anti-addiction"), "missing");
});
什么时候轻壳会变重
壳的厚薄不是永久标签。活动、合规或分发变化都可能把一项能力推上关键路径。判断是否要改配置,可以用下面这张表。
| 判断 | 结论 |
|---|---|
| 能力失败会让用户无法进入主玩法 | 标为 blocking,fallback 至少为 none |
| 失败可以用游客态或空实现补偿 | 保持非阻塞,明确 fallback |
| 既不能阻塞、也无法补偿 | 契约变更,需要产品与合规一起评审 |
| 只有某个渠道需要 | 放进渠道配置,不进入基础契约 |
最后一行尤其重要。渠道 SDK 的差异应该留在渠道包配置里,而不是变成 Host 接口的一部分。否则每接一个渠道,引擎侧就要多认识一组概念,Host 会从平台事实抽象退化成渠道差异的集合。
与相邻章节的关系
3.1 确定了生命周期、权限、支付归壳;3.2 给出了跨边界的消息、线程与错误语义;本节把「哪些能力在启动时等待」变成配置。三节合起来是一条判断链:先定所有权,再定通信协议,最后定启动策略。
生命周期对启动的具体影响在第 4 章展开:冷启动、热启动与后台挂起会改变 blocking 能力的实际耗时,但不会改变所有权。内购合规的细节可以参考内购与合规 ;渠道与机型的组合验证可以参考机型兼容矩阵测试 ,那属于真机建模项,不在本节的 L2 范围内。
小结
- 壳的厚薄由启动关键路径决定,不由代码量或 SDK 数量决定。
- 每项能力用 backed、blocking、fallback 三个字段描述,产品差异留在配置层。
- 消消轻壳只阻塞生命周期与隐私,账号走游客态,支付与广告用空实现占位。
- 方舟重壳把账号、防沉迷、渠道 SDK 与支付放上关键路径,对照只用于说明契约容纳能力。
- 桥接层读配置而不读产品名;渠道差异进渠道包,不进 Host 契约。
阅读导航:上一节:3.2 桥接层设计:消息、线程与生命周期 · 下一节:4.1 冷启动、热启动与后台挂起 。
常见错误与边界
- 把厚薄等同于代码行数。接入很多但全部延迟初始化的壳可能更轻;看阻塞能力数量,不看文件数量。
- 所有能力都标 blocking。启动会被最慢的 SDK 拖住,且任何一个失败都挡住用户;只让正确性或合规前置条件阻塞。
- 在桥接层按产品名写分支。分支会随产品数量增长;把差异放进
ShellProfile,桥接层只读配置。 - fallback 写 none 却没有失败界面。无法降级意味着必须给用户明确提示和重试入口,否则就是白屏。
- 把渠道 SDK 差异塞进 Host 契约。Host 描述平台事实,渠道差异属于渠道包配置;混在一起会让引擎认识每个渠道。
- 边界:本节不验证真机启动耗时。配置只说明哪些能力阻塞,实际冷启动、热启动和低端机首帧必须在设备矩阵上测量,不能从步骤数推算。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。