小程序里点不开的支付
一个功能在 Web 与原生端运行良好,上线小程序后,用户点击支付没有任何反应。排查发现代码这样写:
if (canPay()) { startPay(); }
canPay() 在 Web 上检查 window.PaymentRequest,在小程序里这个对象不存在,于是返回 false,按钮点击后什么也不发生。没有报错,没有提示,用户以为卡住了。
问题不是 canPay() 写错了,而是能力缺失没有对应的产品行为。能力探测如果只返回布尔值,调用方只能选择「做」或「不做」,而真实产品需要四种结果:原生做、用替代方案做、降级做、明确告诉用户做不了。
四种策略
| 支持状态 | 功能要求 | 策略 | 端上行为 |
|---|---|---|---|
native | 任意 | native | 直接调用平台能力 |
polyfill | 任意 | polyfill | 用替代实现完成同一语义 |
absent | required | block | 入口置灰或隐藏,说明原因 |
absent | optional | degrade | 提供较弱但仍可用的路径 |
关键在最后两行:必需能力缺失要阻塞,可选能力缺失要降级。把两者都当成「返回 false」,就会出现上面那种「点了没反应」。
反过来,把所有缺失都当成阻塞也不行。扫码是可选能力,没有相机时应该允许手动输入,而不是禁止用户进入结算页。
能力表与功能需求分开
能力探测的常见错误是把「端有什么」与「功能要什么」混在一张表里。两者应该分开:
- 能力表描述端:
camera: native、push: absent。 - 功能需求描述业务:扫码需要相机,但可选;支付需要支付能力,且必需。
分开之后,同一份功能需求可以在五个端上生成五份不同的计划,而不需要为每个端写一套判断。
把判定写成代码
// file: src/platform/capability-detect.ts
// tier: L1
// verify: node --test test/platform/capability-detect.test.ts
export type Capability = "camera" | "push" | "payment" | "offline" | "share" | "biometrics";
export type Support = "native" | "polyfill" | "absent";
export type Requirement = "required" | "optional";
export type Strategy = "native" | "polyfill" | "degrade" | "block";
export type CapabilityMap = Partial<Record<Capability, Support>>;
export type FeatureNeed = {
id: string;
capability: Capability;
requirement: Requirement;
};
export const CAPABILITIES: Capability[] = ["camera", "push", "payment", "offline", "share", "biometrics"];
export function supportOf(map: CapabilityMap, capability: Capability): Support {
return map[capability] ?? "absent";
}
export function strategyFor(support: Support, requirement: Requirement): Strategy {
if (support === "native") return "native";
if (support === "polyfill") return "polyfill";
return requirement === "required" ? "block" : "degrade";
}
export type PlanItem = {
id: string;
capability: Capability;
support: Support;
strategy: Strategy;
};
export function planFor(needs: FeatureNeed[], map: CapabilityMap): PlanItem[] {
return needs.map((need) => {
const support = supportOf(map, need.capability);
return {
id: need.id,
capability: need.capability,
support,
strategy: strategyFor(support, need.requirement),
};
});
}
export function blockedFeatures(needs: FeatureNeed[], map: CapabilityMap): string[] {
return planFor(needs, map)
.filter((item) => item.strategy === "block")
.map((item) => item.id);
}
export function degradedFeatures(needs: FeatureNeed[], map: CapabilityMap): string[] {
return planFor(needs, map)
.filter((item) => item.strategy === "degrade")
.map((item) => item.id);
}
export function capabilityScore(map: CapabilityMap): number {
let score = 0;
for (const capability of CAPABILITIES) {
const support = supportOf(map, capability);
score += support === "native" ? 2 : support === "polyfill" ? 1 : 0;
}
return score;
}
export function missingCapabilities(map: CapabilityMap): Capability[] {
return CAPABILITIES.filter((c) => supportOf(map, c) === "absent");
}
strategyFor 是全部逻辑的核心,只有四行,但把「支持状态 × 要求」的组合穷尽了。planFor 把功能需求映射成可执行的计划,blockedFeatures 与 degradedFeatures 则给发布检查提供输入:有阻塞项时,该端不能发版;有降级项时,需要在发布说明里写清楚。
测试与真实输出
✔ 六类能力全部登记 (0.039792ms)
✔ 未声明能力按缺失处理 (0.035042ms)
✔ 原生能力直接使用 (0.0385ms)
✔ 有 polyfill 时优先 polyfill (0.036917ms)
✔ 必需能力缺失时阻塞 (0.040208ms)
✔ 可选能力缺失时降级 (0.03475ms)
✔ 完整能力表让所有功能原生可用 (0.047292ms)
✔ 缺少支付能力会阻塞支付功能 (0.042625ms)
✔ 缺少相机只会降级扫码 (0.039458ms)
✔ 缺失多个必需能力时全部阻塞 (0.043125ms)
✔ 计划保留每项的支持状态 (0.051458ms)
✔ 能力得分按原生二分、polyfill 一分计算 (0.037792ms)
✔ 空能力表得分为零 (0.033958ms)
✔ 缺失能力按固定顺序返回 (0.040833ms)
ℹ tests 14
ℹ suites 0
ℹ pass 14
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 145.8525
注意「未声明能力按缺失处理」:能力表是部分映射,没写的键不能默认成可用。这是刻意的保守选择——漏配一个能力,宁可降级,也不要让用户在运行时撞上空指针。
一次「默认支持」的复盘
某端在能力表里漏配了 biometrics,而代码里的默认值是 native。结果在没录入指纹的设备上,登录页直接调用了生物识别接口,抛出异常,用户看到白屏。
修复只改了一行:默认值从 native 改成 absent。但这个 bug 暴露了更深的问题——默认值的方向决定了故障形态。默认可用,故障发生在运行时;默认缺失,故障发生在发布前的阻塞清单里。后者可控得多。
渐进增强,不是渐进降级
这两个词经常混用,但方向相反:
- 渐进增强:先保证基础路径可用,能力更强时再增强。
- 渐进降级:先按最强能力实现,能力不足时逐级退让。
多端产品应该选前者。基础路径是「所有端都能完成核心任务」,增强路径才是原生能力带来的体验。支付的基础路径是「能完成支付」,不是「必须用某种支付 SDK」;扫码的基础路径是「能输入编码」,相机只是增强。
按这个顺序设计,能力探测就不是补丁,而是主流程的一部分。
探测时机
能力探测应该在功能入口渲染之前完成,而不是在点击之后。原因有三个:
- 入口需要根据结果显示可用、降级或阻塞。
- 探测本身可能异步(权限查询、设备能力查询),点击后才开始会带来延迟。
- 探测失败要有兜底,放在入口层比放在动作层更容易统一处理。
一个实用做法是应用启动时生成能力快照,功能入口读取快照;权限变化时刷新快照,而不是每次点击都重新探测。
能力快照与刷新
能力探测的结果应该缓存成快照,而不是每次使用时重新查询。快照有三个字段:生成时间、端标识、能力表。
刷新时机有四种:
- 应用启动时生成一次。
- 权限变化时刷新,例如用户刚授予相机权限。
- 系统版本或客户端版本变化时刷新。
- 快照超过有效期(例如 24 小时)时后台刷新。
快照要有失效策略。一个常见的 bug 是:用户在设置里关闭了通知权限,应用仍拿旧快照显示「已开启」。刷新不是实时监听,而是「进入相关页面时校验一次」,成本更低且足够。
快照还必须可序列化,便于在崩溃日志里还原「当时系统认为端有什么能力」。这对排查「只在某些设备上出现」的问题非常有用。
探测的三类错误
假阳性。 探测说支持,实际调用失败。常见于浏览器 API 存在但行为不完整,或权限被系统策略禁用。对策是「探测 + 首次调用兜底」:探测只决定入口形态,真正调用时仍要处理失败。
假阴性。 探测说不支持,实际可用。常见于 polyfill 检测写得过窄,或新端的能力没有被识别。对策是保留手动覆盖开关,允许线上临时纠正。
过期。 快照与当前状态不一致。对策是给快照加时间戳与刷新触发点,关键操作前重新校验。
三类错误的产品后果不同:假阳性导致用户走到一半失败,假阴性让用户用不到本可用的功能,过期则表现为「有时行有时不行」。对应的监控指标也应该分开,而不是只有一个「能力探测失败率」。
能力、开关与实验的边界
能力探测、功能开关、A/B 实验经常被混在一起,但它们回答不同问题:
| 机制 | 回答的问题 | 决策依据 | 变化频率 |
|---|---|---|---|
| 能力探测 | 这个端能不能做 | 设备与平台事实 | 低 |
| 功能开关 | 现在要不要开 | 运营与风险 | 中 |
| A/B 实验 | 哪种做法更好 | 数据 | 高 |
一个功能最终是否可用,通常是三者的与:端有能力、开关打开、实验分组允许。判断顺序应该是先能力、再开关、最后实验,因为能力是事实,开关是策略,实验是优化。
把三者合并成一个布尔值,会让排查变得困难:用户说「按钮没了」,你无法判断是设备不支持、开关关了,还是分到了对照组。分开记录,日志里各留一个字段,问题会简单很多。
探测结果的产品表达
探测结果最终要变成用户能理解的状态。三种策略对应三种表达:
| 策略 | 入口状态 | 文案重点 | 用户动作 |
|---|---|---|---|
native | 正常可用 | 不需要解释 | 直接使用 |
polyfill | 正常可用 | 不需要解释 | 直接使用 |
degrade | 可用但较弱 | 说明替代方式 | 使用替代路径 |
block | 置灰或隐藏 | 说明原因与条件 | 换设备、改设置或放弃 |
degrade 的文案最容易被写坏。用户不需要知道「能力探测失败」,只需要知道「可以手动输入编码」。技术原因写进日志,产品语言写进界面。
block 的文案要给出可执行的动作。例如「当前设备不支持指纹登录,请使用密码登录」比「生物识别不可用」有用得多。如果没有任何替代路径,入口应该隐藏而不是置灰——置灰的按钮只会制造点击。
还有一个细节:降级与阻塞要在功能入口表达,而不是在用户操作之后。用户点进去才发现不能用,比一开始就看不到更糟。
一个真实的降级清单
某交易应用的能力降级清单如下:
| 功能 | 能力 | 缺失时 |
|---|---|---|
| 扫码加购 | 相机 | 手动输入商品编码 |
| 指纹支付 | 生物识别 | 密码支付 |
| 订单推送 | 推送通道 | 应用内红点与短信 |
| 离线订单 | 本地存储 | 仅在线查看,明确提示 |
| 分享卡片 | 端分享 SDK | 复制链接 |
| 图片搜索 | 相机与本地相册 | 隐藏入口 |
这张表的每一行都对应一个 optional 需求与一条降级路径。真正必需的只有支付与登录,其余都能降级。
清单的价值在于逼团队为每个能力想一个替代方案。想不出来时,要么这个功能其实不必要,要么它应该被标成必需并阻塞。两种情况都比「点了没反应」好。
探测性能与启动时间
能力探测本身也会影响性能,尤其是启动阶段。三条经验:
- 同步探测只做内存与配置检查,不做权限查询、设备枚举。
- 异步探测放到首屏渲染之后,不阻塞主流程。
- 探测结果缓存,同一进程内不重复查询。
如果某个能力查询必须同步且耗时,考虑把它从「启动探测」降级为「入口探测」:用户进入相关页面时再查,而不是一启动就查五个端的所有能力。
启动阶段每多 10 毫秒,用户感知不一定明显;但五个端各自加 10 毫秒,再加上失败重试,就可能变成可见的卡顿。探测要像网络请求一样有预算。
探测与开关的排查顺序
用户报告「功能不可用」时,按固定顺序排查:
- 能力快照里这一项是什么?——排除设备事实。
- 功能开关是开还是关?——排除运营策略。
- 实验分组是否允许?——排除灰度。
- 版本是否支持?——排除旧客户端。
- 权限是否授予?——排除系统设置。
五步都在日志里有字段,排查就不需要猜。顺序不能反:先怀疑设备能力,再怀疑开关,最后才怀疑代码。
一个实用做法是把这五个字段合成一个 featureAvailability 结构,随埋点上报。用户反馈时,直接看这条事件,比远程指挥用户截图快得多。
一张能力矩阵
把能力探测结果整理成矩阵,比散落的 if 更容易评审:
| 端 | 相机 | 支付 | 推送 | 离线 | 生物识别 | 得分 |
|---|---|---|---|---|---|---|
| Web | polyfill | native | polyfill | native | absent | 8 |
| iOS | native | native | native | native | native | 10 |
| Android | native | native | native | native | native | 10 |
| 小程序 | native | native | polyfill | polyfill | absent | 7 |
| 桌面 | absent | native | absent | native | native | 7 |
得分不是用来排名,而是用来发现「最弱端」。小程序与桌面并列最低,说明这两个端的降级路径需要重点测试。
矩阵里的 absent 不应该让整个功能消失,而应该触发前面说的降级清单。矩阵负责暴露事实,降级清单负责产品决策,两者分开,讨论才不会混在一起。
把探测结果写进日志
每次功能入口渲染时,记录一条结构化日志:功能 id、端、能力、支持状态、策略、开关、实验组。字段不多,但足够回答「为什么这个用户看不到按钮」。
日志要脱敏,不上报设备唯一标识;能力状态本身不是敏感信息,可以保留。采样率可以低,但阻塞与降级事件应该全量上报,因为它们直接对应可用性损失。
有了这条日志,「用户说不能用」就不再需要远程猜测,而是一次查询就能定位到具体环节。
小结
能力探测的输出不该是布尔值,而是策略。
- 支持状态分
native、polyfill、absent,功能要求分required、optional。 - 组合出四种策略:原生、polyfill、降级、阻塞;阻塞要有明确文案,降级要有可用路径。
- 默认值取「缺失」,让问题在发布前暴露。
- 先设计基础路径,再把原生能力当增强。
能力表写得越早,后面「某个端点不动」的问题越少。
阅读导航:上一节:1.2 共享内核与端上适配层 · 下一节:2.1 设计 token 与主题化 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。