ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

苹果内购支付工具Swift实战:StoreKit 2选型、收据校验与避坑指南

2026/9/26 4:35:46 拓冰建站 浏览量
苹果内购支付工具Swift实战:StoreKit 2选型、收据校验与避坑指南 简介面向iOS开发者的Swift内购支付工具示例工程基于StoreKit框架实现App内购买IAP完整链路覆盖消耗品、非消耗品、订阅三类产品适合需要为应用接入内购功能的移动端开发者查阅与复用。资源压缩包大小为66KB共31个文件类型上以9个Swift源文件为核心附带5个plist配置、3个Objective-C头文件与对应实现、2个storyboard界面文件以及Xcode工程配置、entitlements权限文件等结构紧凑便于直接打开工程对照核心代码。示例通过InAppPurchaseManager封装产品请求与交易监听完整演示了从SKProductsRequest拉取产品信息、SKPaymentQueue添加支付请求、paymentQueue(updatedTransactions:)处理交易状态到恢复购买和服务器收据验证的流程同时包含产品ID配置、观察者注册、交易队列刷新、失败与退款处理等实现细节。Info.plist中的App Transport Security配置与entitlements相关设置也已备好可减少实际集成时的踩坑成本。已有1701人学习下载对正准备接入内购的中级iOS开发者尤其有参考价值。1. 苹果内购支付工具在 Swift 里的真实定位不是“接个 SDK”那么简单不少 Swift 团队把苹果内购当成一个“接上就能跑”的支付工具实际上它更像一套需要自建状态机的业务系统。我在过去两年里前后帮三个团队处理过内购相关的问题一个因为收据校验放在了客户端被刷了上千笔假订单一个因为没处理 Transaction 延迟到账导致用户永久性丢单还有一个在审核前夜才发现“恢复购买”按钮在 iOS 15 的弹窗逻辑下根本走不通。这篇文章不打算给你贴一份官方文档的翻译而是把我在 Swift 里落地苹果内购支付工具时反复用到的流程、配置、参数和踩坑记录整理成一份能直接照着做的方案。适合已经写好 App、准备接入或正在重构内购模块的 iOS 工程师也适合想搞清楚 StoreKit 1/2 差异再做技术选型的技术负责人。2. StoreKit 2 与 StoreKit 1选型前先搞懂两代 API 的差异和迁移代价WWDC 之后苹果把 StoreKit 2 补到了 iOS 15 及以上官方演示几乎全是 Swift 原生 async/await 写法。但很多存量 App 还背着 StoreKit 1 的 SKPaymentQueue 老代码新建项目的人也容易被两套 API 搞混。选型直接决定接下来几个月的开发量新工程建议直接用 StoreKit 2老工程则要评估迁移时对交易监听、收据校验和订阅续期判断这三块的重写成本。我见过有人在 iOS 15 的 target 上强行混用两代Transaction.updates和SKPaymentTransactionObserver同时挂一次购买触发两套回调业务侧重复解锁用户在会员页被扣了两遍权益。2.1 两代框架的能力对比Transaction、Product、Entitlement 的差异先把能力对比放在前面后面所有代码和踩坑都基于这张表。维度StoreKit 1StoreKit 2最低系统版本iOS 6iOS 15商品查询SKProductsRequestDelegate 回调Product.products(for:) async/await发起购买SKPaymentQueue.add(_:)Product.purchase()交易监听SKPaymentTransactionObserverTransaction.updates恢复购买restoreCompletedTransactionsAppStore.sync()交易凭证本地收据文件Transaction.jwsRepresentation校验结果无内置校验VerificationResult 校验两代最本质的差异是数据模型。StoreKit 1 把一次购买拆成 SKPayment、SKPaymentQueue、SKPaymentTransaction 三个类状态散落在队列和代理回调里StoreKit 2 直接用 Product 和 Transaction 两个值类型表达Transaction 自带signedDate、expirationDate、revocationDate、productID、purchaseDate订阅续期和撤销都走同一条更新流下发。这对支付工具层意义很大订阅续期会产生一笔新的 Transaction退款或撤销会下发一笔revocationDate不为空的 Transaction如果你只按“purchased 就发货”的逻辑写订阅场景一定会出问题。我见过不止一个团队在测试订阅时发现“用户退款后权益没收回”追到根因就是把revocationDate忽略掉了。2.2 从 StoreKit 1 迁移到 StoreKit 2 的代码路径与关键改动点迁移不是把方法名替换一遍核心是把“代理回调 手动队列”改成“异步序列”的思维。常见做法是先抽一个IAPService接口把对外的buy(productID:)、restore()、observeTransactions()抽象出来内部再决定用哪一代实现。下面这段映射是迁移时最常遇到的三个改动点。// StoreKit 1商品查询走代理结果要等 didReceive 回调 let request SKProductsRequest(productIdentifiers: productIDs) request.delegate self request.start() // StoreKit 2一行拿到结果失败直接抛错 let products try await Product.products(for: productIDs)productIDs是你在 App Store Connect 里建好的产品标识符集合类型是SetString。这里有两个容易踩的细节同一个产品 ID 重复放进集合苹果会忽略请求产品 ID 里如果混入了空格或大写字母返回的数组会是空数组而不是报错所以guard let products.first是必须写的防御逻辑。交易监听是迁移里最容易翻车的部分。StoreKit 1 要求在 AppDelegate 里挂一个常驻观察者调SKPaymentQueue.default().add(observer)StoreKit 2 把监听改成Transaction.updates这个异步序列在每个更新到达时处理。关键是updates必须在 App 启动早期就开始消费否则购买完成后的 transaction 会一直留在队列里下次启动才补发。// StoreKit 2在启动阶段开启交易监听 func startObserveTransactions() async { for await update in Transaction.updates { // update 可能是购买成功、续期、撤销、退款 let transaction try checkVerified(update) await handle(transaction) await transaction.finish() } }checkVerified做的是VerificationResult解包苹果返回.unverified时不能继续发货.verified才能进入业务处理。这个函数在下面第 3.2 节会给出完整实现。恢复购买在 StoreKit 2 里变成了AppStore.sync()调用后同样从Transaction.updates或Transaction.currentEntitlements里读结果而不是像 StoreKit 1 那样单独走restoreCompletedTransactions的代理方法。3. 用 Swift 实现沙盒内购的最小可运行流程从配置到收据校验3.1 在 App Store Connect 配置内购商品的六个步骤与关键参数很多人先写代码再去建商品等你调Product.products(for:)时发现返回空数组才意识到是商品状态或协议问题。顺序应该是先把 App Store Connect 里的配置做对再开 Xcode。我一般按下面六步走在 App Store Connect 选择对应 App 的“功能 - App 内购买项目”点加号。选择商品类型。消耗型、非消耗型、自动续订订阅、非续订订阅四选一。填写“引用名称”团队内部识别用和产品 ID。设置价格与价格梯度。订阅还要选订阅组和结算周期。填本地化信息显示名称和描述。这一项为空或和 App 实际功能不符审核会被拒。提交审核信息里的截图和备注然后保存。沙盒环境下商品不需要真正过审也能测但状态必须是“准备提交”或“已批准”。关键参数表参数取值建议坑点产品 IDcom.公司名.App名.商品名全小写创建后不可修改删除也删不掉只能停用商品类型消耗型用于金币/道具非消耗型用于解锁功能自动续订用于会员类型创建后不可修改价格点选择苹果提供的价格点调价不用提交新版本订阅组同一个 App 的多个订阅归入同组组内价格梯度需一致否则被拒本地化语言至少填英文只有中文会要求补材料这里提醒一句非续订订阅是最容易被误用的类型。它不会自动续期也不能跨设备恢复需要自己处理多设备同步。如果做的是会员服务优先考虑自动续订订阅否则设备切换后会面临“用户明明付过费新设备却查不到交易”的投诉。3.2 沙盒购买流程的 Swift 代码从商品查询到 Transaction 结束下面是一段能在 iOS 15 编译运行的 Swift 购买函数完整处理购买结果的三大分支。建议放在一个IAPService类里而不是直接在 ViewModel 里写。import StoreKit enum IAPError: Error { case productNotFound(String) case failedVerification case unknown } func purchase(_ productID: String) async throws { // 1. 拉取商品信息 let products try await Product.products(for: [productID]) guard let product products.first else { throw IAPError.productNotFound(productID) } // 2. 发起购买等待系统弹窗和用户操作 let result try await product.purchase() switch result { case .success(let verification): // 这里的 verification 类型是 VerificationResultTransaction let transaction try checkVerified(verification) // 先发货一般是调用服务端接口解锁再 finish await deliver(transaction) await transaction.finish() case .userCancelled: // 用户点了取消不做任何处理 return case .pending: // 家长同意 / 余额不足 / 账号待验证 // 此时没有 transaction等 Transaction.updates 后续到达 return unknown default: throw IAPError.unknown } } private func checkVerifiedT(_ result: VerificationResultT) throws - T { switch result { case .verified(let safe): return safe case .unverified: throw IAPError.failedVerification } }逻辑说明Product.products(for:)只接受SetString重复 ID 和非法字符都会导致请求失败靠返回空数组而不是抛错来表现所以一定要先guard let products.first。product.purchase()是异步挂起函数在调用期间用户会看到系统购买弹窗调用方的 UI 状态要提前切到 loading。result的三个分支必须分别处理.success说明支付完成.userCancelled什么都不用做.pending是最容易忽略的它表示交易不能立即完成比如家庭共享需要家长批准这一单没有同步返回 transaction真正的交易会在之后通过Transaction.updates到达。如果只处理.success这类订单会丢失用户那边表现为“钱扣了但权益没到账”。参数说明productID对应 App Store Connect 里的产品 ID必须是字符串。checkVerified是泛型函数只对VerificationResult做解包不做业务校验真正的业务校验要放到第 3.3 节的服务端逻辑里。3.3 收据校验的取舍本地验证为什么只能当兜底上面的代码可以让你在沙盒里弹窗、付款、解锁但它离“支付工具”还差一个关键环节验证这个 transaction 不是伪造的。StoreKit 2 的VerificationResult只能证明 JWS 签名完整、没被篡改不能证明这笔交易在苹果服务器上真实存在。绕过它的常见做法是直接构造一个假的Transaction对象由于VerificationResult解包后拿到的类型是值类型攻击者可以在内存里 hook 掉返回结果。因此生产环境必须做服务端校验。客户端在购买成功后把transaction.jwsRepresentation发给自己的服务器服务器用苹果的根证书验证签名并拿着 transaction 的originalTransactionID和productID配合 App Store 服务端 API 二次确认。本地校验只建议在弱网兜底场景用比如服务端暂时不可达时先用本地凭证临时解锁等网络恢复后再确认。// 获取 JWS 收据并发送给服务端的示意 guard case .success(let verification) result, case .verified(let transaction) verification else { return } var request URLRequest(url: URL(string: https://your.api/iap/verify)!) request.httpMethod POST request.setValue(application/json, forHTTPHeaderField: Content-Type) let body: [String: Any] [ jws: transaction.jwsRepresentation, productID: transaction.productID, transactionID: transaction.id ] request.httpBody try JSONSerialization.data(withJSONObject: body) let (data, response) try await URLSession.shared.data(for: request) // 服务端返回 verified: true 再解锁内容这段代码里jwsRepresentation是 StoreKit 2 最核心的收据载体一个 JWS 字符串包含 transaction 的完整信息、签名和证书链。注意transactionID和originalTransactionID是两回事前者是每一笔交易的唯一 ID后者是这个用户在这个产品上的首笔交易 ID。订阅续期时前者变化、后者不变。服务端查重时用originalTransactionID做唯一键不要用transactionID判断“用户是否已购买”否则续期一次就会多放行一次。4. 苹果内购支付工具避坑手册5 个从 Sandbox 到审核的翻车记录这一章不按 API 顺序走按实际高频问题排每条都是“现象 - 原因 - 解决”你对照自己的项目排查就行。4.1 沙盒账号登录状态混乱导致无法弹窗现象与解法现象在 Xcode 里跑真机点击购买按钮没有任何反应控制台也没有错误系统弹窗根本不出现。原因最常见的是设备 App Store 的登录状态和目标沙盒账号不一致或者上一个测试交易还挂在队列里没有被 finish。TestFlight 和 Xcode 沙盒环境对账号状态要求很苛刻有时候连 Xcode 都没办法替你切换沙盒账号。解决先到系统设置里退出 App Store 登录再在 App 内触发购买此时系统会弹出沙盒账号登录框用 App Store Connect 里创建的测试账号登录。如果还是无弹窗检查代码里有没有未 finish 的 transaction把所有 pending 的 transaction 调finish()再不行就换一台干净的真机模拟器在内购弹窗上表现不稳定不适合做最终验证。这套操作相当玄学但确实能解决九成以上的无弹窗问题。另外沙盒账号只能通过 App Store Connect 后台创建不要用真实 Apple ID 测试否则会被苹果封掉这个账号的测试资格。4.2 订阅续期与撤销 Transaction 重复发放状态机没有覆盖 revocation现象订阅产品在用户续期或退款后服务器重复发货后台日志显示同样的originalTransactionID被处理了多次。原因StoreKit 2 中订阅会产生多个 Transaction续期一笔、撤销一笔。撤销时会下发revocationDate不为空的 Transaction服务端如果没读取这个字段就会再次执行发货逻辑。解决在handle(transaction)里先判断revocationDate。func handle(_ transaction: Transaction) async throws { if let revocationDate transaction.revocationDate { // 撤销/退款收回权益不要发货 await service.revokeEntitlement(transaction.originalTransactionID) } else { // 正常购买或续期发货 await service.grantEntitlement( originalTransactionID: transaction.originalTransactionID, productID: transaction.productID ) } }判断必须放在发货之前。revocationDate是Date?为空表示正常交易。还要注意Transaction.currentEntitlements也会返回已经撤销的 Transaction过滤逻辑要和这里保持一致建议把这段函数抽成公共方法Transaction.updates和currentEntitlements两个入口复用避免两处逻辑不一致。4.3 客户端收据校验被绕过伪造票据和假订单现象后台出现大量同一用户短时间内“购买”同一非消耗品的记录但苹果开发者后台看不到对应订单。原因代码里用VerificationResult通过就当成功而VerificationResult只验证本地 JWS 的签名链。攻击者可以构造一个签名有效的假 transaction或直接把别人换取的 JWS 拿来重放。解决唯一可靠做法是让服务端调 App Store 的status接口核对交易状态。客户端拿到 JWS 后先做设备端完整性校验再把 JWS 和originalTransactionID发给服务端服务端用transactionID调苹果 API确认productID、purchaseDate、expirationDate与客户端上报一致才算真支付。不要信任任何客户端传来的布尔字段业务上只认服务端的结论。这里补充一个血泪经验JWS 里的时间字段要用苹果服务器时间不要拿客户端本地时间做比对因为攻击者可以改系统时间绕过“未过期”判断。4.4 审核被拒隐藏的恢复购买按钮与缺失的引导现象App 提审后收到 Guideline 3.1.1 拒审理由是“无法在 App 内找到恢复购买的功能入口”。原因非消耗品和自动续订订阅必须提供恢复购买入口且不能藏太深。很多团队把“恢复购买”和“联系客服”挤在设置页底部审核员找不到。解决在购买页、会员页和设置页至少放一个显眼的“恢复购买”按钮点击后调用AppStore.sync()。// 恢复购买sync 后等待 Transaction.updates 或 currentEntitlements func restorePurchases() async throws { try await AppStore.sync() // 手动检查当前权益 for await entitlement in Transaction.currentEntitlements { if case .verified(let transaction) entitlement { await service.grantEntitlement(transaction) } } }注意AppStore.sync()不能让用户输入 Apple ID 密码。如果用户在当前设备上没有购买记录系统弹窗会提示“已恢复”你不能把它当作错误处理。另外恢复购买成功后要回到购买页刷新按钮状态否则用户以为没成功会反复点导致队列里塞满 sync 请求。4.5 多设备同步与延迟到账Transaction 到达顺序不可控现象用户在同一 Apple ID 的另一台设备上购买当前设备冷启动后没有解锁。原因StoreKit 的 Transaction 更新并不是实时推送Transaction.updates只负责“到达本设备”的交易。新设备启动时如果 App 没有主动检查Transaction.currentEntitlements或调AppStore.sync()就不知道这个账号在其他设备买过什么。解决App 启动时同时做两件事第一检查Transaction.currentEntitlements并恢复该 Apple ID 的已有权益第二订阅产品要监听Transaction.updates里的续期事件。注意这两套逻辑要幂等服务端按originalTransactionID去重客户端重复调用grantEntitlement不会重复解锁。延迟到账的极端情况是用户在飞行模式下完成支付transaction 会留在队列里等网络恢复如果你看到“钱扣了但没发货”先查队列里有没有未 finish 的 transaction。5. 打造自己的内购支付工具层封装、队列与状态机的设计5.1 为什么需要自己的内购支付工具层把 StoreKit 关进业务门外直接在 SwiftUI 的 ViewModel 里写product.purchase()很快但后面维护会越来越难受。内购涉及启动时监听、购买中状态、购买后服务端校验、恢复购买四件事每个页面都要处理这三个时序就会产生大量重复代码。我一般会在工程里单独建一个IAPService或IAPStore层把 StoreKit 全部关在里面业务层只看到几个干净的方法protocol IAPServicing { func purchase(_ productID: String) async throws func restore() async throws var currentEntitlements: AsyncStream[String] { get } }这个抽象带来的直接好处是如果你要切到 StoreKit 1、或者接一个自己造的支付网关做灰度业务层完全不用动。坏处是不要把协议设计得太细StoreKit 2 的异步序列和验证结果一旦被协议吃掉太多类型迁移时协议本身会变成绊脚石。我的习惯是协议里只暴露业务含义的方法比如purchase、restore不暴露Transaction、VerificationResult这些 StoreKit 类型方便以后替换实现。5.2 用状态机管理购买过程idle、purchasing、pending、failed 的迁移规则购买不是一个瞬时操作服务端校验、网络请求都可能让流程中断几分钟。我见过团队用布尔变量同时记“正在购买”和“购买失败”两个标志位组合出四态结果漏了一种就翻车。更稳妥的是在工具层定义一个状态机。enum PurchaseState: Equatable { case idle case purchasing(productID: String) case pending(productID: String) case purchased(productID: String, transactionID: String) case failed(productID: String, error: Error) case restoring }状态迁移规则如下发起购买时从.idle到.purchasing。收到.userCancelled回到.idle。收到.pending进入.pending等待Transaction.updates推送真实交易。收到.success且服务端校验通过进入.purchased。校验失败或抛错进入.failed并允许用户重试。恢复购买开始到结束走.restoring结束后回到.idle并刷新权益。在IAPStore里的实现片段MainActor final class IAPStore: ObservableObject { Published private(set) var state: PurchaseState .idle func buy(_ productID: String) async { state .purchasing(productID: productID) do { let result try await doPurchase(productID) switch result { case .pending: state .pending(productID: productID) case .purchased(let transactionID): state .purchased(productID: productID, transactionID: transactionID) case .cancelled: state .idle } } catch { state .failed(productID: productID, error: error) } } }这段代码里MainActor是 Swift 并发下的 UI 安全边界state是Published让 SwiftUI 直接订阅界面变化。.pending状态必须能被界面展示比如显示“等待确认中”不要把它当错误弹红色提示。.failed状态要带上 productID因为用户可能在两个商品之间快速切换如果没有 productID界面不知道失败的是哪一单。5.3 服务端二次校验的 Swift 实现把 JWS 收据交给自己的后端工具层里校验逻辑最容易写散。我的做法是在IAPService内维护一个ReceiptValidator专门负责把 JWS 发往服务端并返回校验结论。下面是一个最小结构struct ReceiptValidationRequest: Encodable { let jws: String let transactionID: String let originalTransactionID: String } struct ReceiptValidationResponse: Decodable { let isValid: Bool let productID: String? let expirationDate: Date? } final class ReceiptValidator { let endpoint: URL init(endpoint: URL) { self.endpoint endpoint } func validate(_ transaction: Transaction) async throws - ReceiptValidationResponse { let request ReceiptValidationRequest( jws: transaction.jwsRepresentation, transactionID: transaction.id, originalTransactionID: transaction.originalTransactionID ) var urlRequest URLRequest(url: endpoint) urlRequest.httpMethod POST urlRequest.httpBody try JSONEncoder().encode(request) urlRequest.setValue(application/json, forHTTPHeaderField: Content-Type) let (data, _) try await URLSession.shared.data(for: urlRequest) return try JSONDecoder().decode(ReceiptValidationResponse.self, from: data) } }服务端要做的事是先用苹果的 JWKS 公钥验证 JWS 签名再用originalTransactionID调 App Store 服务端 API 查询交易状态最后比对productID与会话登录的用户是否一致。注意一个安全细节不要把整个 JWS 原样存进服务端日志只存transactionID和originalTransactionID的哈希否则重放攻击时日志本身就是泄露源。ReceiptValidationResponse里的expirationDate是给订阅用的非消耗品可以忽略但最好统一在响应里带上方便后续加订阅产品不换模型。6. 用自动化测试撑住内购工具沙盒账号矩阵与 Transaction 模拟手动在沙盒上点购买容易漏场景Xcode 14 之后内置的 StoreKit Test 框架能在本地模拟商品、订阅续期、退款、撤销不依赖真实网络和沙盒账号。6.1 用 StoreKit Test 配置文件做本地模拟不依赖沙盒环境的自动化测试在 Xcode scheme 里打开 StoreKit Configuration选择一个.storekit文件运行时 StoreKit 会使用本地配置替代 App Store。.storekit文件可以手动添加并编辑也可以通过 Xcode 的 StoreKit Test 面板生成。常用配置项包括商品列表、价格、订阅组、续期时长、撤销/退款的时间点。我用它做了两个测试场景本地购买成功且服务端 mock 返回有效用户取消后状态回到 idle。func testPurchaseAndGrantEntitlement() async throws { let store IAPStore() try await store.buy(com.example.premium) XCTAssertEqual(store.state, .purchased(productID: com.example.premium)) }这个测试要求 scheme 里启用了 StoreKit Test并且 mock 了ReceiptValidator否则会真的发起网络请求。跑通后续期、撤销、退款这几个场景都可以在本地模拟出来不需要去 App Store Connect 反复创建沙盒账号。6.2 上线前的内购回归清单十个必须手点的场景自动化测试补不到服务端交互和审核体验因此上线前仍要手动过一遍清单场景操作预期首次购买非消耗品沙盒账号点击购买解锁权益二次点击不重复扣费重复购买非消耗品再次点击购买弹窗提示“已购买”或直接恢复购买取消弹窗点击取消状态回 idle无日志异常订阅首次购买沙盒短周期订阅能拿到 expirationDate订阅续期使用本地模拟触发续期第二次 Transaction 不重复发货撤销/退款本地模拟退款权益被收回恢复购买点恢复换设备权益恢复且不重复弱网开飞行模式购买无崩溃出现等待状态服务端校验失败mock 后端返回 500客户端提示稍后重试状态 failed审核路径从设置页进入会员能看到“恢复购买”按钮最后两条必须手动。我自己的习惯是每次提审前把十条完整跑一遍哪怕只改了一行 UI 代码也跑因为内购工具层的状态机一旦被 UI 改动干扰出错时定位成本极高。希望这些配置、代码和踩坑记录能帮到你。本文还有配套的精品资源点击获取