《移动游戏产品的架构·客户端与移动运行时》1.1 一个 IP 两款产品:为什么客户端架构不能按品类复制

拾光工作室把《潮汐消消》的客户端架构直接套到《潮汐方舟》,首帧、帧预算与联网假设同时失效。本节用六个运行驱动和一张共享/隔离表说明:品类只决定玩法内容,客户端架构必须按约束重新推导,并把判断写成可运行的层模型与测试,避免下一版被另一张架构图覆盖。

一次「照搬三消架构」的评审

2025 年 4 月,距离《潮汐方舟》6 月上线还有两个月,客户端负责人做了一件在当时看起来非常合理的事:把《潮汐消消》的启动链、UI 栈和资源加载目录整理成一份「通用客户端模板」,让方舟团队按目录复制。

理由很充分:两款产品同属拾光工作室的「潮汐」IP,同样用 Cocos,同样用 TypeScript,同样接账号、支付、活动与埋点中台。既然底层技术栈一致,复用一套架构似乎能省下大量重复工作。

第一次性能评审,三个问题同时出现:

  • 首帧:方舟首包 350MB、总资源 1.8GB,低端机 P95 首帧 5.4 秒;消消对应的是首包 120MB、总资源 480MB、首帧 2.6 秒。消消的启动链默认「先起 UI,再异步补资源」,方舟的登录前资源校验把这个异步过程变成了串行。
  • 帧预算:方舟默认 60fps,每帧只有 16.7ms,低档 30fps 也只有 33.3ms;消消默认 30fps,高档才到 60fps。三消里可以「下一帧再刷新」的列表更新,在方舟的战斗主循环里累计成了 jank。
  • 联网:消消是弱联网,除了登录、云存档、活动和内购,大部分时间可以离线推进;方舟是强联网战斗加异步 PvP、赛季结算。消消把网络失败当作「可重试的旁路」,方舟必须把断线当成状态机里的一等状态。

这三个问题都不是代码写得差,而是模板把消消的约束当成了所有产品的约束。评审最后没有换引擎,也没有推翻重写,而是做了一件更基础的事:把「哪些能复制、哪些必须重做」拆成可讨论的项。

品类标签不是架构输入

「三消」和「卡牌策略 RPG」描述的是玩法内容、单局节奏和付费结构,不是运行时约束。把它们当作架构输入,会得到两个方向相反的错误结论。

第一种错误是「休闲游戏不需要复杂分层」。消消的低端机占比 35%,高于方舟的 15%;它的弱联网形态又要求本地状态能在断网、杀进程、切后台之后恢复。这两条恰恰要求更严格的资源分层和状态保存,而不是更少。

第二种错误是「中重度游戏必须全套自研」。方舟的账号、支付通道与订单、活动配置、埋点与指标字典、渠道包构建,全部复用了中台。它真正隔离的是客户端层、玩法内核、资源包与热更节奏、性能预算和发布窗口。

所以品类相同,包体、帧率、联网可以差一个数量级;品类不同,共用中台反而可以完全一致。架构的输入应当是可观测、可比较的运行约束,而不是应用商店里的品类标签。关于分层职责与依赖方向的通用讨论,可以参考四层职责划分 与依赖方向 ,本节不重复那套 Web 客户端的论证,只讨论移动游戏多出来的约束。

真正决定架构的六个驱动

把两款产品的案例设定并排放在一张表里,差异立刻从「品类」变成了六行可比较的事实。

驱动潮汐消消潮汐方舟首先影响
单局/单场时长90 秒8–12 分钟状态保存与恢复粒度
联网形态弱联网:登录、云存档、活动、内购强联网战斗 + 异步 PvP + 赛季结算失败语义与会话状态机
资源体量首包 120MB / 总资源 480MB首包 350MB / 总资源 1.8GB加载分层与首帧路径
默认帧率30fps(高档 60fps)60fps(低档 30fps)每帧预算与任务切片
低端机占比35%15%机型分档与降级策略
更新频率每周 1 次活动资源每两周 1 次版本 + 每周活动热更边界与回滚

这张表里没有一个字段叫「品类」。单局 90 秒和 8–12 分钟的区别,决定了消消可以在每局结束时整体落盘,而方舟必须在战斗中途支持断线重连和快照恢复。首包 120MB 与 350MB 的区别,决定了消消可以把大半资源放到首次进入后下载,而方舟的登录前校验必须更早介入。低端机 35% 与 15% 的区别,决定了降级在消消是主路径,在方舟是保底路径。

