一次「只改 UI」的返工
案例设定:潮汐消消每周二更新活动。某个家园装饰活动在 Android 渠道包上白屏,iOS 正常。排查发现,表现层为了少写一个适配器,直接拿平台层的文件句柄读取活动图;渠道包把资源放在不同目录,路径在 Android 上失效。修复只改了三行,但为了让同一个页面在两个平台都能回归,团队花了两天补接口和测试。
这次返工不是「代码写错了」,而是依赖方向写错了。表现层本来只需要消费两个结果:活动配置是什么、图片什么时候加载完成。它不应该知道文件从哪里来。平台的差异一旦穿过系统层,表现层就会长出平台分支;分支越多,两个产品能共享的发布流程越少。
四层架构要解决的就是这个问题。它不是把目录分成四个,而是把「谁可以知道什么」变成一条可执行的规则。下面的职责表先回答每一层该知道什么,再回答依赖为什么只能朝一个方向。
四层各自回答什么问题
| 层 | 核心职责 | 主要输入 | 主要输出 | 典型误用 |
|---|---|---|---|---|
| platform | 把引擎、原生壳、渠道 SDK、文件、网络与传感器包装成稳定的 Host 接口 | 设备能力查询、生命周期事件、渠道回调 | Host 接口、平台事件、原始资源句柄 | 向上读取玩法状态;把渠道差异泄漏给表现层;在平台层缓存业务配置 |
| system | 把账号、支付、配置、埋点、资源索引与存档组织成可替换的能力客户端 | Host 接口、配置版本、账号与订单状态 | 会话快照、配置快照、存档读写能力 | 直接操作界面节点;把玩法结算写进支付回调;在系统层判断关卡胜负 |
| gameplay | 维护纯玩法状态机、规则、结算与可重放的事件序列 | 规则配置、玩家输入意图、注入的 Clock 与 Random | 状态迁移、结算结果、玩法事件 | 直接播放动画或音效;读取平台文件句柄;用 Date.now 或 Math.random 代替注入 |
| presentation | 把玩法状态映射为界面、场景、动画、输入反馈与可访问性提示 | 玩法状态与事件、系统快照、平台安全区与语言 | 渲染指令、输入意图、界面事件 | 绕过意图直接改玩法状态;在视图里做结算;把平台分支散落在组件里 |
这张表的关键不是「职责描述得多漂亮」,而是每一行的输入和输出能否被单独替换。platform 的输出是 Host 接口,所以测试里可以换成 mock;system 的输出是快照,所以配置更新可以重放;gameplay 的输入是配置与注入的 Clock/Random,所以同一局可以在 Node 里复现;presentation 的输入是状态,所以界面可以在没有真实设备的情况下做快照测试。
反过来看,典型误用几乎都破坏了「输入可替换」:表现层直接读文件句柄,测试就必须有真实文件系统;玩法层直接播放动画,测试就必须有渲染器;系统层判断关卡胜负,支付回调就和玩法规则绑死。分层不是为了好看,而是为了让每一层都能被单独验证。
依赖方向不是「从上到下画箭头」
把四层按 platform、system、gameplay、presentation 从下到上排列,依赖只能向下。下表是允许矩阵,行是调用方,列是被调用方。
| 调用方 \ 被调用方 | platform | system | gameplay | presentation |
|---|---|---|---|---|
| platform | 同层 | 禁止 | 禁止 | 禁止 |
| system | 允许 | 同层 | 禁止 | 禁止 |
| gameplay | 允许 | 允许 | 同层 | 禁止 |
| presentation | 允许 | 允许 | 允许 | 同层 |
「允许」不等于「推荐」。presentation 直连 platform 只应发生在纯展示能力上,例如安全区、字体缩放、震动反馈;一旦涉及账号、支付、配置、存档,就必须经过 system。否则 system 提供的缓存、重试、埋点和灰度能力会被绕过,表现层又会重新长出平台分支。
「同层」也不是默认放行。同层模块之间可以合作,但同层循环依赖会让模块无法单独拆出;所以边界测试会把同层导入单独计数,而不是把它当成合法或非法两个极端。跨层跳过一层则是允许的:presentation 可以直接调用 platform,但这条边必须在模块图里可见,不能靠一个全局单例藏起来。
依赖方向被打断时,最常见的形式不是一个大箭头,而是一行 import。2.3 会把这一行 import 变成构建期断言;本节先把「哪条边应该存在」写进代码。
典型误用:破例如何变成架构
- 表现层读平台文件句柄。省掉一个资源适配器,代价是每个平台分支都要在 UI 里判断一次;活动资源换目录时,白屏只在部分渠道出现。
- 玩法层直接弹提示。玩法规则里出现「如果体力不足就弹窗」,内核就再也无法在没有界面的环境里运行;测试只能手点。
- 系统层判断关卡胜负。支付回调里写「购买体力后解锁下一关」,结算规则被拆到两个模块,回放和补偿都找不到唯一事实源。
- 平台层缓存业务配置。平台层本来只负责「读到哪里」,一旦缓存活动配置,热更和灰度就必须穿透平台层,平台接口被迫理解业务版本。
- 表现层持有比分。分数在动画回调里累加,跳过动画或降低帧率都会改变结果;同一局在不同设备上无法复现。
这些破例的共同点是「省一次接口」。第一次省下来的几行代码,会在下一次需求变更时变成跨层调试。分层规则的价值不是禁止破例,而是让破例必须被记录、被评审、被测试;如果一条破例连模块图里都看不见,它就已经变成了隐式架构。
双案例:同一条边界,两种压力
潮汐消消是弱联网休闲产品:案例设定首包 120MB、总资源 480MB、低端机占 35%、默认 30fps、单局 90 秒、每周一次活动资源更新。它的压力在「频繁小更新」:活动配置必须走 system,资源目录变化必须被 platform 吸收,表现层只接收「配置已就绪」和「图片已加载」。这条边界稳定,每周二的活动才能复用同一套发布流程。
方舟的边界压力不同。案例设定首包 350MB、总资源 1.8GB、强联网战斗、每两周一次版本更新。平台层多出网络会话与账号能力,但玩法层仍然只接收注入的随机数与时钟,不能直接读网络状态。
如果战斗内核直接判断「现在是否弱网」,异步 PvP 的回放就无法在客户端和服务端之间对齐;这条边界在方舟比在消消更贵,因为一次结算不一致会影响赛季结果。
低端机降级可以参考低端机降级 ,它属于 platform 与 presentation 的协作;活动资源热更可以参考远程热更 ,它属于 system 的配置与资源索引职责。两者都不应该让玩法层感知渠道或目录差异。
把职责写进代码
下面的 layers.ts 把职责表、允许目标和依赖分类写成数据与纯函数。它保留了第 1 章已有的 LAYER_ORDER、canDependOn、findUpwardEdges 与双案例驱动快照,只增加契约查询,不改变原有导出。
// 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";
}
这段代码有三个刻意的选择。
第一,职责表用 Record<Layer, LayerContract>,而不是一个数组。数组可以少写一个层而不报错,Record 会在类型层面要求四层齐全;以后增加一层,编译期就会提醒所有契约需要补全。
第二,allowedTargets 直接复用内部的 ALLOWED_LOWER,而不是重新写一份矩阵。允许关系只有一个事实源,测试和文章里的表格都从它推导;否则文档和代码会各说一套。
第三,classifyDependency 把「同层、向下、向上」分成三种结果,而不是一个布尔值。布尔值只能回答「是否允许」,无法区分「同层需要评审」和「向上必须阻断」;边界报告需要这两种信息。
对应测试在 test/core/layer-contracts.test.ts,覆盖四层契约完整、允许目标与向下规则一致、三种依赖分类。加上原有的 layers.test.ts,这两组共 7 条断言全部通过。它们不依赖设备、网络或引擎,是后续章节可以反复运行的基线。
与相邻章节的关系
1.3 已经给出 mga-lab 的骨架与验收口径,本节只把四层的职责和依赖方向补成契约。2.2 会把 gameplay 层里的三消玩法拆成纯逻辑内核,验证「输入可替换」不是一句口号。2.3 会把本节允许矩阵写成模块图断言,检查真实 import 是否越界。
分层职责与依赖方向的通用论证,可以对照四层职责划分 、依赖方向 和模块化 。本书的差异在于把结论落成可运行的 TypeScript 契约,并同时对照两个移动产品。
小结
- 四层分别回答「设备怎么接」「能力怎么组织」「规则怎么算」「状态怎么显示」,每一层都要有可替换的输入输出。
- 依赖只能向下;presentation 直连 platform 只适合纯展示能力,涉及账号、支付、配置、存档时必须经过 system。
- 同层依赖不是默认放行,跨层跳过必须可见;循环依赖会让模块无法单独拆出。
- 典型误用几乎都破坏了「输入可替换」,最终让测试依赖真实设备、文件系统或渲染器。
layers.ts用Record保证四层契约齐全,用单一允许矩阵保证文档与代码同源,用三分类区分同层评审与向上阻断。
阅读导航:上一节:1.3 mga-lab 的分层骨架与验收口径 · 下一节:2.2 纯逻辑内核:把玩法从引擎里拆出来 。
常见错误与边界
- 把四层当成四个目录。目录可以叫 platform,却在里面调用 UI;判断分层看依赖边,不看文件夹名字。
- 用「就这一次」为向上依赖开脱。每次破例都要在模块图里留下记录,否则下一位维护者只会看到一条既成事实。
- 让表现层直接读平台文件或网络状态。纯展示能力可以直连 platform,业务能力必须经过 system,否则缓存、重试、灰度都会被绕过。
- 在玩法层调用动画、音效或弹窗。玩法内核只输出状态迁移和事件,表现层决定怎么演;否则内核无法在无界面环境里测试。
- 只画依赖图,不写断言。图会过期,import 不会;2.3 用可执行断言替代评审时的口头约定。
- 边界:本节不讨论具体引擎 API 与渲染管线。引擎与原生壳的分工放在第 3 章,渲染性能放在第 6 章;这里只定义职责与方向。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。