《互联网产品的客户端架构·端形态与体验基础》2.3 选型的判据矩阵

把团队规模、发布节奏、离线要求、性能敏感度与平台覆盖转成硬门槛加加权评分,用 TypeScript 选型函数输出排序、淘汰原因与决策记录,并以「回声」的默认事实演示 Web 胜出、桌面备选的完整推演,说明评分结果何时可信、何时必须重跑,以及组合策略的边界。

选型不是选最强,而是选约束下最合适

能力矩阵告诉我们每种端形态能做什么,但没有回答“这次该选哪个”。因为选型从来不是单目标优化:原生能力最全,但审核周期可能赶不上发布节奏;Web 覆盖最广,但离线与后台能力有限;小程序获客最快,但多平台覆盖与设备能力被宿主收窄;桌面常驻稳定,却需要用户先安装。

「回声」的第一次选型会议开了两个小时,前半段在比较“哪个端体验最好”,后半段在争论“团队能不能维护三端”。两个问题都重要,但混在一起就变成偏好之争。把问题拆开后,其实只有两步:先看哪些端形态被产品事实直接淘汰,再在剩下的候选里按权重排序。

这一步的关键,是把产品事实写成输入,而不是留在某个人脑子里。输入必须能被追问、被复核、被回放。下面这份判据矩阵不追求精确预测未来,它追求的是:同一个产品事实,任何人跑一遍都得到同一个排序和同一份淘汰理由。

文中出现的权重、单项评分与阈值都是案例假设值,用来演示判断过程,不是行业基准。真实项目应按自己的维护成本和发布约束重新校准。

判据矩阵的三层结构

第一层是硬门槛。只要产品要求“核心流程离线可用”,Web 与小程序就直接出局;只要发布窗口短于 7 天,需要商店审核的原生与小程序就出局;只要性能敏感度达到 3,帧预算不足的端形态就出局。硬门槛的作用是先把“不可能”拿掉,避免用高分掩盖致命缺口。

第二层是权重。权重不是固定的,它由产品事实调整:团队小于 5 人时,小团队友好度的权重上升;安装摩擦容忍度低时,安装摩擦的权重上升;不要求离线时,离线能力的权重下降。权重变化必须能从事实推导出来,不能凭感觉调。

第三层是评分。每个端形态在六个判据上有一个 0 到 5 的案例评分,乘以权重后求和。评分是相对值,不是绝对能力;它的作用是排序,不是证明某个端“更好”。

这三层的顺序不能反。先加权再设门槛,会让一个高分但根本不满足离线要求的方案留在候选里;先评分再定权重,会让权重变成对结果的解释,而不是对事实的建模。

把判据写成函数

下面的文件包含权重调整、单项评分、硬门槛、排序与决策记录生成。它只依赖一个类型导入,不读时钟、不读环境变量、不发请求。

// file: src/forms/form-selection.ts
// tier: L1
// verify: node --test test/forms/form-selection.test.ts

import type { ClientForm } from "./capability-matrix.ts";
import { CLIENT_FORMS } from "./capability-matrix.ts";

export type CriterionKey =
  | "cadence"
  | "team"
  | "offline"
  | "performance"
  | "coverage"
  | "install";

export type PlatformCoverage = "web-only" | "mobile" | "desktop" | "all";

export interface ProductFacts {
  teamSize: number;
  releaseCadenceDays: number;
  offlineRequired: boolean;
  performanceSensitivity: 1 | 2 | 3 | 4 | 5;
  platformCoverage: PlatformCoverage;
  installFrictionTolerance: "low" | "medium" | "high";
}

export interface Criterion {
  key: CriterionKey;
  label: string;
  scores: Record<ClientForm, number>;
}

/** 单项评分是 0 到 5 的案例假设值,不是行业基准。 */
export const CRITERIA: Criterion[] = [
  {
    key: "cadence",
    label: "发布节奏匹配",
    scores: { web: 5, native: 2, miniapp: 3, desktop: 4 },
  },
  {
    key: "team",
    label: "小团队友好度",
    scores: { web: 5, native: 2, miniapp: 4, desktop: 3 },
  },
  {
    key: "offline",
    label: "离线能力",
    scores: { web: 2, native: 5, miniapp: 1, desktop: 5 },
  },
  {
    key: "performance",
    label: "性能预算",
    scores: { web: 3, native: 5, miniapp: 3, desktop: 5 },
  },
  {
    key: "coverage",
    label: "平台覆盖",
    scores: { web: 5, native: 3, miniapp: 2, desktop: 3 },
  },
  {
    key: "install",
    label: "安装摩擦",
    scores: { web: 5, native: 1, miniapp: 4, desktop: 2 },
  },
];