同样的推理可以继续往下走:60fps 与 30fps 不是「画质选项」,而是每帧 16.7ms 与 33.3ms 的硬预算;每周活动更新与每两周版本更新,决定了热更目录的稳定边界不同。首帧优化的具体手段可以看首次加载与冷启动优化 ,本节的关注点是:这些差异必须在架构层被表达出来,而不是留到性能调优时再补。

共享能力,隔离实现

把六个驱动翻译成架构决策,得到的不是「两套完全独立的客户端」,也不是「一套到处打补丁的客户端」,而是一张有明确边界的共享/隔离表。

类别内容复用方式
共用账号体系、支付通道与订单服务、活动配置平台、埋点与指标字典、渠道包构建中台复用接口与数据契约,产品侧只做适配
隔离客户端层、玩法内核、资源包与热更节奏、性能预算、发布窗口各自演进,不互相引用

关键的一句话是:共用的是能力契约,不是层实现。账号 SDK 的登录流程可以共用,但登录后进入哪个场景、预加载哪些资源、失败后回到哪个页面,必须由各产品自己的系统层与表现层决定。支付通道可以和订单服务共用,但内购入口放在哪、补单提示怎么显示、审核期怎么降级,仍然由产品自己负责;合规细节可参考内购与合规 。

这也是「复制架构」和「复用中台」不矛盾的原因:前者复制的是层与层之间的调用关系,后者复用的是被调用方提供的能力。把两者混为一谈,就会出现「账号代码共用,所以 UI 栈也共用」的滑坡。

把判断写成可运行的层模型

口头结论很容易在下一个版本被推翻。mga-lab 的做法是把「层」「共享能力」「产品驱动」写成三个可断言的结构:层顺序与允许的依赖方向、五个共享能力与四个隔离能力、以及两款产品的驱动快照。下面的文件就是本节的可运行产物。

// file: src/core/layers.ts
// tier: L1
// verify: node --test test/core/layers.test.ts test/core/layer-contracts.test.ts

export const LAYER_ORDER = ["platform", "system", "gameplay", "presentation"] as const;

export type Layer = (typeof LAYER_ORDER)[number];

const ALLOWED_LOWER: Record<Layer, readonly Layer[]> = {
  platform: [],
  system: ["platform"],
  gameplay: ["platform", "system"],
  presentation: ["platform", "system", "gameplay"],
};

export function canDependOn(from: Layer, to: Layer): boolean {
  return ALLOWED_LOWER[from].includes(to);
}

export interface LayerEdge {
  readonly from: Layer;
  readonly to: Layer;
}

export function findUpwardEdges(edges: readonly LayerEdge[]): readonly LayerEdge[] {
  return edges.filter((edge) => !canDependOn(edge.from, edge.to));
}

export const SHARED_CAPABILITIES = [
  "account",
  "payment",
  "activity-config",
  "analytics",
  "channel-build",
] as const;

export const ISOLATED_CAPABILITIES = [
  "gameplay-core",
  "resource-packs",
  "perf-budget",
  "release-window",
] as const;

export type SharedCapability = (typeof SHARED_CAPABILITIES)[number];
export type IsolatedCapability = (typeof ISOLATED_CAPABILITIES)[number];

export function isSharedCapability(name: string): name is SharedCapability {
  return (SHARED_CAPABILITIES as readonly string[]).includes(name);
}

export interface ProductShape {
  readonly name: string;
  readonly sessionSecondsMin: number;
  readonly sessionSecondsMax: number;
  readonly firstPackageMb: number;
  readonly totalResourceMb: number;
  readonly targetFps: number;
  readonly lowEndSharePercent: number;
  readonly weakNetwork: boolean;
}

export const MATCH3_SHAPE: ProductShape = {
  name: "潮汐消消",
  sessionSecondsMin: 90,
  sessionSecondsMax: 90,
  firstPackageMb: 120,
  totalResourceMb: 480,
  targetFps: 30,
  lowEndSharePercent: 35,
  weakNetwork: true,
};

export const ARK_SHAPE: ProductShape = {
  name: "潮汐方舟",
  sessionSecondsMin: 480,
  sessionSecondsMax: 720,
  firstPackageMb: 350,
  totalResourceMb: 1800,
  targetFps: 60,
  lowEndSharePercent: 15,
  weakNetwork: false,
};

export type DriverField = Exclude<keyof ProductShape, "name">;

export const DRIVER_LAYERS: Record<DriverField, readonly Layer[]> = {
  sessionSecondsMin: ["gameplay", "presentation"],
  sessionSecondsMax: ["gameplay", "presentation"],
  firstPackageMb: ["system", "presentation"],
  totalResourceMb: ["system", "presentation"],
  targetFps: ["presentation"],
  lowEndSharePercent: ["platform", "presentation"],
  weakNetwork: ["platform", "system"],
};

