《移动游戏产品的架构·客户端与移动运行时》2.1 表现、玩法、系统、平台四层与依赖方向

四层不是四个文件夹,而是四种能被单独验证的职责:平台层封装设备差异,系统层组织账号、支付与配置,玩法层维护纯状态,表现层只做映射。依赖只能向下,跨层调用必须经过接口;本节把职责、输入输出与典型误用写成可检查的契约,并给出允许矩阵与破例记录方式。

一次「只改 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 从下到上排列,依赖只能向下。下表是允许矩阵,行是调用方,列是被调用方。

调用方 \ 被调用方platformsystemgameplaypresentation
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 章;这里只定义职责与方向。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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