const COVERAGE_SCORES: Record<PlatformCoverage, Record<ClientForm, number>> = {
  "web-only": { web: 5, native: 1, miniapp: 1, desktop: 2 },
  mobile: { web: 3, native: 5, miniapp: 4, desktop: 1 },
  desktop: { web: 4, native: 1, miniapp: 1, desktop: 5 },
  all: { web: 5, native: 3, miniapp: 2, desktop: 3 },
};

const FORM_ORDER: Record<ClientForm, number> = {
  web: 0,
  native: 1,
  miniapp: 2,
  desktop: 3,
};

/**
 * 产品事实只调整权重与硬门槛,不直接改单项评分。
 * 这样每次选型的变化都能解释成「哪个事实改变了权重」。
 */
export function effectiveWeights(facts: ProductFacts): Record<CriterionKey, number> {
  const weights: Record<CriterionKey, number> = {
    cadence: 20,
    team: 10,
    offline: 20,
    performance: 20,
    coverage: 20,
    install: 10,
  };
  if (facts.teamSize < 5) weights.team *= 1.5;
  if (facts.teamSize > 30) weights.team *= 0.8;
  if (facts.releaseCadenceDays <= 7) weights.cadence *= 1.3;
  if (facts.installFrictionTolerance === "low") weights.install *= 1.5;
  if (facts.installFrictionTolerance === "high") weights.install *= 0.6;
  if (!facts.offlineRequired) weights.offline *= 0.5;
  if (facts.performanceSensitivity >= 4) weights.performance *= 1.2;
  return weights;
}

export function scoreCriterion(form: ClientForm, key: CriterionKey, facts: ProductFacts): number {
  if (key === "coverage") return COVERAGE_SCORES[facts.platformCoverage][form];
  const criterion = CRITERIA.find((item) => item.key === key);
  if (!criterion) throw new Error(`未知判据:${key}`);
  return criterion.scores[form];
}

export interface FormScore {
  form: ClientForm;
  score: number;
  breakdown: Array<{ key: CriterionKey; weighted: number }>;
}

export function scoreForm(form: ClientForm, facts: ProductFacts): FormScore {
  const weights = effectiveWeights(facts);
  const breakdown = CRITERIA.map((criterion) => {
    const raw = scoreCriterion(form, criterion.key, facts);
    return { key: criterion.key, weighted: (raw / 5) * weights[criterion.key] };
  });
  const total = breakdown.reduce((sum, item) => sum + item.weighted, 0);
  return { form, score: Math.round(total * 10) / 10, breakdown };
}

export interface GateResult {
  form: ClientForm;
  passed: boolean;
  reason?: string;
}

/** 硬门槛先于加权分:不满足就出局,不允许用高分补回来。 */
export function gateForm(form: ClientForm, facts: ProductFacts): GateResult {
  if (facts.offlineRequired && scoreCriterion(form, "offline", facts) < 4) {
    return { form, passed: false, reason: "离线要求:该形态无法稳定支持核心流程离线" };
  }
  if (facts.performanceSensitivity >= 3 && scoreCriterion(form, "performance", facts) < 4) {
    return { form, passed: false, reason: "性能敏感度:该形态的帧预算不足" };
  }
  if (facts.releaseCadenceDays <= 7 && scoreCriterion(form, "cadence", facts) < 4) {
    return { form, passed: false, reason: "发布节奏:商店审核无法满足 7 天发布窗口" };
  }
  return { form, passed: true };
}

export interface SelectionResult {
  facts: ProductFacts;
  weights: Record<CriterionKey, number>;
  gates: GateResult[];
  ranking: FormScore[];
  chosen: ClientForm | null;
  runnerUp: ClientForm | null;
}

export function selectForm(facts: ProductFacts): SelectionResult {
  const gates = CLIENT_FORMS.map((form) => gateForm(form, facts));
  const passed = new Set(gates.filter((gate) => gate.passed).map((gate) => gate.form));
  const ranking = CLIENT_FORMS.map((form) => scoreForm(form, facts)).sort((a, b) => {
    if (b.score !== a.score) return b.score - a.score;
    return FORM_ORDER[a.form] - FORM_ORDER[b.form];
  });
  const eligible = ranking.filter((item) => passed.has(item.form));
  return {
    facts,
    weights: effectiveWeights(facts),
    gates,
    ranking,
    chosen: eligible[0]?.form ?? null,
    runnerUp: eligible[1]?.form ?? null,
  };
}

