一次「照搬三消架构」的评审
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 的模块矩阵与真实依赖报告。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。