产品10/12/2026·约 6 分钟

四个副本,一个事实,三十一天的债

我们下掉一个功能,忘了 App Review 备注——它仍然让审核员去按一个已经不存在的按钮。同一份文案住在四个各自独立的文件里。

那个按钮已经不存在了

9 月 11 日,我们把 Wavecraft 的免费试用下掉了。那是一个我们主动做出的决定,理由和工程无关:这个试用没有挣回它自己的位置。我们删掉了 introductory-offer 的配置,从付费墙的 FAQ 里拿掉了那个问题,还加了一条测试——只要那个问题回来,测试就红。因为付费墙的 FAQ 是静态文案,对每个用户渲染同一份,所以一个关于试用的静态答案,对一个从来没有试用过的人也在说谎。

然后我们就停下了。

剩下的文案还在描述这个试用。两个订阅产品的 App Review 备注仍然告诉审核员:对于符合 introductory offer 条件的账号,去点一个写着「Start 7-Day Free Trial」的按钮。这个标签已经三十一天不存在了。不是一句含糊的、过期的承诺,而是一条指令,带着一个被引号引起来的按钮标签,让你按一个已经被删掉的东西。

我们公开的 FAQ 在另一个方向上有同样的问题。一个关于 Pro 的回答承诺了无损导出、全部母带预设、无限次 AI 人声分离,还有最后一句「并移除广告」。这个应用里没有广告。产品里没有任何广告代码。那句话曾经是真的,大概是在某个早期设计阶段,而它比功能活得更久。

第二处值得停下来想一想,因为它是更丢人的那种失败。下掉一个试用是会到处留下可见残迹的商业决定,而一个关于你从未发布过的功能的承诺,什么都不留下。没人会因为看一眼构建结果就注意到「并移除广告」。

商店页面是用来做什么的

这件事发生在一个具体的语境里,而语境决定了你该怎么看它有多疏忽。

我们刚刚为这个产品打开了付费投放。搜索广告,一个市场,很小的日预算。在一个列表页上,那是任何人看到的第一眼;而在审核员拿着清单读的页面上,四个各自独立的地方在做关于产品能力的主张,其中一个不只是过期,而是无法照着执行。

说"这本来应该被发现"很容易。更难也更值得写下来的问题是:为什么没有任何东西发现它。 会失败的构建会被找到。说谎的文案只会被一个读它的人找到,而会去读一个自己没写的产品的 App Review 备注的人非常少,并且他们没有理由在那天去看。

四个文件,一个事实

最终找到它的审计并不聪明。它走遍产品对自己做出主张的每一个地方,对每一条主张问一句:现在还成立吗?那份清单比两项更长:试用文案、广告承诺、一个和枚举对不上的效果器数量,以及一句关于「轨道不设限」的措辞——它字面上不算假,但它描述的是你最后得到的编曲结果,而不是你经历的录音过程。你确实一次只能叠一条 take,轨道数确实不设限。"轨道不设限"说的是结果。

清单本身不是有意思的部分。有意思的是接下来。

这些主张住在四类文件里,每一类都有自己的存在理由:

  • 商店列表元数据,十九个语言;
  • 每个订阅产品的 App Review 备注;
  • 每个内购项目的 App Review 备注;
  • 官网的公开 FAQ。

其中没有任何一个是另一个的拷贝。它们各自写在各自被消费的地方,因为订阅的审核备注和终身购买的审核备注本来就是两份不同的文档,而且同一句话不可能同时服务审核员和潜在用户。这个推理是对的。它也正是让一个事实往四个方向漂移的形状。

因为它们是四份文档,改一个事实意味着四处编辑,四处编辑意味着得有人记得四处,而记得是整个系统里唯一的机制。记得不是一个机制。它是一个习惯,而习惯是静悄悄地失效的——这正是审核备注漂移了一个月才有人读它的原因。

文案表面是一份写义务。没有 owner 的义务不会慢慢腐坏。它会精确地停在你忘掉的那一天。

这不是文案专属的失败。它就是那个 README 描述了你改过名的 flag、帮助文档描述了你挪走位置的菜单、营销站描述了你下线的价格档。机制永远是同一个:一个事实、几个陈述它的地方、以及一个把人连起来的唯一东西。

门知道,但没人问它

让修法成为可能的部分在这里。