/** 生成一段可以直接贴进评审记录的文本,确保输入、门槛与结论同时留痕。 */
export function decisionRecord(facts: ProductFacts): string {
  const result = selectForm(facts);
  const rejected = result.gates.filter((gate) => !gate.passed);
  const lines = [
    "选型记录(案例假设值)",
    `输入:团队 ${facts.teamSize} 人,发布间隔 ${facts.releaseCadenceDays} 天,离线 ${facts.offlineRequired ? "必须" : "非必须"},性能敏感度 ${facts.performanceSensitivity},覆盖 ${facts.platformCoverage}`,
    `硬门槛:${rejected.length === 0 ? "全部通过" : rejected.map((gate) => `${gate.form}=${gate.reason}`).join(";")}`,
    `排序:${result.ranking.map((item) => `${item.form}=${item.score}`).join(" > ")}`,
    `结论:${result.chosen ?? "无"}${result.runnerUp ? `,备选 ${result.runnerUp}` : ""}`,
    "复核触发:离线要求变化、发布窗口缩到 7 天以内、性能敏感度升到 3 及以上、目标平台增减",
  ];
  return lines.join("\n");
}

代码里有三个刻意的设计。

第一,评分与门槛分离。scoreForm 只负责算分,gateForm 只负责判断是否出局。一个端形态可以分数很高但依然被门槛淘汰,这样评审时能清楚看到“它为什么不能选”,而不是只看到一个低分。

第二,权重调整集中在一个函数里。effectiveWeights 是唯一能改变权重的地方,任何权重变化都必须在这里留下理由。评审时不需要翻遍代码找“哪里偷偷加了权”。

第三,决策记录由函数生成。decisionRecord 把输入、门槛、排序、结论和复核触发写进同一段文本。它不能保证决策正确,但能保证决策可回放:三个月后有人问“当时为什么选 Web”,记录里有完整答案。

测试与真实输出

测试覆盖默认事实、离线加性能的移动场景、发布窗口淘汰、权重调整、动态平台覆盖、评分拆解与决策记录。完整测试文件是 test/forms/form-selection.test.ts。

在实验工程根目录执行:

node --test test/forms/form-selection.test.ts

本机一次运行的真实输出如下,耗时随机器变化:

✔ 回声的默认事实下,Web 以覆盖与安装摩擦胜出 (1.254541ms)
✔ 离线与性能同时硬性要求时,移动双端由原生胜出 (0.120292ms)
✔ 7 天发布窗口直接淘汰原生与小程序,而不是靠加权分补救 (0.127417ms)
✔ 离线硬门槛会把 Web 与小程序挡在候选之外 (0.520958ms)
✔ 小团队与低安装摩擦会真实改变权重 (0.091916ms)
✔ 平台覆盖是动态判据,同一端形态在 mobile 与 desktop 下分数不同 (0.060834ms)
✔ 评分拆解包含六个判据,且总分等于拆解之和 (0.072292ms)
✔ 决策记录同时保留输入、门槛、排序与复核触发 (0.177458ms)
ℹ tests 8
ℹ suites 0
ℹ pass 8
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 144.912458

测试里最值得关注的是“7 天发布窗口直接淘汰原生与小程序”。它验证的不是某个分数,而是顺序:硬门槛先执行,加权分不能把出局者救回来。如果先排序再过滤,原生可能因为设备能力得分高而排在前面,评审时就会产生“它明明分更高”的错觉。

一次完整的选型决策记录

用「回声」的默认事实跑一遍 decisionRecord,真实输出如下。输入是团队 6 人、发布间隔 7 天、离线非必须、性能敏感度 2、覆盖 all、安装摩擦容忍度低。

选型记录(案例假设值)
输入:团队 6 人,发布间隔 7 天,离线 非必须,性能敏感度 2,覆盖 all
硬门槛:native=发布节奏:商店审核无法满足 7 天发布窗口;miniapp=发布节奏:商店审核无法满足 7 天发布窗口
排序:web=87 > desktop=74.8 > native=59.4 > miniapp=57.6
结论:web,备选 desktop
复核触发:离线要求变化、发布窗口缩到 7 天以内、性能敏感度升到 3 及以上、目标平台增减

