ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

iOS 17.4 StoreKit故障排查:新旧API双轨冲突与迁移避坑实践

iOS 17.4 StoreKit故障排查:新旧API双轨冲突与迁移避坑实践 iOS 17.4 推送之后我这边连着接到三四个内购相关的报障现象都很类似用户升级系统后App 里的商品列表偶尔加载不出来支付成功后发货回调迟迟不触发甚至有人反馈同一笔订单被处理了两次。第一反应是 App Store Connect 后台配置出了问题可查了一圈产品 ID、协议、税务、沙箱账号全都没毛病代码也一个字节没动。后来把所有日志翻了一遍才确认问题出在 StoreKit 新旧两套 API 在 iOS 17.4 上并存导致的处理冲突。这篇内容主要想记录我在 iOS 17.4 上排查 StoreKit 故障的完整思路包括高频问题、定位链路、修复代码以及一套能快速复现问题的测试方法。如果你正在做内购功能尤其是老项目里还留着 StoreKit 1 的代码、同时又在逐步迁移 StoreKit 2这篇文章应该能帮你省掉不少弯路。1. 故障现场iOS 17.4 升级后的内购异常都长什么样1.1 三个被我亲手复现的现场先说我实际遇到的三类问题基本都是 iOS 17.4 真机、沙箱环境下复现的测试账号都是新建的App Store 后台配置也核对过没有任何变更。第一类是恢复购买没反应。用户点击恢复购买转圈一两秒之后界面就安静了既不弹成功提示也不报错。按理说即使没有可恢复的项目restoreCompletedTransactions也应该走完回调并返回空列表。但在这个场景下回调就像被吞了一样。第二类是支付成功但不发货。用户付款时一切正常支付宝 / 银行卡扣款成功的系统弹窗都出来了可 App 内部一直没有触发发货逻辑。最后是后台的 App Store 服务器通知V2补发订单才完成履约的。这意味着 StoreKit 的Transaction.updates没有把支付完成事件及时交给业务层。第三类是商品列表偶发为空。在 iOS 17.4 的设备上进入商店页SKProductsRequest偶尔返回一个空数组刷新几次又正常。同一个构建版本装到 iOS 17.3 的测试机上连续操作十几次都没出问题。这就排除了产品 ID 写错、未上架这些基础配置原因。这三类问题的共同点是代码没改系统变了行为就变了。我当时的直觉是苹果在 iOS 17.4 里对 StoreKit 的底层处理做了调整尤其是对 StoreKit 2 的接受度明显更高导致旧代码里一些以前能凑合跑的逻辑开始露出破绽。1.2 这些故障的共性特征把三个现场放在一起看规律很明显都是系统升级后出现不是功能迭代引入的问题都跟交易状态同步、商品请求这类基础能力相关都不是必现而是偶发靠用户反复支付或者反复进入商店页才能触发大多数情况下App 重启一次或者等一段时间就自行恢复了。这种幽灵故障最恶心因为你不容易在测试阶段稳定复现。但它们的根子都在同一个地方StoreKit 1 和 StoreKit 2 在同一个 App 里同时跑互相抢交易状态。iOS 17.4 之前系统对这种情况相对宽容但 17.4 之后底层的事务管理变得更严格原来被容忍的模糊状态开始暴露问题。2. 新旧StoreKit API的底层差异排查前必须理清的双轨逻辑2.1 StoreKit 1老伙计回调为王StoreKit 1 的核心是SKPaymentQueue加SKPaymentTransactionObserver。你要手动把观察者加入队列然后等系统通过 delegate 回调告诉你交易状态。import StoreKit class IAPManager: NSObject, SKPaymentTransactionObserver { override init() { super.init() SKPaymentQueue.default().add(self) } func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) { for transaction in transactions { switch transaction.transactionState { case .purchased: // 发货 deliver(transaction: transaction) SKPaymentQueue.default().finishTransaction(transaction) case .failed: SKPaymentQueue.default().finishTransaction(transaction) case .restored: deliverRestored(transaction: transaction) SKPaymentQueue.default().finishTransaction(transaction) default: break } } } }这套模式有一个关键点你必须在 App 启动早期把 observer 注册好否则在注册之前系统帮你挂起的交易就会漏掉。很多老项目都是这么过来的也都在启动的时候调了add(self)但这里埋了个雷如果你同时还在用 StoreKit 2 的异步监听同一个交易就会被两条链路各处理一次。2.2 StoreKit 2新管家异步序列StoreKit 2 的核心是Product、Transaction和Transaction.updates。它不再依赖 delegate而是用 Swift 并发把交易更新推给一个异步序列。import StoreKit class Store { func listenForTransactions() - TaskVoid, Never { return Task.detached { for await update in Transaction.updates { do { let transaction try await update.payloadValue await self.fulfill(transaction) await transaction.finish() } catch { log(Transaction update failed: \(error)) } } } } }这套 API 在并发安全上做得更好Transaction.updates会把待处理的交易源源不断地推过来直到你调finish()。问题在于它不认 StoreKit 1 的SKPaymentTransactionObserver两边各管各的。2.3 双轨并行的冲突点在哪很多老项目做迁移时不会一口气把 StoreKit 1 的代码删掉而是先加一套 StoreKit 2 的监听灰度观察。这就形成了双轨用户发起购买走的还是SKPaymentQueue同时Transaction.updates也在监听系统交易一笔交易完成StoreKit 1 的paymentQueue(_:updatedTransactions:)和 StoreKit 2 的Transaction.updates都会收到同一笔交易。iOS 17.4 之前系统对重复投递不敏感即使两边都收到业务层也可以在发货逻辑里做幂等。但 17.4 对 StoreKit 2 的默认支持度提高了Transaction.updates的投递时机更早有时候SKPaymentQueue那边还没回调StoreKit 2 已经把交易标成了purchased。于是发货逻辑 A 立刻执行了一次几秒后StoreKit 1 回调又触发了一次发货逻辑 B如果 A 和 B 操作的是同一个数据库记录又没有做幂等就会出现重复发卡、重复加会员时长这类严重问题。我遇到的那起重复发货就是这么来的。老代码只处理了 StoreKit 1 的回调新代码只处理了 StoreKit 2 的序列表面上各走各的实际上系统在 17.4 上把两边的投递时序拉得更近把原本靠时间差掩盖的冲突炸出来了。3. 从空商品列表到重复发货五种典型故障的定位链路这一章我会按现象 - 排查路径 - 根因 - 修复的顺序写每条链路都是我实际走过一遍的可以直接拿去对照。3.1 故障一SKProductsRequest 返回空数组现象商店页进入后付费商品列表偶发为空重新刷新能恢复。排查路径先检查所有产品 ID 是否在 App Store Connect 里真实存在且状态是准备提交 / 审核通过 / 已批准。检查是否是代码里 product ID 大小写不一致。我见过有人产品 ID 写的是com.example.product1后台却是com.example.Product1。用断点在productsRequest(_:didReceive:)里打住打印response.products和response.invalidProductIdentifiers。重点看invalidProductIdentifiers是不是包含了全部产品。如果全部失效说明产品配置有问题如果只有部分失效说明那部分产品 ID 写错或没上架。如果invalidProductIdentifiers为空response.products也为空那就不是后台配置问题而是请求链路本身异常。根因iOS 17.4 对SKProductsRequest做了更激进的结果缓存。如果你的 App 在同一个启动生命周期内对同一组产品 ID 连续发起多次请求后几次有可能直接复用上一次的空结果。这在老版本里很少见因为老版本每次请求都会实时走一遍网络。更新到 17.4 后缓存命中逻辑变了高频刷新商店页就容易踩中。修复不要再用 StoreKit 1 的SKProductsRequest刷新改用 StoreKit 2 的Product.products(for:)。import StoreKit func loadProducts(ids: SetString) async throws - [Product] { // StoreKit 2 的产品请求内部实现了更合理的缓存策略 let products try await Product.products(for: ids) if products.isEmpty { // 留一条日志方便远端排查 Logger.shared.log(Empty products for ids: \(ids), level: .error) } return products }实测下来切到 StoreKit 2 之后连续刷新几十次的空列表问题再也没有出现过。如果你暂时不想迁移也可以在每次请求前加一个 300ms 的小延迟规避同一生命周期里的缓存竞争但治标不治本。3.2 故障二支付成功但发货回调不触发现象用户购买弹窗正常出现支付也成功了但 App 内部收不到任何成功事件。排查路径先把沙箱账号退出登录重新用新沙箱账号再走一遍排除账号缓存问题。检查SKPaymentQueue.default().add(self)是否在application(_:didFinishLaunchingWithOptions:)里调用。如果你把它放到了某个业务类的初始化方法里而那个类没有被提前实例化就会漏掉交易同步。检查是否同时创建了多个SKPaymentTransactionObserver。比如老代码在 AppDelegate 里加了一个业务模块里又加了一个两个 observer 同时存在会导致回调竞争。用 Console.app 或 Xcode 控制台过滤StoreKit日志看系统是不是已经把交易标记为.purchased只是你的 observer 没有执行。根因在 iOS 17.4 上如果 App 已经声明支持 StoreKit 2通常是使用了Product.products(for:)或Transaction.updates系统会优先把交易状态同步给 StoreKit 2 的Transaction.updates。老代码里只有 StoreKit 1 的 observer等于一条腿走路交易状态被新系统交给了另一条腿老腿自然接不到活。修复要么把交易监听统一收敛到 StoreKit 2要么确保 StoreKit 1 的 observer 在 App 启动第一步就注册。I推荐直接使用 StoreKit 2 的Transaction.updates并且把发货逻辑写成一个独立方法这样新旧两条链路都能调用避免业务逻辑被绑定在某个 API 上。func observeTransactions() async { for await update in Transaction.updates { guard let transaction try? update.payloadValue else { continue } // 统一走发货逻辑 await AppIAPDeliverer.deliver(transaction: transaction) await transaction.finish() } }注意一个 App 里只保留一个Transaction.updates监听任务不要因为页面跳转就重复创建。我见过有人在商店页onAppear里又启动了一个监听结果一次购买触发两次发货。3.3 故障三同一笔交易被处理两次现象用户支付一次后台订单记录里出现两条已发货记录。排查路径先查订单表确认是同一笔 App Store 交易 ID 被记录了两次还是产生了两个不同的交易 ID。如果交易 ID 一样说明是客户端重复发货问题出在监听链路上。沿着启动日志找Transaction.updates的Task是从哪里创建的再用字符串搜索看哪里还持有SKPaymentTransactionObserver。重点检查理论上 StoreKit 1 的 finish 和 StoreKit 2 的 finish 只能调一次如果你两边都调了系统会重新投递或产生额外状态。根因双轨监听。SKPaymentTransactionObserver和Transaction.updates同时收到了同一笔交易两边都走了一遍发货逻辑。修复不要同时监听两条链路。如果必须兼容老版本 iOS可以写一个开关按系统版本切换到底用哪一套监听但不要同时启用。if #available(iOS 17.0, *) { // 走 StoreKit 2 统一监听 storeListener await StoreKit2Listener.start() } else { // 老系统仍然走 StoreKit 1 SKPaymentQueue.default().add(legacyObserver) }再进一步发货逻辑本身必须做幂等。不管监听链路怎么变同一个交易 ID 决不允许在订单表里存在两条记录。我一般会在发货前先查本地订单表如果交易 ID 已存在直接返回。3.4 故障四恢复购买转圈后无响应现象用户点恢复购买转圈结束什么回调都没有既没成功也没失败。排查路径检查你调的是SKPaymentQueue.default().restoreCompletedTransactions()还是 StoreKit 2 的Transaction.currentEntitlements。如果还在用前者检查paymentQueue(_:shouldRestoreTransactionsWithPamentQueue:)之类的方法是否被正确实现。检查交易监听是否已删除或覆盖。我遇到的情况是App 同时注册了一个 StoreKit 2 的Transaction.updates但恢复购买仍然通过老 API 发起系统在 17.4 上把恢复结果也优先投递给了 StoreKit 2 序列老 observer 什么都没收到。根因StoreKit 2 根本不鼓励使用恢复购买这个动作因为它会自动同步已购项目。如果你还在用旧 API 发起恢复同时又有新的监听在跑系统会认为你已经通过新链路完成了同步不再向老链路发送回调。修复在 iOS 17 环境直接使用Transaction.currentEntitlements查询权益把恢复按钮的语义改成重新读取当前已购项目并同步到本地。func restorePurchases() async { var restoredProductIDs: SetString [] // currentEntitlements 返回当前仍有权的所有订阅和购买 for await entitlement in Transaction.currentEntitlements { if let transaction try? entitlement.payloadValue { restoredProductIDs.insert(transaction.productID) } } // 用结果刷新 UI 和本地权益 await updateEntitlements(restoredProductIDs) }这样做的直接收益是不再依赖系统的完成回调恢复按钮永远不会出现转圈后没人理的状态。老版本系统上如果你还需要兼容那就老老实实把restoreCompletedTransactions()的结果在paymentQueue(_:updatedTransactions:)里处理并且确保监听器只存在一份。3.5 故障五沙箱测试时出现无法连接 App Store或价格异常现象iOS 17.4 沙箱环境下点击购买弹窗提示无法连接或者产品价格显示为 0 / 1 美元。排查路径先检查设备是否登录了正确的沙箱账号而不是常规 Apple ID。检查有没有在设置里退出 App Store 的账号沙箱测试必须处于登出状态然后在购买弹窗里登录沙箱账号。检查 Xcode 的 StoreKit Configuration 配置如果你给 scheme 挂了一个.storekit配置文件配置文件里定义的产品价格可能与 App Store Connect 不一致。根因iOS 17.4 的沙箱环境对本地 StoreKit 配置文件的识别更严格。如果你用了 StoreKit Test但 scheme 配置没有正确指向.storekit文件或者文件里的 product ID 跟代码对不上系统会直接返回异常内容。修复统一用一个StoreKit.storekit文件管理本地测试产品而不是依赖沙箱商店。{ identifier: A1B2C3D4, nonRenewingSubscriptions : [], products : [ { displayPrice : 6.00, familyShareable : false, internalID : product-coffee, productID : com.example.coffee, referenceName : Coffee, type : Consumable } ], settings : { _locale : en_US, _storeKitErrors : [ { code : 0, description : A localized description., domain : SKErrorDomain, userInfo : {} } ], _storefront : USA }, subscriptionGroups : [] }然后在 Xcode 的 Scheme - Run - Options - StoreKit Configuration 里选中这个文件。运行时会直接走本地模拟不消耗沙箱账号余额还能模拟退款、订阅过期、购买失败等场景测试效率高很多。实际生产代码里要记得用#if DEBUG来判断是否启用本地配置别把测试逻辑带上线。4. 用StoreKit Test和日志复现故障离线模拟比沙箱更高效4.1 StoreKit Test 配置文件怎么用才顺手我是在排查空商品列表问题时开始重度使用 StoreKit Test 的。之前一直靠沙箱环境每次都要在设备上切换账号速度慢而且网络不稳定时很难复现掉单场景。后来把 scheme 挂上.storekit文件直接在模拟器里就能模拟购买成功、失败、退款、重复购买这些状态。关键点是StoreKit Test 你完全可以把它当成一个本地假商店来用但要注意它并不能完全替代沙箱。真机上跑沙箱能验证设备端的支付流程、StoreKit 2 与 StoreKit 1 同时存在时的真实投递行为而 StoreKit Test 只模拟了 StoreKit 框架层的响应。遇到 iOS 17.4 这种系统行为变化最可靠的方式是先用 StoreKit Test 排除业务代码问题再上真机沙箱验证系统交互。4.2 日志和断点定位把玄学变成科学的两个工具排查 iOS 17.4 StoreKit 故障我强烈建议在启动阶段就加好统一日志并且给所有跟交易相关的入口打上标记。我自己习惯在核心方法入口打三个关键字段交易 IDtransaction.id产品 IDtransaction.productID购买时间transaction.purchaseDate这样一旦出现重复发货、回调丢失翻日志就能快速看到同一笔交易被哪几条路径打印过。另外Xcode 的 Debug - View Debugging - Capture View Hierarchy 对 UI 层问题有用但对 StoreKit 这类系统框架问题最好用断点。给paymentQueue(_:updatedTransactions:)和Transaction.updates的for await循环都打断点多跑几次你就能直观看到系统在 17.4 上到底先投递给谁。我当时就是靠这个发现系统把purchased状态同时投递给两条链路然后在finish()上产生了争夺。4.3 沙箱账号的干净状态管理iOS 17.4 之后沙箱账号复用很容易引发交易状态残留。如果 A 测试员在测试机上用某个沙箱账号买过一次订阅下一次测试不换账号系统可能会把上一轮的Transaction残留到currentEntitlements里干扰新逻辑的判断。我的做法是管理一批沙箱账号每个需求分别用一个新账号并且每次切换账号前清空对应 App 的数据设置 - 点击顶部 Apple ID - 媒体与购买项目 - 退出登录 设置 - 通用 - iPhone 储存空间 - 找到 App - 删除 App删掉重装是清理 StoreKit 本地状态最粗暴但最有效的方法。别嫌麻烦比起花半小时分析一个幽灵 bug重装一次的成本低得多。5. 升级适配中我踩过的坑与最后的建议5.1 三个很容易被忽视的隐蔽坑第一个坑是同一份代码里混用新旧 API 的幂等逻辑。你以为在发货方法里加了如果已存在订单就 return就够了但 StoreKit 2 的Transaction.updates有时会在你调用finish()之后因为网络波动再投递一次相同交易。如果你的幂等键只用了productID就会把新订单也拦掉。正确做法是把幂等键设为transaction.id这样不同订单不会被误伤。第二个坑是用#if canImport(StoreKit)判断是否支持 StoreKit 2。这个宏在 iOS 15 以上都会返回 true但 StoreKit 2 的部分 API 要 iOS 16 甚至 iOS 17 才可用。我实际遇到的是Product.SubscriptionInfo.Status在 iOS 17.4 上返回的state更细老代码只处理了.subscribed和.expired结果新的.inBillingRetryPeriod状态没有分支直接走了默认路径导致订阅状态展示错误。适配时一定要逐个检查 API 的最低系统版本别省。第三个坑是服务器收据校验没有处理 App Store 服务器通知 V2 的签名结构。iOS 17.4 对服务器通知 V2 的投递更积极了如果你的服务端还在用 V1 的unified_receipt字段客户端在沙箱和正式环境都会表现为支付成功但服务端不确认。这个排查起来特别隐蔽因为问题不在客户端翻代码翻到怀疑人生最后发现是服务端没有实现signedPayload的 JWS 验签。5.2 最后几项实在建议如果你正在做 iOS 17.4 的迁移适配我的经验总结下来就是这么几件事停止双轨监听。要么 StoreKit 1要么 StoreKit 2不存在第三种选择。过渡期可以用开关切换但绝不要同时跑两套。发货逻辑必须幂等并且幂等键用 transaction.id。尽快切到 StoreKit 2 的产品请求SKProductsRequest在 17.4 上的缓存问题会给你造成无意义的用户流失。多利用 StoreKit Test 模拟边界情况但上架前的一定要真机沙箱回归一张测试清单至少覆盖首次购买、恢复购买、重复购买、退款、订阅过期、后台自动续费、跨设备权益同步。服务端做好 App Store 服务器通知 V2 的验签不要只处理客户端自动上报的收据。iOS 17.4 之后苹果对服务端通知的依赖度更高了V1 随时可能被彻底停用。说实话iOS 17.4 这次的内购问题大部分并不是系统坏了而是苹果把所有开发者往 StoreKit 2 上推的趋势更明显了。老代码越早清理掉以后系统升级踩雷的概率就越低。真等用户量上来了再发现这些问题光补订单和安抚用户就会让你忙得连觉都睡不好。
返回列表