
简介这份资源是面向iOS开发者的苹果内购IAP支付工具示例工程基于Swift语言实现适合正在学习或需要集成应用内购买功能的中级开发者参考。工程围绕StoreKit框架展开涵盖产品请求、支付队列监听、交易状态处理、恢复购买及收据验证等核心环节并涉及消耗品、非消耗品与订阅三类产品的配置思路。压缩包共31个文件约66KB以9个swift源码文件为主体配合5个plist配置、3个h头文件、3个json数据文件及storyboard界面文件、entitlements权限文件等结构完整便于直接运行调试。目前已有1703人学习下载。通过阅读源码读者可以掌握SKProductsRequestDelegate与SKPaymentTransactionObserver的代理实现方式理解支付请求添加、交易更新回调、恢复购买失败与成功等分支处理逻辑并参考ATS配置与订阅管理要点快速搭建稳定合规的内购支付模块。1. 苹果内购支付工具从 StoreKit 2 到订单校验的完整落地路径做过 iOS 内购的同行大概都有同感StoreKit 1 时代的支付流程像一间没开灯的房间SKPaymentQueue的代理回调什么时候来、来几次、丢单了怎么补全靠经验和日志硬扛。苹果在 StoreKit 2 里用 async/await 重写了整套 API把交易监听、凭证校验、订阅状态查询收拢成几个可等待的方法代码量能砍掉一半以上。这份 Swift 内购支付工具就是围绕 StoreKit 2 搭的一套可复用封装覆盖商品拉取、购买、交易验证、订阅状态判断和恢复购买这几条主线。它适合正在做会员订阅、虚拟道具或去广告解锁的 iOS 开发者尤其是被丢单和沙盒环境反复折磨过的人。下面按「它是什么、怎么接、坑在哪、怎么验」的顺序拆开讲。2. StoreKit 2 的并发模型为什么这次不用再守代理回调2.1 从 SKPaymentQueue 到 Transaction 的范式切换StoreKit 1 的核心是SKPaymentQueue.default().add(observer)所有交易结果通过paymentQueue(_:updatedTransactions:)这个代理方法回调。问题在于这个回调可能在任意线程、任意时机触发一次购买可能收到.purchasing、.purchased、.deferred多个状态稍不留神就重复发货。更麻烦的是App 启动时如果有一笔未完成的交易系统会在你注册观察者之后立刻重放它很多丢单和重复发货的 bug 都出在这里。StoreKit 2 换了个思路。购买走Product.purchase()它直接返回一个Product.PurchaseResult枚举.success(let verification)里带着一个已经过签名验证的Transaction。同时苹果提供了Transaction.updates这个AsyncSequence用来接收 App 运行期间所有交易状态变化包括那些在 App 外完成的交易比如家庭共享、订阅续期、退款。你不再需要手动管理观察者的注册和移除用for await循环监听即可。这个模型的关键差异是交易验证被前置到了 API 层。VerificationResult要么是.verified要么是.unverified你拿到.verified才去发货.unverified直接丢弃并记录。这比 StoreKit 1 里自己拿receipt去苹果服务器校验要省事得多也少了一层网络依赖。2.2 交易监听循环的正确写法交易监听必须尽早启动通常放在 App 启动或支付模块初始化时。下面这段是工具里监听循环的骨架// 启动交易监听App 生命周期内持续运行 func startTransactionListener() { Task.detached { for await result in Transaction.updates { do { let transaction try self.checkVerified(result) // 发货逻辑根据 productID 区分订阅/消耗型/非消耗型 await self.deliverProduct(transaction) // 关键发货完成后必须 finish否则交易会一直重放 await transaction.finish() } catch { // 验证失败记录日志不发货 print(Transaction verification failed: \(error)) } } } } // 统一校验入口 func checkVerifiedT(_ result: VerificationResultT) throws - T { switch result { case .unverified(_, let error): throw StoreError.failedVerification(error) case .verified(let safe): return safe } }逻辑说明Transaction.updates是一个永不结束的异步序列用Task.detached起独立任务避免阻塞主线程。每收到一笔交易先过checkVerified只有.verified才进入发货。发货成功后调用transaction.finish()是硬性要求——不 finish 的话这笔交易会在下次启动时再次出现在Transaction.updates里造成重复发货。参数说明checkVerified是个泛型函数能同时处理Transaction和Product.SubscriptionInfo.RenewalInfo等类型。deliverProduct需要你自己实现按transaction.productID分发到不同的业务逻辑消耗型商品还要考虑transaction.purchasedQuantity。提示Transaction.updates只覆盖 App 运行期间的交易。App 启动时那些历史未完成交易需要额外调用Transaction.currentEntitlements或Transaction.all来补捞这点后面避坑章节会细说。3. 商品配置与购买流程从 App Store Connect 到代码落地3.1 商品类型的选择与配置要点在写代码之前商品得先在 App Store Connect 后台建好。内购商品分四类选错了后面很难改类型适用场景是否可重复购买是否支持恢复消耗型 Consumable金币、道具、一次性加速是否非消耗型 Non-Consumable去广告、永久解锁否是自动续期订阅 Auto-Renewable会员、月卡是按周期是非续期订阅 Non-Renewing季票、限时服务是是配置时几个容易翻车的点产品 ID 建议用反向域名加业务后缀比如com.example.app.vip.monthly别用纯数字订阅商品必须建在「订阅群组」里同一群组内的订阅互斥用户升级/降级会触发Transaction里的upgradeDowngradeDetails价格档位选完后沙盒环境的价格显示可能和后台不一致这是正常的以实际扣费为准。3.2 拉取商品与发起购买商品信息通过Product.products(for:)批量拉取传入产品 ID 集合// 拉取商品信息 func fetchProducts(ids: [String]) async throws - [Product] { let products try await Product.products(for: ids) // 按后台配置顺序排序避免 UI 顺序错乱 return products.sorted { $0.price $1.price } } // 发起购买 func purchase(_ product: Product) async throws - PurchaseResult { let result try await product.purchase() switch result { case .success(let verification): let transaction try checkVerified(verification) await deliverProduct(transaction) await transaction.finish() return .purchased case .userCancelled: return .cancelled case .pending: // 待处理比如家长控制需要批准 return .pending unknown default: return .unknown } }逻辑说明Product.products(for:)返回的数组顺序不保证和传入的 ID 顺序一致UI 展示前最好自己排一下。purchase()的返回值里.pending是个容易被忽略的状态——当用户开启了「购买前询问」或家长控制时交易会进入待批准状态此时不能发货要等Transaction.updates后续推送。参数说明product.purchase()可以传options参数比如.setSimulatesAskToBuyInSandbox(true)用来在沙盒里模拟待批准流程测试时很有用。deliverProduct里对订阅商品要判断transaction.expirationDate过期的订阅不该发货。3.3 订阅状态查询与恢复购买订阅类商品需要判断用户当前是否在有效期内。StoreKit 2 提供了Product.SubscriptionInfo.status// 查询订阅状态 func checkSubscriptionStatus(productID: String) async throws - Bool { guard let product try await Product.products(for: [productID]).first, let subscription product.subscription else { return false } let statuses try await subscription.status for status in statuses { guard case .verified(let renewalInfo) status.renewalInfo, case .verified(let transaction) status.transaction else { continue } // 判断是否在有效期内 if transaction.expirationDate ?? .distantPast Date() { return true } } return false } // 恢复购买 func restorePurchases() async throws { try await AppStore.sync() // sync 之后 currentEntitlements 会刷新重新遍历发货 for await result in Transaction.currentEntitlements { if case .verified(let transaction) result { await deliverProduct(transaction) } } }逻辑说明subscription.status返回一个状态数组每个状态包含交易和续期信息。判断有效期的核心是transaction.expirationDate和当前时间比较。AppStore.sync()会触发系统弹窗要求用户输入 Apple ID 密码这是恢复购买的标准入口但注意它可能抛错要处理用户取消的情况。参数说明Transaction.currentEntitlements只返回当前有效的权益已过期的订阅不在其中。如果业务需要查历史购买记录用Transaction.all但它包含所有历史交易数据量大别在主线程遍历。4. 避坑与排查内购翻车现场实录4.1 沙盒环境登录后仍提示「无法连接 App Store」现象测试机登录了沙盒账号点购买却弹「无法连接到 App Store」或直接失败。原因多半是设备上登录了正式 Apple ID沙盒账号和正式账号冲突或者 App Store Connect 里商品状态不是「准备提交」以上未生效的商品拉不到。解决去「设置 → App Store」退出正式账号再在购买流程中登录沙盒账号。商品状态要确认是「准备提交」或「已批准」光创建不提交是拉不到的。另外沙盒账号必须在 App Store Connect 的「用户和访问 → 沙盒测试员」里单独建不能用正式账号。4.2 交易重复发货现象用户买了一次后台记录了两笔订单。原因transaction.finish()没调用或者调用前就发了货导致交易在下次启动时重放也可能是Transaction.updates和currentEntitlements两处都做了发货没有去重。解决发货和 finish 要成对且发货逻辑必须幂等——用transaction.originalID或transaction.id做唯一键发货前查一下是否已处理过。Transaction.updates和currentEntitlements的监听要共用同一个发货函数靠订单号去重。4.3 订阅续期后权益没更新现象用户订阅到期自动续费了但 App 里还是显示过期。原因只监听了Transaction.updates但续期交易可能发生在 App 未运行期间启动后没有主动刷新currentEntitlements。解决每次 App 冷启动或进入前台时主动遍历Transaction.currentEntitlements刷新本地权益状态。订阅类商品还可以配合SubscriptionInfo.status做双重校验。4.4 沙盒购买不扣费但交易状态异常现象沙盒环境购买成功但transaction.expirationDate是过去的或者订阅时长不对。原因沙盒环境的订阅周期被压缩了——1 个月订阅在沙盒里可能只有几分钟续期频率也加快。这是苹果的设计不是 bug。解决测试时别用真实时间判断改用transaction.expirationDate和transaction.purchaseDate的差值来验证逻辑。沙盒里订阅会快速自动续期多次正好用来测续期和退款场景。4.5 收据校验在本地通过但服务器拒绝现象客户端VerificationResult是.verified但把收据发给自家服务器校验时被拒。原因客户端验证只校验了签名服务器校验还要检查bundleId、environment沙盒 vs 生产等字段。沙盒收据发到生产校验接口会被拒。解决服务器校验时根据收据里的environment字段选择对应的校验地址沙盒走 sandbox 接口生产走正式接口。别在客户端做最终校验客户端验证只用于快速反馈最终以服务器校验为准。5. 进阶技巧用 StoreKit Testing 把内购测试搬进 Xcode内购测试最烦的就是依赖沙盒环境网络一抖、账号一换测试就断。Xcode 从 12 开始内置了 StoreKit Testing可以在本地模拟完整的购买流程不连苹果服务器。配置方式是在项目里新建一个.storekit配置文件定义商品、订阅群组和交易场景然后在 Scheme 的 Run 选项里指定这个文件。配置文件里可以定义商品的价格、周期还能预设交易历史——比如模拟一个已经订阅了三个月的用户或者一个刚退款的用户。测试时用StoreKitTest框架的SKTestSession控制交易行为import StoreKitTest // 在单元测试里控制 StoreKit 环境 func testSubscriptionRenewal() async throws { let session try SKTestSession(configurationFileNamed: TestProducts) session.resetToDefaultState() session.clearTransactions() // 模拟订阅续期 session.disableDialogs true // 跳过购买弹窗 // 触发购买后手动推进时间模拟续期 try session.expireSubscription(productIdentifier: com.example.app.vip.monthly) }逻辑说明SKTestSession让你在测试里完全掌控交易生命周期disableDialogs跳过系统弹窗expireSubscription强制让订阅过期用来测过期后的权益回收。resetToDefaultState每次测试前重置保证用例独立。参数说明.storekit配置文件里可以配多个商品和订阅群组SKTestSession初始化时指定文件名。session.askToBuyEnabled可以模拟家长控制session.interruptedPurchasesEnabled模拟中断购买覆盖各种边界。我自己的习惯是任何内购相关的改动先在 StoreKit Testing 里跑一遍完整流程——购买、续期、退款、恢复全绿了再上沙盒真机验证。沙盒只用来做最后的端到端确认日常开发全靠本地模拟。从那以后我每次改支付逻辑都强制先过一遍本地测试用例省下来的调试时间比写测试多得多。希望帮到你。本文还有配套的精品资源点击获取