产品早就有答案,在代码里,在一个地方。一个五个 case 的枚举,列着购买到底解锁什么:人声分离、自动混音、母带链、分轨导出、批量导出。产品里被 gate 的一切都从它派生。付费墙的能力块从它派生。工具旁边的 PRO 徽标从它派生。它自己的文档说:在这个枚举里就是有门,不在里面就是免费,而两种出错方式——「承诺了一个没有门的功能」和「有门却没卖」——"都不可能靠遗忘产生"。

这个说法是真的,在下掉试用那天也是真的,而它当时毫无帮助。枚举知道 Pro 是什么。没有任何东西把枚举和那四个文件连起来。

产品代码拥有真相。而那个 lint 商店元数据的仓库工具刻意不构建依赖任何产品的客户端代码,因为耦合到某一个产品源码树的工具就不再是仓库工具,而变成了那个产品的构建的一部分。于是枚举和 linter 在两张不同的构建图里,这两者按构造就看不见对方。

把缺口换个说法:枚举是那个事实的唯一 owner;文案在四个地方,而没有任何单一事实源指向它们。在两者之间,什么都没有。

两道守卫,一道缝

修法是两道守卫,而且刻意不是一道,因为一道守卫没法同时待在两张构建图里。

第一道是仓库商店元数据工具里的一个新 lint 类别。 它读取每份审核备注现在携带的一个机器可读的 Pro 能力列表,并比较同一个产品的这些备注之间的列表。它无法把它们和枚举比较,因为它拿不到枚举。所以它做它能做的另一半:三份备注必须声明同一组能力。代码里的注释把这件事写得明明白白,并把另一半点名成产品自己的测试。

列表的顺序不重要——同一组能力换个顺序不算分歧。这是刻意的:一个会在重新排序时报警的检查,惩罚的正是那个做「把东西保持同步」这类机械工作的人,而那恰恰是你想让他继续做的人。

第二道是产品里的一个测试。 它拿一份审核备注里的声明和枚举的 case 比较,失败时给出一条说明该怎么办的消息:改备注,或者如果它本来就该免费,就把这个能力从枚举里拿掉。它读的那份备注被打包成测试资源,而只需要一份的理由写在构建文件里:第一道守卫已经证明另外两份声明了完全相同的集合,而三份同名文件在测试 bundle 里本来就会撞名。

两道守卫,一道看不见枚举,另一道看不见另外的备注。任何单独一道都会留下一个和这个 bug 一样大的洞。

让守卫保持诚实的两条规则

守卫是一个承诺:坏事情会变响。一道大部分时候是对的守卫不是守卫,是建议。两条规则让它们不至于变成建议。

守卫不能对它不 管的事开火。 一个完全不做 Pro 主张的产品,没有义务为了满足一条 lint 规则而发明一个主张。而让那条错误消失的方式是声明范围,不是删掉文案。产品里的测试还会在某份备注停止声明范围时失败,因为一个空的声明会让散文漂回它想说的任何话。

守卫必须能做完它的工作。 一条 lint 规则没法 import 一个 Swift 枚举,所以它没法和枚举比较,它只能互相比较声明。这个上限正是产品那道测试存在的理由。一道试图在一个地方同时做完两半的守卫,会在其中一半上失败,并且在另一半上静默地失败。

小结

文案表面是一份写义务,而没有 owner 的义务不会慢慢腐坏;它精确地停在你忘掉的那一天。11 号我们下掉免费试用,而 App Review 备注在下一个月的 12 号仍然让审核员去按一个标着「Start 7-Day Free Trial」的按钮。三十一天没人发现。

下掉一个功能,而每一个提到它的文案表面此刻都是错的;如果那些表面是四份独立的文档,改这个事实就意味着记得改四处。订阅的审核备注确实和终身购买的那份是不同文档,这也是那四份文档无法合并成一份的原因。它同样也是没有任何东西把它们连起来的原因。

给每一个文案表面一个机器可读的声明,然后比较这些声明之间的差异。文案本身可以继续手写;不再漂移的是散文所承诺的那一组东西。每份备注里有一份声明、备注之间互相比较,于是四个拷贝只能偏离那份声明,永远不会彼此偏离。

并且把检查放在 owner 旁边。 那些互相比较声明的 lint 不构建依赖产品代码,也永远不会有,因为耦合到某一个产品源码树的工具就不再是工具。而拿声明与枚举比较的测试,只能存在于那个枚举可以被 import 的地方。它们之间那道缝就是设计本身,而不是设计里的一个缺口。

文中提到的产品

继续阅读