export function changedDrivers(a: ProductShape, b: ProductShape): readonly DriverField[] {
  const fields = Object.keys(DRIVER_LAYERS) as DriverField[];
  return fields.filter((field) => a[field] !== b[field]);
}

export function impactedLayers(changed: readonly DriverField[]): readonly Layer[] {
  const touched = new Set<Layer>();
  for (const field of changed) {
    for (const layer of DRIVER_LAYERS[field]) {
      touched.add(layer);
    }
  }
  return LAYER_ORDER.filter((layer) => touched.has(layer));
}

export interface LayerContract {
  readonly layer: Layer;
  readonly duty: string;
  readonly inputs: readonly string[];
  readonly outputs: readonly string[];
  readonly misuse: readonly string[];
}

export const LAYER_CONTRACTS: Record<Layer, LayerContract> = {
  platform: {
    layer: "platform",
    duty: "把引擎、原生壳、渠道 SDK、文件、网络与传感器包装成稳定的 Host 接口",
    inputs: ["设备能力查询", "生命周期事件", "渠道回调"],
    outputs: ["Host 接口", "平台事件", "原始资源句柄"],
    misuse: ["向上读取玩法状态", "把渠道差异直接泄漏给表现层", "在平台层缓存业务配置"],
  },
  system: {
    layer: "system",
    duty: "把账号、支付、配置、埋点、资源索引与存档组织成可替换的能力客户端",
    inputs: ["Host 接口", "配置版本", "账号与订单状态"],
    outputs: ["会话快照", "配置快照", "存档读写能力"],
    misuse: ["直接操作界面节点", "把玩法结算写进支付回调", "在系统层判断关卡胜负"],
  },
  gameplay: {
    layer: "gameplay",
    duty: "维护纯玩法状态机、规则、结算与可重放的事件序列",
    inputs: ["规则配置", "玩家输入意图", "注入的 Clock 与 Random"],
    outputs: ["状态迁移", "结算结果", "玩法事件"],
    misuse: ["直接播放动画或音效", "读取平台文件句柄", "用 Date.now 或 Math.random 代替注入"],
  },
  presentation: {
    layer: "presentation",
    duty: "把玩法状态映射为界面、场景、动画、输入反馈与可访问性提示",
    inputs: ["玩法状态与事件", "系统快照", "平台安全区与语言"],
    outputs: ["渲染指令", "输入意图", "界面事件"],
    misuse: ["绕过意图直接改玩法状态", "在视图里做结算", "把平台分支散落在组件里"],
  },
};

export function layerContract(layer: Layer): LayerContract {
  return LAYER_CONTRACTS[layer];
}

export function allowedTargets(layer: Layer): readonly Layer[] {
  return ALLOWED_LOWER[layer];
}

export type DependencyKind = "same-layer" | "downward" | "upward";

export function classifyDependency(from: Layer, to: Layer): DependencyKind {
  if (from === to) {
    return "same-layer";
  }
  return canDependOn(from, to) ? "downward" : "upward";
}

这段代码做了三件事。

第一,LAYER_ORDER 固定了 platform → system → gameplay → presentation 的自下而上顺序,ALLOWED_LOWER 规定每一层只能依赖它下面的层。findUpwardEdges 不负责「修复」,只负责把违规边找出来,让评审看到具体是哪一条依赖越过了边界。

第二,SHARED_CAPABILITIES 与 ISOLATED_CAPABILITIES 把案例圣经里的共享/隔离决定固化成数据。共享能力是账号、支付、活动配置、埋点、渠道包构建;隔离能力是玩法内核、资源包、性能预算、发布窗口。这里没有「客户端层」这种模糊项,每个名字都能对应到一个可指派的负责人。

第三,MATCH3_SHAPE 与 ARK_SHAPE 保存了两款产品的驱动快照,changedDrivers 比较七个字段,impactedLayers 再把变化的驱动映射到受影响的层。这个映射不是性能模型,它只回答「哪些层需要重新论证」,不回答「需要多少工作量」。

代码验证了什么

模型本身也需要被模型验证。下面的测试把三条判断变成了断言:共享能力清单与工作室的实际分工一致;依赖只能向下;两款产品在全部七个驱动上都不同,因此四层全部受影响。

// file: test/core/layers.test.ts
// tier: L1
// verify: node --test test/core/layers.test.ts

import { test } from "node:test";
import assert from "node:assert/strict";

import {
  ARK_SHAPE,
  LAYER_ORDER,
  MATCH3_SHAPE,
  canDependOn,
  changedDrivers,
  findUpwardEdges,
  impactedLayers,
  isSharedCapability,
} from "../../src/core/layers.ts";