这份记录里有三个信息点容易被忽略。

第一,硬门槛淘汰了原生与小程序,理由都是发布节奏。这不是说它们能力不行,而是说在当前发布窗口下不可行。把“不可行”和“不好”分开,是选型记录最重要的作用。

第二,Web 的 87 分不是绝对值。它是在当前权重下的相对结果,权重又由产品事实调整而来。如果发布间隔从 7 天放宽到 30 天,原生会重新进入候选;如果离线要求变成必须,Web 会被门槛直接淘汰。分数只在同一组事实下可比。

第三,备选是桌面而不是原生。桌面在离线、性能、后台执行上都是 5 分,发布节奏也有 4 分,只是平台覆盖与安装摩擦拖低了总分。这个结果提示团队:如果后续需要客服工作台或常驻能力,桌面是自然的第二形态,而不是再做一个原生壳。

分数接近时看拆解

Web 87 分、桌面 74.8 分,差距不算小,但拆解之后能看到完全不同的形状。Web 的优势集中在发布节奏、团队友好度、平台覆盖与安装摩擦;桌面的优势集中在离线、性能与后台执行。两者不是“谁更好”,而是“哪组约束更重要”。

如果产品事实改成“离线必须、性能敏感度 3、覆盖 mobile”,排序会立刻反转:Web 与小程序被离线门槛淘汰,桌面被平台覆盖拖低,原生胜出。同一个函数、同一套评分,输入变了,结论就应该变。如果结论不跟着变,要么输入没变,要么权重写死了。

拆解还能暴露“假胜出”。某个端形态总分领先,可能只是因为一个权重被调得很高,而那个权重对应的能力并不是产品的真实瓶颈。评审时应该逐项问:这项加权分对应的事实是什么?如果事实不成立,分数还成立吗?

判据矩阵的边界

判据矩阵能把选型从争论变成可复核的推演,但它有明确边界。

它不能量化团队技能。一个没有原生经验的团队,原生的实际成本远高于评分表里的“小团队友好度 2 分”。技能缺口应该作为额外门槛或成本项补进去,而不是假装评分已经覆盖。

它不能预测平台政策。审核周期、设备权限、宿主限制都会变,权重和单项评分需要定期复核,不能一次校准用三年。

它不能替代组合策略。真实产品经常是“Web 主站 + 原生核心流程 + 小程序获客”的组合,而不是四选一。判据矩阵适合选“主形态”,组合策略要再单独评估共享层与维护成本。

它不能消除不确定性。评分是案例假设值,权重也是。它的价值不是给出正确答案,而是让错误答案以可讨论的形式出现:哪条门槛淘汰了谁,哪个权重改变了排序,哪个事实需要再确认。

小结

选型可以拆成三层:硬门槛先淘汰不可能,权重由产品事实调整,评分只负责在剩余候选里排序。三层顺序不能反,否则高分会掩盖致命缺口,权重会变成结果的解释而不是事实的建模。

把判据写成函数后,选型变成可回放的过程:同一组事实得到同一份排序、同一组淘汰理由和同一份决策记录。记录不保证决策正确,但它让“当时为什么这样选”有据可查,也让“什么情况下应该重新选”提前写清楚。

下一章进入体验的基础指标:首屏、可交互与流畅度。选型决定了端形态的上限,指标决定这个上限有没有被真正用出来。

阅读导航:上一节:同构与多端的技术边界 · 下一节:首屏、可交互与流畅度 。

常见错误与边界

  • 先排序再过滤。高分但违反硬门槛的方案必须出局,顺序反了就会产生“它分更高”的错觉。
  • 权重拍脑袋且不留痕。权重变化必须能从产品事实推导,并集中在一个函数里。
  • 把评分当绝对能力。评分是相对值,只在同一组事实与权重下可比。
  • 用总分掩盖缺口。拆解到判据,确认领先来自真实瓶颈,而不是被调高的权重。
  • 忽略团队技能。没有相关经验的端形态,实际成本远高于评分表。
  • 把一次选型当永久结论。平台政策、发布节奏与团队规模变化时,要重新跑一遍。
  • 用判据矩阵替代组合策略。主形态之外还有共享层、维护成本与获客路径。
  • 决策记录只写结论。输入、门槛、排序与复核触发缺一不可,否则无法回放。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查