《移动游戏产品的架构·客户端与移动运行时》3.3 双案例壳层差异:轻壳与重壳

壳的厚薄不取决于代码量,而取决于启动链路上有多少能力必须阻塞等待。消消的轻壳把账号、支付、广告降级为可延迟能力;方舟的重壳把账号、防沉迷与渠道 SDK 放上关键路径。同一份 Host 契约用能力、阻塞与回退三列容纳两者,桥接层不需要按产品写分支。

同一份 Host 契约,两个壳长出了 if-else

拾光工作室共用账号、支付与渠道构建中台,桥接层本来也打算共用。合并代码时发现:消消的 account.login 失败后返回游客态,方舟的同一个调用会一直重试直到成功;消消的支付回调允许延迟处理,方舟要求进入战斗前完成渠道校验。桥接层里于是出现了 if (product === "ark") 的分支,随后又出现了第二个、第三个。

这不是两个产品「需求不同」那么简单,而是契约只声明了能力名,没有声明失败时怎么办。account.login 在两个壳里都叫这个名字,但一个可以降级、一个不能,桥接层只能靠产品名猜。本节把这种差异从左倾的分支里抽出来,变成壳配置的三个字段:支持哪些能力、哪些必须阻塞启动、失败时回退到什么。

消消仍是主案例,因为它的轻壳更接近大多数休闲产品的默认形态;方舟作为对照,只用来说明同一契约如何容纳更长的关键路径。

厚薄由关键路径决定

「轻壳」和「重壳」常被理解成代码量或接入 SDK 的数量。这个理解会误导排期:一个接入了十个 SDK 但全部延迟初始化的壳,启动体验可能比只接三个 SDK 却全部阻塞的壳更轻。真正决定厚薄的是启动关键路径上有多少能力必须等待。

判断一个能力属于哪一档,看三件事。

第一,是否 backed:契约声明需要这项能力,壳是否真的提供了适配器。声明了却没有实现,用户会在第一次调用时才发现,这比不声明更糟。

第二,是否 blocking:进入主玩法之前是否必须完成。阻塞能力决定冷启动的下限,也决定失败时用户能不能进去。

第三,失败时 fallback 是什么:none 表示无法降级,必须重试或提示;guest 表示可以游客态继续;stub 表示用空实现占位,功能暂不可用;deferred 表示推迟到空闲时再初始化。四者没有优劣,只有是否与产品目标匹配。

维度消消轻壳方舟重壳
启动阻塞能力生命周期、隐私弹窗另加权限、账号、防沉迷、渠道 SDK、支付
账号失败回退游客态继续无法进入,必须重新登录
支付失败回退空实现占位,稍后重试无法进入渠道服,必须完成校验
推送与埋点延迟初始化延迟初始化
启动步骤数710
首包与单局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 描述平台事实,渠道差异属于渠道包配置;混在一起会让引擎认识每个渠道。
  • 边界:本节不验证真机启动耗时。配置只说明哪些能力阻塞,实际冷启动、热启动和低端机首帧必须在设备矩阵上测量,不能从步骤数推算。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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