test("shared capabilities follow the studio split", () => {
  for (const name of ["account", "payment", "activity-config", "analytics", "channel-build"]) {
    assert.equal(isSharedCapability(name), true, name);
  }
  for (const name of ["gameplay-core", "resource-packs", "perf-budget", "release-window"]) {
    assert.equal(isSharedCapability(name), false, name);
  }
});

test("layer dependencies only point downward", () => {
  assert.equal(canDependOn("presentation", "gameplay"), true);
  assert.equal(canDependOn("gameplay", "system"), true);
  assert.equal(canDependOn("system", "platform"), true);
  assert.equal(canDependOn("platform", "system"), false);
  assert.equal(canDependOn("gameplay", "presentation"), false);

  const edges = [
    { from: "presentation" as const, to: "gameplay" as const },
    { from: "platform" as const, to: "system" as const },
    { from: "system" as const, to: "gameplay" as const },
  ];
  assert.deepEqual(findUpwardEdges(edges), [edges[1], edges[2]]);
});

test("the two products differ on every architectural driver", () => {
  const changed = changedDrivers(MATCH3_SHAPE, ARK_SHAPE);
  assert.equal(changed.length, 7);
  assert.deepEqual(impactedLayers(changed), [...LAYER_ORDER]);
});

test("a product compared with itself touches no layer", () => {
  assert.deepEqual(changedDrivers(MATCH3_SHAPE, { ...MATCH3_SHAPE }), []);
  assert.deepEqual(impactedLayers([]), []);
});

第三条断言值得展开。changedDrivers 返回 7 个字段(单局时长拆成最小值和最大值,所以比上表的六个驱动多一个字段),impactedLayers 返回全部四层,这说明「按品类复制」在这两个案例里没有任何一层可以原样照搬。但同一份文件里,isSharedCapability("account") 仍然为真,五个共享能力一个不少。两个结论同时成立,正是本节想说的架构切法:层要重新推导,能力可以复用。

测试里还有一条反向用例:产品与自身比较时,变化驱动为空,受影响层也为空。它看起来不起眼,却能防止后续有人把「比较」写成「只要不是同一个对象就返回全部层」——那种实现会让模型永远报警,最终被团队忽略。

与相邻章节的关系

本节回答「为什么不能复制」,第 2 章回答「那应该分成什么样」。2.1 会把四层的职责、输入输出和典型误用逐层展开;2.3 会把这里的 findUpwardEdges 扩展成构建期可执行的边界测试。本节只保留最小骨架,避免和第 2 章重复定义。

与《互联网产品的客户端架构》的分层章节相比,本书多出的约束是移动运行时:内存水位、帧预算、机型分档、弱网与强网、热更节奏。那本书讲的是 Web 客户端的层间依赖,本节讲的是移动游戏为什么必须把这些运行约束写进层决策;两者可以互相参照,但不能互相替代。

资源体量与首帧的关系会在第 6 章展开,帧率与热更边界会在第 9 章展开,发布窗口与灰度会在第 13 章收口。本节只负责把一个模糊的「照搬」问题,变成一张可以评审的差异表。

小结

  • 品类描述玩法内容,不描述运行时约束;同品类可以差一个数量级,跨品类可以共用中台。
  • 六个驱动——单局时长、联网形态、资源体量、默认帧率、低端机占比、更新频率——才是客户端架构的输入。
  • 共用能力契约,隔离层实现;账号和支付可以共用,UI 栈和玩法内核不能。
  • 把判断写成可运行模型,才能在下一个版本站住脚;模型只判断「哪些层受影响」,不冒充工作量估算。

阅读导航:上一节:目录与学习路径 · 下一节:1.2 移动端约束清单:内存、电量、发热、网络、机型 。

常见错误与边界

  • 把「同引擎」当成「可复制架构」。同用 Cocos 只说明 API 相同,不说明帧预算、包体和机型分布相同;复制目录之前先复制那张驱动表。
  • 把中台复用扩大成客户端层复用。账号、支付、活动、埋点可以共用接口,场景栈、资源目录和 UI 导航不能共用。
  • 用平均帧率代替帧预算。60fps 看的是 16.7ms 的单帧上限,平均值好看不代表没有超过预算的长帧。
  • 把弱联网的失败处理直接搬到强联网。重试、幂等、状态回滚和断线重连是四件不同的事,消消的「失败即旁路」在方舟会变成状态不一致。
  • 只比较品类,不比较低端机占比。35% 与 15% 决定降级是主路径还是保底路径,这会反过来改变资源加载和 UI 复杂度的上限。
  • 把「受影响」误读成「必须重写」。impactedLayers 只说明该层需要重新论证,最终是复用、适配还是重写,要看 13.1 的模块矩阵与真实依赖报告。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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