ARTICLE · 1048376
苹果拒绝了我的 App:因为我的购买按钮太难找到

9 月 2 日,苹果拒绝了我的第 22 个构建。理由只有六个字:
我们无法购买内购项目(IAP)。
审核设备是一台 iPhone 17 Pro Max,iOS 26.6。
如果你上架过内购,你会知道这种感觉。一种特定的恐惧。因为 2.1(b) 听起来像是基础设施问题,而基础设施问题是最慢的那种。所以我做了所有人都会做的事,去检查整套技术栈。
每一项都检查过了,这恰恰是最糟的结果
我一项一项地过,想把它们全部列出来,因为这份清单的长度本身就是重点:
付费应用协议(Paid Applications Agreement)生效中。 8 月 12 日真的在沙盒环境完成过一次购买。Fingertips Unlock,在那个商店前端里售价 14.19 美元,一次性、无到期时间,至今还能在 RevenueCat 后台看到。 RevenueCat 里只有一个 offering,default,标记为 current,里面装着一个 $rc_lifetime 套餐,承载着 app.fingertips.pro,处于 active。 它的 App Store Connect 凭证三项检查全部通过。 这个内购有价格方案(price schedule),以美国区为基准价。 内购挂在了这次提交上。提交 f7e1392e 里有两项,版本和内购。 Entitlement.swift 自那次成功的购买以来没有变过,除了一个在 Release 里不生效的 #if DEBUG 块。
每一项检查都通过了。购买流程是通的。写完五天以后它真实运行过一次,现在它仍然可用。
于是只剩一个解释,而我花了太长时间才想到它。审核员找不到它。
这是一类和我在找的 bug 完全不同的 bug。我在找一个坏了的东西。可什么都没坏。有样东西是隐形的,而隐形的东西能通过你写得出的每一项测试。
一行代码
privatevar library: some View {Group {if isSideWindow { palette } else { splitLibrary } }isSideWindow 的值是 sizeClass == .compact。iPhone 永远是 compact。所以在 iPhone 上,这个应用永远渲染 palette,永远不会渲染 splitLibrary,也就永远不会渲染 sidebar。
而那行醒目的购买提示,那行写着「Buy Fingertips,还有 14 天」、根本不可能错过的提示,就住在 sidebar 里。
所以在苹果审核的那台设备上,付钱的唯一路径是这样的:
Menu("Library", systemImage: "line.3.horizontal") {// ... Folders submenu// ... Tags submenuButton(buyButtonTitle, systemImage: "cart") { showPaywall = true }}一个没有文字标注的汉堡菜单图标。点开,翻过 Folders 子菜单和 Tags 子菜单,才看到最底下的购买按钮。这要求一个陌生人在几分钟内做到,对着一个刚装好、完全不知道是干嘛的 App。
换作我也找不到。我从 8 月 17 日起每天都在用这个 App,之前并不知道这在 iPhone 上是唯一的购买方式,因为我不在 iPhone 上开发。我在 iPad 和 Mac 上开发,两者都会渲染 sidebar,都会显示那行醒目的提示。
你在哪一类设备上开发,决定了哪些 bug 对你隐形。 不是决定有哪些 bug 存在,而是决定哪些 bug 你从结构上是看不见的。
最扎心的部分
下面这行注释,早就躺在那个购买按钮上面,是几周前写的:
// The countdown lived in the toolbar and never fitted: at sidebar// width iOS truncated it to "8 da…", then hid it in its own overflow// menu, then rendered the button icon-only. Three attempts at the// same wrong place. A row has the whole column and cannot truncate,// it is the first thing in the sidebar rather than the last, and it// disappears entirely on purchase.之前把购买入口放进菜单的尝试,一共有三次。三次都失败了,每一次都有记录,最后归成一条我自己写下的规则。一行能占满整列,不会变形截断。
而紧凑布局,一直在用另一种方式、在另一个文件路径里,悄悄进行着第四次对同一错误位置的尝试。我学到过这个教训,把它写在有人会读到的地方,然后发布了一个从来没接受过这条教训的布局。
如果你的代码库里有一条注释,记录着某一件事失败过三次,那就去检查一下,第四次尝试是不是已经在别的地方跑起来了。我的就是,它让我在截止日期前的最后一个月,白费了一个审核周期。
第一次修复是一段死代码
这部分是我最想让别的开发者看到的,因为它是一个流程上的失败,而不是知识上的失败,而我自己也差点把它发布了。
显然的修复是加一个工具栏项。于是我加了一个,加了守卫,只在紧凑布局下出现。它编译通过,在评审里看起来完全正确。
它在 sidebar 的工具栏里。
sidebar 只在 isSideWindow 为 false 时渲染。新增的项却守卫在 isSideWindow 为 true。在这个视图所在的位置,这个条件永远不可能为真。它是一段死代码,读起来却像修复,躺在一份读起来完全正确的 diff 里,专门去回应一次被拒。
是一个评审 agent 对着周围文件读这个 diff 时抓住它的。我想说清楚它为什么能奏效,而我自己读的时候没发现。这个 bug 在 diff 里是看不见的。diff 显示的是一段看起来合理、被守卫的工具栏项。你得同时在心里记着这个 diff、和它所在视图的渲染条件,才能看出它们互相矛盾,而那个外围条件在 250 行之外。
针对一次被拒的修复,理应和引发被拒的代码一样被审视,而它通常得到的更少。 因为你心烦,时间又晚,而且这个修复明显是对的。
第二次修复也一样会被拒
哪怕放在正确的工具栏里,它也会失败。侧边窗口布局是 370pt 宽,已经有四个栏位项了。第五个会有一半滚到边缘外,而 iOS 26 会把溢出的部分折叠进一个 More 菜单。
那又是一个藏在无标注菜单里的购买按钮。那正是我刚收到的被拒理由,换了一条路,第三次出现。
真正有效的根本不是一个工具栏项:
// A `safeAreaInset`, NOT a toolbar item. The first attempt at this fix// WAS a toolbar item and it was dead code, because it was added to// `sidebar`'s toolbar where `isSideWindow` is false by definition. Even// in the right toolbar it would have been wrong: the side window is// 370pt, a fifth item renders half off the edge, and iOS 26 collapses// the overflow into a More menu - which is the rejection again. An inset// has the full width, cannot truncate and cannot collapse..safeAreaInset(edge: .top) {if entitlement.state != .purchased {Button { showPaywall = true } label: {HStack(spacing: Design.Space.tight) {Image(systemName: "cart")Text(buyButtonTitle) .font(Design.Text.body) .fontWeight(.medium)Spacer(minLength: 0)Image(systemName: "chevron.right") .font(Design.Text.meta) .foregroundStyle(Design.Ink.secondary) } .padding(.horizontal, Design.Space.regular) .frame(minHeight: Design.Row.minHeight) .frame(maxWidth: .infinity) .background(.bar) } .buttonStyle(.plain) .accessibilityHint("Opens the purchase screen") }}满宽,不会变形截断,不会折叠进溢出菜单,购买后自动消失。这跟侧边栏里那行注释几个月前得出的结论是同一个,只是用在了一个从没得到过它的布局上。
用肉眼验证过了,在 iPhone 17 Pro Max 模拟器上,正是被拒理由里点名的那台设备。「Buy Fingertips,还有 14 天」在启动时钉在列表上方,点它打开付费墙,付费墙显示真正的 9.99 美元,而不是一片空白。最后这个细节比看起来更重要。屏幕上有真实价格,说明 StoreKit 正确解析了产品,而这正是被拒理由看似指向、却从来都不是的东西。
Mac 也有同样的洞,而且更糟
一旦我弄清了这个 bug 的形状,我就去别处找它,这是对待这种 bug 唯一有用的做法。
Mac 版是一个菜单栏应用。承载购买提示的那个浏览窗口在启动时是 .suppressed 的,所以审核员打开 Mac 版,会看到一枚菜单栏图标,别的什么都没有。而菜单栏菜单里根本没有支付途径。购买隔着两步,藏在「Open Fingertips…」后面。
macOS 1.0(23)已经暂存、准备提交,一周后十有八九会拿到一模一样的 2.1(b)。现在,菜单栏菜单里的第一个入口就是购买项,在调色板上方,和 iOS 那行共用同一个标题属性,这样两个界面不会各自跑偏、报出不同的剩余天数。
这,才是认真排查一次被拒、而不是给表面的症状打补丁,换来的真实回报。一次被拒,两个平台被修复,其中一个还没被报告出来就先修好了。
修复过程中发现的两件事
一个坏掉的购买按钮有四个可能的原因,而它们全都静默无声。 无论 RevenueCat 没配置、offerings 拉取抛错、offering 里没有套餐,还是购买本身抛错,purchase() 都返回 .unavailable。从外面看一模一样。这花了我一个晚上,所以现在每种原因都会记录下来:
guardlet package = offerings.current?.availablePackages.first else {// The usual cause is StoreKit returning no product for the id, which// drops the package from `availablePackages` and leaves the offering// itself looking perfectly healthy.let current = offerings.current?.identifier ?? "nil"print("[entitlement] purchase unavailable: no packages in current offering " + "(current=\(current), offerings=\(offerings.all.keys.sorted()))")return .unavailable}注意这段注释。最难诊断的失败,是那种容器看着很健康、里面却空着的情况,而这恰恰是默认打印最少的那一种。
还有第二个、且独立的 2.1(b),躺在成功路径里。 一笔购买完成后,旧代码会调用 refresh() 重新读取权益状态。refresh() 要回网络去取,而那里返回 null,就会对一个钱已经付出去的人报失败:
// Trust the customerInfo StoreKit just handed us, BEFORE asking// again. `refresh()` re-fetches, and a nil answer on that second// call reported failure to somebody whose money had already gone -// the paywall saying "The App Store isn't reachable right now" over// a completed purchase. A reviewer seeing that is 2.1(b) on its own.if result.customerInfo.entitlements[Self.entitlementID]?.isActive == true { state = .purchasedreturn .purchased}StoreKit 会把一份带着刚成功那笔交易的、崭新的 customerInfo 交到你手上。再回网络问一遍,去确认你手里已经有的东西,只会丢掉信息。如果你要从这篇文章里带走一行代码,就带走这一行。
重新提交里藏着一个坑
如果你曾经在等审核的时候升过版本号,值得知道这个。
iOS 记录是 1.0,被拒,装着第 22 个构建。8 月 14 日,我把项目升到 1.1,合理地假设 1.0 很快就要过审。它始终没过。
一个标着 1.1 的构建无法挂到 1.0 那条记录上。它会强制新建一条版本记录,重新跑一遍元数据检查,要过 39 种语言。所以重新提交仍是 1.0,用命令行把版本号强行压回去,只动构建号。先是 24,因为 23 已经被 Mac 构建用掉了。然后当一次无障碍审计又发现付费墙上有两个偏小的点击目标时,变成 25。最终上架的是 1.0(25)。
审核进行的时候,不要升你的版本号。 升上去是免费的,退回来可不免费。
它过审了。 1.0(25)在 9 月 2 日被拒那晚重新提交,9 月 5 日通过。三天。没有第二次 2.1(b),审核员也没有再问购买入口在哪。
我想说清楚这能证明什么、不能证明什么。它不能证明我诊断对了,因为苹果不会告诉你。换个不同的审核员、在不同设备上,也许能找到旧汉堡菜单然后放行。它能证明的是更保守的那个说法。这次回去的应用,在任何布局下都有一个陌生人不可能错过的购买入口,而且它没有被再次拒绝。上面那些结论从来都不依赖其结果,这正是我为什么在结果出来之前就把它们写了下来。
带着同样修复的 Mac 构建,和 iPhone 过审是同一天进入审核的。这篇文章发布之后,我才会知道它结果如何。
该带走什么
一个审核员找不到的功能,就是不存在的功能。 你的用户找不到的功能也一样,而审核员只是第一个告诉你这件事的用户。我没有内购 bug。我有的是一个伪装成内购 bug 的可发现性(discoverability)bug,而我写得出的每一项测试,都跟着我一起认为购买是通的。
你对不使用的设备类别上的 bug,结构上是盲的。 我在 iPad 和 Mac 上开发。两者渲染的都是带着显眼购买提示的布局。而 iPhone,也就是审核员拿起来的那台,渲染的是没有的那一种。打开你的 App,要用被拒理由里那台设备类别,而不是你桌上这台。
给针对被拒的修复,要比引发被拒的代码更多的审视。 我的是一段读起来正确的死代码,而我的第二次尝试,会用另一套机制拿到同样的被拒。连着两个错误的修复,两者都编译通过、评审表现良好。
当你找到一种 bug 的形状,就去别的地方找这个形状。 Mac 有同一个洞,更深,而且没有被报告。如果只修掉被报告的那一处,一周后我只会再收到一次被拒。
还有,去读你自己旧注释。 我的注释记录了三次把购买按钮放进菜单的失败尝试,最后指向的那条规则,本可以阻止这一切。教训被写下来了。只是它没有被用到那个,在我学到它的时候、还不存在的布局上。
Fingertips 是一款面向 iPhone、iPad 和 Mac 的代码片段库,为 RevenueCat 2026 年 Shipaton 黑客松打造。它已上架 App Store,官网是 usefingertips.com。本系列此前还有:一个把不可达的 RevenueCat 变成无限许可证的 entitlement 检查;为什么不能在提交前给黑客松评委一个免费解锁;以及一个无视搜索工具、凭空编造数据的端上模型。
来源
原文标题:Apple Rejected My App Because Its Buy Button Was Too Hard to Find 作者:Peter Dongo 发布平台:HackerNoon 原文链接:https://hackernoon.com/apple-rejected-my-app-because-its-buy-button-was-too-hard-to-find
相关链接
Fingertips App Store 页面:https://apps.apple.com/app/id6798439217 Fingertips 官网:https://usefingertips.com/ 一个把不可达的 RevenueCat 变成无限许可证的 entitlement 检查:https://hackernoon.com/one-early-return-turned-my-14-day-trial-into-a-free-licence 为什么不能在提交前给黑客松评委一个免费解锁:https://hackernoon.com/can-you-give-a-hackathon-judge-a-free-unlock-before-you-submit-no-and-here-is-the-exact-409 一个无视搜索工具、凭空编造数据的端上模型:https://hackernoon.com/i-gave-an-on-device-llm-a-search-tool-it-ignored-it-and-made-up-data-instead
相关阅读:
一个运行了一年的个人 AI Agent:自动整理日历、邮件、会议和工作记忆
