三张不一样的矩阵
版本评审会上,三位负责人各自投出一张模块矩阵。客户端负责人按代码目录分,列出 14 个模块;发行同学按渠道能力分,列出 9 个;数据同学按埋点域分,列出 6 个。三张表都「对」,但没有一张能回答评审真正关心的问题:哪些模块两款产品共用,哪些必须隔离,出了问题找谁。
这不是沟通问题,而是矩阵缺了列。一张能用的模块矩阵至少要有四列:
| 列 | 回答的问题 | 缺失的后果 |
|---|---|---|
| 层 | 这个模块属于 platform / system / gameplay / presentation 哪一层 | 依赖方向无法校验 |
| 能力 | 它提供的是账号、支付、玩法内核还是界面能力 | 同名模块被当成同一个东西 |
| 负责人 | 出问题时谁响应、谁批准改动 | 共享模块无人维护 |
| 共享性 | 两款产品共用、各自隔离,还是产品专属 | 该隔离的被复用,该复用的被复制 |
本节把这张表做成一个可运行的审计产物:输入模块清单,输出矩阵、分层计数与问题列表。它不替代架构图,但能让架构图每年至少被机器检查一次。
共享性不是态度,是规则
「这个模块要不要共用」经常被当成政治问题。实际上它可以被压成三条规则,先看规则再看人情:
- 共享能力只能落在 platform 或 system 层。 账号、支付、活动配置、埋点、渠道构建这五项是典型共享能力。如果它们出现在 gameplay 或 presentation,说明共享逻辑已经渗进了玩法或界面。
- 隔离能力不能落在 platform 层。 玩法内核、资源包、性能预算、发布窗口属于产品隔离项。把它们下沉到 platform,等于让两款产品在最底层纠缠。
- 既不是共享也不是隔离的,标为产品能力。 例如
screen-stack、engine-host。它们可以由一款产品独有,也可以在另一款产品里重写;矩阵只记录事实,不强迫统一。
这三条规则来自第 2 章的分层契约与第 3 章的壳层差异。它们不是新原则,只是把已有原则变成了可检查的断言。
一个可运行的矩阵审计
下面的模块把规则写成代码。它不读文件、不访问网络,输入一组模块记录,输出矩阵行、分层计数和问题清单。
// file: src/lab/module-matrix.ts
// tier: L1
// verify: node --test test/lab/module-matrix.test.ts
import { ISOLATED_CAPABILITIES, isSharedCapability } from "../core/layers.ts";
import type { Layer } from "../core/layers.ts";
export type Sharing = "shared" | "isolated" | "product";
export interface ModuleRecord {
readonly id: string;
readonly layer: Layer;
readonly capability: string;
readonly owner: string;
}
export interface MatrixRow extends ModuleRecord {
readonly sharing: Sharing;
}
export interface MatrixAudit {
readonly rows: readonly MatrixRow[];
readonly byLayer: Readonly<Record<Layer, number>>;
readonly problems: readonly string[];
}
const LAYERS: readonly Layer[] = ["platform", "system", "gameplay", "presentation"];
const SHARED_LAYERS: readonly Layer[] = ["platform", "system"];
export function classifySharing(capability: string): Sharing {
if (isSharedCapability(capability)) {
return "shared";
}
if ((ISOLATED_CAPABILITIES as readonly string[]).includes(capability)) {
return "isolated";
}
return "product";
}
export function buildMatrix(modules: readonly ModuleRecord[]): MatrixAudit {
const seen = new Set<string>();
const problems: string[] = [];
const byLayer: Record<Layer, number> = {
platform: 0,
system: 0,
gameplay: 0,
presentation: 0,
};
const rows: MatrixRow[] = [];
for (const module of modules) {
if (seen.has(module.id)) {
problems.push(`duplicate module: ${module.id}`);
continue;
}
seen.add(module.id);
if (!LAYERS.includes(module.layer)) {
problems.push(`unknown layer: ${module.id} -> ${module.layer}`);
continue;
}
if (module.owner.trim() === "" || module.capability.trim() === "") {
problems.push(`empty owner or capability: ${module.id}`);
}
const sharing = classifySharing(module.capability);
if (sharing === "shared" && !SHARED_LAYERS.includes(module.layer)) {
problems.push(`shared capability in ${module.layer}: ${module.id}`);
}
if (sharing === "isolated" && module.layer === "platform") {
problems.push(`isolated capability in platform: ${module.id}`);
}
byLayer[module.layer] += 1;
rows.push({ ...module, sharing });
}
return { rows, byLayer, problems };
}
export function formatMatrix(audit: MatrixAudit): string {
const header = "id\tlayer\tcapability\tsharing\towner";
const body = audit.rows
.map((row) => `${row.id}\t${row.layer}\t${row.capability}\t${row.sharing}\t${row.owner}`)
.join("\n");
return body === "" ? header : `${header}\n${body}`;
}
export function coverageGaps(audit: MatrixAudit): readonly Layer[] {
return LAYERS.filter((layer) => audit.byLayer[layer] === 0);
}
export function sharedCount(audit: MatrixAudit): number {
return audit.rows.filter((row) => row.sharing === "shared").length;
}
export const DEFAULT_MODULES: readonly ModuleRecord[] = [
{ id: "host", layer: "platform", capability: "engine-host", owner: "客户端" },
{ id: "channel", layer: "platform", capability: "channel-build", owner: "发行中台" },
{ id: "account", layer: "system", capability: "account", owner: "账号组" },
{ id: "payment", layer: "system", capability: "payment", owner: "支付组" },
{ id: "activity", layer: "system", capability: "activity-config", owner: "运营平台" },
{ id: "analytics", layer: "system", capability: "analytics", owner: "数据组" },
{ id: "match3-core", layer: "gameplay", capability: "gameplay-core", owner: "消消客户端" },
{ id: "ark-core", layer: "gameplay", capability: "gameplay-core", owner: "方舟客户端" },
{ id: "resource-packs", layer: "system", capability: "resource-packs", owner: "客户端" },
{ id: "perf-budget", layer: "system", capability: "perf-budget", owner: "性能组" },
{ id: "ui", layer: "presentation", capability: "screen-stack", owner: "客户端" },
];
buildMatrix 做四件事:去重、校验层名、标记共享性、按层计数。任何一条不满足规则,都进入 problems,而不是被静默忽略。coverageGaps 负责回答另一个问题:哪一层在矩阵里是空的。
怎么读这份报告
用默认模块清单跑一遍,formatMatrix 输出的是这样一张表:
id layer capability sharing owner
host platform engine-host product 客户端
channel platform channel-build shared 发行中台
account system account shared 账号组
payment system payment shared 支付组
activity system activity-config shared 运营平台
analytics system analytics shared 数据组
match3-core gameplay gameplay-core isolated 消消客户端
ark-core gameplay gameplay-core isolated 方舟客户端
resource-packs system resource-packs isolated 客户端
perf-budget system perf-budget isolated 性能组
ui presentation screen-stack product 客户端
得到的是一个非常具体的分布:platform 2、system 6、gameplay 2、presentation 1,共享能力 5 项,问题 0 条。这个分布本身就是判断:
- system 层最厚,因为账号、支付、活动、埋点、资源包、性能预算都在这里。它是两款产品之间真正的接缝层。
- gameplay 层有两个模块,分别是消消的
match3-core与方舟的ark-core。同属gameplay-core能力,但 id 不同——这是刻意的:它们共享「纯逻辑内核」这条规则,不共享实现。 - presentation 只有一项,因为界面能力大多跟随产品独立演进,不适合做共享矩阵的最小单位。
问题清单则分三类,每一类对应一种真实事故:
| 问题 | 触发条件 | 对应事故 |
|---|---|---|
duplicate module | 同一 id 出现两次 | 两拨人改了同名模块,合并时互相覆盖 |
shared capability in gameplay | 支付或账号出现在玩法层 | 玩法代码直接调支付 SDK,换渠道时重写玩法 |
isolated capability in platform | 玩法内核下沉到平台层 | 两款产品的玩法被锁进同一个底层,无法独立发版 |
双案例:五项共享,两层隔离
用这张矩阵对照双案例,边界会变得很清楚。
共享的是五项能力:账号、支付、活动配置、埋点、渠道构建。它们在矩阵里全部落在 platform 或 system 层,负责人分别属于账号组、支付组、运营平台、数据组与发行中台。这也是「潮汐」IP 能共用一套中台的直接原因。
隔离的是玩法与资源:消消的 match3-core 与方舟的 ark-core 分属两个模块,资源包、性能预算、发布窗口也各自独立。案例设定中,消消首包 120MB、总资源 480MB,方舟首包 350MB、总资源 1.8GB——资源体量差近四倍,任何「统一资源包」的尝试都会把两款产品拖进同一套优化约束。
矩阵不阻止共享,它只是让每一次共享都留下负责人和层级记录。没有负责人的共享,等于没有共享。
矩阵不是一次性文档
一份矩阵画完就归档,三个月后一定过期。维护它不需要开大会,只需要四个触发条件:
| 触发条件 | 需要更新的列 | 常见遗漏 |
|---|---|---|
| 新增一项共享能力 | 能力、负责人、共享性 | 只加代码,不登记负责人 |
| 两款产品开始合并资源或性能策略 | 共享性、层 | 实际已共享,矩阵仍标隔离 |
| 新渠道或新区域接入 | 能力(渠道构建、区域配置) | 把渠道差异写进玩法层 |
| 团队合并或负责人转岗 | 负责人 | 事故时找不到响应人 |
每次更新只做两件事:改对应行,然后重跑 buildMatrix。如果 problems 从 0 变成非 0,先解决新增问题,再合并代码。问题清单的变化比矩阵本身更有信息量:它通常提前一到两个版本暴露「某个人绕过规则直接调用」的迹象。
评审时也有一个简单用法:不看矩阵全表,只看三处——coverageGaps 是否为空、sharedCount 是否与上次一致、problems 是否出现新条目。三处都正常,矩阵这一项就算过。
与相邻章节的关系
本节依赖第 2 章的分层契约(LAYER_CONTRACTS)与第 3 章的壳层边界;没有那两章,矩阵只能列出名字,无法判断层级归属。
本节产出的是「结构对不对」,下一节 13.2 负责「数值够不够」:把内存水位、帧预算与机型分档合并成两张可复核的基线表。13.3 再把结构、数值与评审清单合并成进入卷二的门禁。
小结
- 模块矩阵必须有四列:层、能力、负责人、共享性;少一列就无法审计。
- 共享能力只能落在 platform 或 system;隔离能力不能落在 platform;其余标为产品能力。
- 矩阵的价值不在画出来,而在每次版本都能重跑并输出问题清单。
- 双案例默认矩阵是 2/6/2/1 的层分布与 5 项共享能力;玩法内核按产品分 id,不共享实现。
- 一份没有
problems字段的矩阵,只是架构图,不是依赖报告。 - 维护矩阵只需四个触发条件:新增共享能力、开始合并资源或性能策略、接入新渠道或区域、负责人变更。
- 评审时只看三处:
coverageGaps为空、sharedCount与上次一致、problems没有新条目。
阅读导航:上一节:12.3 线上问题定位闭环 · 下一节:13.2 性能预算与机型分档基线 。
常见错误与边界
- 按组织架构画矩阵。 矩阵应按「层 + 能力」切,不按汇报关系切;否则一合并团队,矩阵就失效。正确做法是负责人作为一列,而不是分组维度。
- 把共享性当成一次性决定。 共享性会随产品阶段变化。消消上线初期可以把活动配置做成产品内模块,进入双产品阶段后再上移;矩阵要能记录这种迁移。
- 只检查重复 id,不检查空负责人。 空负责人的模块在事故时无人响应,比重复 id 更危险。
empty owner必须进问题清单。 - 把产品能力强行标成共享。 界面栈、玩法内核这类能力一旦共享,改动就要同时过两款产品的评审,发布节奏被锁死。矩阵应如实标
product。 - 矩阵跑一次就归档。 每季度或每次大版本前重跑一次,对比
problems的变化;问题数量上升通常早于线上事故。 - 用矩阵替代依赖检查。 本节检查的是模块归属,不是调用关系。跨层调用仍需第 2 章的边界测试来拦截,两者不能互相替代。
- 能力名称随手改。 同一个能力在矩阵里叫
payment、在代码里叫iap、在埋点里叫purchase,共享性判断就会失效。能力名应来自统一清单,新增名称先改清单再改代码。 - 只看总数不看分布。 「共 11 个模块」不说明任何问题;2/6/2/1 的层分布、5 项共享能力、0 条问题,才是可复核的状态描述。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。