ARTICLE DETAIL

资讯详情

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

Swift 内购支付封装实践:StoreKit 交易队列、收据校验与避坑指南

Swift 内购支付封装实践:StoreKit 交易队列、收据校验与避坑指南 简介面向iOS开发者的苹果内购IAP支付工具Swift实现资源适合需要接入应用内购买、处理消耗品/非消耗品与订阅流程的移动端开发者。资源从Apple Developer内购项目配置讲起覆盖StoreKit框架导入、SKProductsRequest产品请求、SKPaymentTransactionObserver交易监听、支付发起与恢复购买等完整链路并包含服务端收据验证、错误提示与订阅管理要点帮助解决内购接入中的常见问题。压缩包共31个文件以Swift源码、plist配置文件、Objective-C头文件与实现文件、故事板及Xcode工程配置为主整体约66KB。工程内提供多个视图控制器示例展示商品信息展示、用户点击购买、支付回调处理与恢复购买流程同时包含Info.plist、entitlements等配置涉及ATS设置与内购权限声明目录结构清晰便于直接查看工程示例或提取复用。已有1701人学习下载适合希望快速理解IAP集成步骤或复用支付代码块的iOS开发者。1. 苹果内购支付工具Swift)不是加个按钮那么简单做过 iOS 支付的人都有同感产品经理觉得「内购不就是调个 StoreKit API 吗」实际上你接的第一周全在跟交易队列、校验地址、订阅续期、沙盒账号这些黑匣子较劲。我拆的这个 Swift 内购支付工具就是把你日常调 StoreKit 要写的样板代码、状态处理、掉单重查逻辑都打包好了支持消耗型、非消耗型、自动续期订阅三种产品处理了 SKPaymentQueue 的自动下载交易、交易观察者的生命周期、服务端二次校验的拼接逻辑。它适合独立开发者和还没形成支付中台的小团队直接拖进工程改配置就能用也能帮想看清 StoreKit 真实执行时序的人省掉几天的踩坑时间。这里不吹全自动内购本来就是个半手动流程工具能帮你稳定的部分剩下的坑还是要你自己趟一遍——下面我把这批代码怎么用、哪些地方必须自己 prj 测一遍全拆开讲清楚。2. 为什么内购工具必须自己做交易队列处理StoreKit 的状态机与选型2.1 交易状态流转SKPaymentTransactionState 里藏着掉单的根源苹果的内购核心是 SKPaymentQueue不是一个「发起支付 - 收到回调」的一次性调用。每一笔交易都带着以 SKPaymentTransactionState 为节点的状态机刚开始是 purchasing随后要么进入 purchased、failed、restored或者因为需要确认而停在 purchased 后被你的代码 finalize调用 finishTransaction才算走完。有人觉得「支付成功回调里给用户发道具然后 finish 就行」这个做法在多数情况下能跑但只要用户杀进程、网络抖动、后台切换交易会停在半路下次启动时 SKPaymentQueue 还会把这些未 finish 的旧交易重新抛给你。常见做法是在 App 启动时先注册一个持久化的 SKPaymentTransactionObserver然后遍历队列里所有非 finished 的旧交易分别按状态处理掉再开始新的购买流程。这个工具把这一整套状态处理封装成了 PurchaseManager 单例你只需要调用startPurchase(with:productID)内部会负责把交易从 purchasing 到 purchased 的转移、把购买凭证拼接好交给回调、以及提醒你该不该调用finishTransaction。关键点是finishTransaction必须等你自己把商品发放逻辑做完或者确保服务端已收到收据后再调用否则一旦调用这笔交易就永远从队列里消失了再想补发就难了。在很多国际化 App 里掉单就是在这里发生的客户端交易状态是 purchased但你还没发道具交易被 system 或手动 finish 掉了用户买了两遍。工具里我刻意把 finish 时机和回调解耦回调结果里带上transactionIdentifier和originalTransaction等字段方便你到服务器上查证后再决定是否终结。2.2 为什么选 StoreKit 1 而不是仅用 StoreKit 2兼容与可控性对比苹果在 iOS 15 推出了 StoreKit 2语法更 Swift 化支持 async/await但老项目、第三方登录体系、服务端订阅状态校验库大多仍以 StoreKit 1 为主。这个工具的核心建立在 StoreKit 1 之上同时留了 StoreKit 2 的适配桥接。对比下来有明确取舍StoreKit 2 的交易管理更现代但你得 iOS 15而且它的收据验证方式变了服务端对订阅状态的管理依赖 App Store Server API 的事件推送这套东西对小团队来说不是省事而是增加运维成本。用 StoreKit 1 加自建收据校验服务端只需要一把证书就能完成校验现有 PHP/Node/Java 服务端都有现成库出了问题你能查到每一层日志。选型时你要看自己的最低支持版本和服务器能力。如果 App 最低支持 iOS 14那不用想StoreKit 1 是唯一选择如果最低 iOS 15且你的订阅状态管理完全交给服务端可以考虑 StoreKit 2但要重新做一套支付回调。多数做工具类、习惯把内购当附属功能的开发者我建议老老实实停在 StoreKit 1用一个可靠的封装把交易队列和收据校验做扎实。这个工具里还处理了SKPaymentQueue.default().shouldAddStorePayment(for:payment)这个兼容点它用来响应从 App Store 外部唤起的内购场景比如点击 App Store 里的推广位直接跳回你的 App 购买这是容易被忽略的上架审核加分项。2.3 收据校验客户端拿到 receipt 只是第一步客户端在purchased状态下从Bundle.main.appStoreReceiptURL读到的 receipt 是一个 Base64 字符串它本身不能直接证明这笔交易对你的服务端有效。攻击者可以用越狱环境伪造 receipt或者在 App 内篡改本地支付回调。所以服务端必须拿这个字符串去 Apple 的verifyReceipt接口换一个 JSON里面才是真正的in_app交易数组。工具里把「取 receipt - 转 Base64 - 通过回调抛给业务层」做成了固定流程服务端怎么验是另一个话题但客户端至少要保证 receipt 取的是当前真正的仓库不是缓存过的旧值。这里有一个容易翻车的细节Bundle.main.appStoreReceiptURL在沙盒环境和你设备上可能因为「重新安装」或者「系统缓存」出问题最直接的表现是你收到的 receipt 是上一次启动的旧值导致服务端验出来是旧的交易新购买被吞掉。工具里的refreshReceipt方法会在必要时调用SKReceiptRefreshRequest并等待requestDidFinish回调再继续避免那种「 receipt 没刷新就上传」的笑话。下面第 3 章我会把核心代码展开包含推荐的使用方式。3. 把交易队列封装成工具PurchaseManager 的完整实现3.1 初始化与观察者注册防止启动时漏掉未完成的交易一个合格的封装必须做到单例、在init里直接把自己加入到SKPaymentQueue。很多新手把观察者加在某个 ViewController 的viewDidLoad里页面 pop 后观察者被释放以后所有内购都失效。正确做法是 App 启动早期、任何 UI 展示之前就完成注册并让这个单例的生命周期跟 App 保持一致。看一下这个工具的初始化部分final class PurchaseManager: NSObject { static let shared PurchaseManager() private var products: [SKProduct] [] private var currentPurchase: ((ResultPurchaseResult, Error) - Void)? private override init() { super.init() SKPaymentQueue.default().add(self) // 关键启动时主动检查未完成交易 SKPaymentQueue.default().restoreCompletedTransactions() } }注册之后restoreCompletedTransactions会把该账号下所有「已经购买但未 finish」的历史交易重新推给你当前的观察者。这个调用不是为了恢复购买按钮准备的它是为了清理上一轮遗留状态。注意如果你在 init 里直接调用restoreCompletedTransactions而你的 App 没有设计「恢复购买」入口用户可能会在首次启动看到多余的恢复弹窗苹果的队列页面会提示「此 App 要求恢复购买」之类的文案。所以我个人建议把启动时的恢复调用改为遍历SKPaymentQueue.default().transactions只处理状态为purchased且还没 finish 的本地交易而不是直接调用 restore。这里工具里留了一个startCheckingPendingTransactions()方法默认实现是把队列里现有交易按状态流转一遍而不是触发 Apple 的恢复流程。如果你需要支持「恢复购买」按钮再单独调restoreCompletedTransactions()避免误用。3.2 发起购买商品信息加载与支付请求发起购买前必须先通过SKProductsRequest从 App Store 拉取商品信息。这里有一个容易掉进去的坑商品 ID 不是随便传一个字符串而是要确保你在 App Store Connect 里创建的 Product ID 完全一致包括大小写和连字符。代码里如果直接用硬编码的 product ID 发起购买可能出现「无法连接到 App Store」或「商品不存在」的错误。下面是加载商品并购买的典型代码模式func fetchProducts(with ids: SetString) { let request SKProductsRequest(productIdentifiers: ids) request.delegate self request.start() } func purchase(productID: String) { guard let product products.first(where: { $0.productIdentifier productID }) else { currentPurchase?(.failure(PurchaseError.productNotFound)) return } let payment SKMutablePayment(product: product) payment.quantity 1 SKPaymentQueue.default().add(payment) } extension PurchaseManager: SKProductsRequestDelegate { func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) { self.products response.products // invalidProductIdentifiers 通常是因为该商品在 App Store Connect 里未上线/未加入协议/类型不匹配 if !response.invalidProductIdentifiers.isEmpty { print(无效商品ID: \(response.invalidProductIdentifiers)) } } }这段代码的逻辑是先通过SKProductsRequest拿到合法的SKProduct数组再从本地变量里取出匹配项发起支付。参数说明payment.quantity对内购来说绝大多数情况下保持为 1如果设成大于 1只用消耗型商品才有意义且必须在 Apple 审核时说明否则有被拒风险。SKMutablePayment还有一个simulatesAskToBuyInSandbox属性沙盒测试家庭共享时设为true可以模拟「问爸妈买」流程但线上不要开。外层业务拿到currentPurchase回调后该去处理页面 loading 了注意回调可能在任意队列线程出来UI 操作请搬回主线程。3.3 交易观察者的核心逻辑 purchased、failed、restored 分流交易观察者统一负责被 Apple 推送过来的交易事件。这部分是这个工具的核心也是所有内购支付最容易写乱的地方。看代码extension PurchaseManager: SKPaymentTransactionObserver { func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) { for transaction in transactions { switch transaction.transactionState { case .purchasing: // 等待后续状态不需要额外处理 break case .purchased: // 先把交易信息随回调发出等业务层确认后调用finishTransaction handlePurchased(transaction) case .failed: if let error transaction.error as? SKError { switch error.code { case .paymentCancelled: print(用户取消支付) default: print(支付失败: \(error.localizedDescription)) } } queue.finishTransaction(transaction) currentPurchase?(.failure(PurchaseError.failed(transaction.error as Any))) case .restored: // 恢复购买成功后需要把原始交易标记为完成 handleRestored(transaction) case .deferred: // 家长同意/企业内购时会出现等待外部处理 break unknown default: break } } } }你要注意purchased分支里我故意没调用finishTransaction而是在handlePurchased里把交易数据抛给业务层。等到自己发完道具、服务端存好收据之后你再调queue.finishTransaction(transaction)否则会出现「客户端道具已发但交易队列一直残留」或「服务端未确认但队列已经清理」的尴尬。restored分支通常用于非消耗型商品或订阅迁移你需要把transaction.originalTransaction?.transactionIdentifier作为关联单据避免重复发放。deferred状态是很多人不处理的App 内购在家庭共享时可能进入这个状态用户看到的是「等待审批」你这里最好记录埋点不要再触发购买弹窗。4. 常见问题与避坑内购支付的五个翻车现场4.1 沙盒测试账号登录不了提示「Sandbox account already exists」现象在真机上反复切换沙盒账号支付弹窗出现「此 Apple ID 已被用于购买请使用其他账号」或者直接登录失败。原因沙盒账号一旦在设置里登录过系统会记住它再次创建同名测试账号购买时会触发 Apple 的旧账号残留限制。解决不要用设置 App 登录沙盒账号改在首次弹出支付确认框时选择「使用其他 Apple ID」来现场登录如果一个测试账号已经被绑定过去 App Store Connect 里把该账号的密码重置等几分钟后重新在支付弹窗里登录。4.2 收据校验返回 21002服务端说 receipt 格式不对现象客户端把appStoreReceiptURL里读到的 data 转成 base64 传给服务端服务端调用 Apple 的 verifyReceipt 接口返回状态码 21002receipt-data 字段格式异常。原因大概率是你客户端读到的 receipt 是 nil或者你把它硬编码成字符串时带了换行 / 空格Apple 要求的是严格的 Base64 字符串任何多余字符都会报错。解决在客户端读取时使用Data(contentsOf: receiptURL).base64EncodedString()不要做String(describing:)转换如果读取失败先调一下 SKReceiptRefreshRequest 刷新再读。服务端把这串 base64 放进 JSON 的receipt-data字段时不要再次 URLEncoder 或 decode它是标准 Base64 字符集原样放进 JSON 即可。4.3 自动续期订阅在沙盒里不自动续费现象沙盒环境下测试自动续期订阅等了两小时也没看到第二次购买回调。原因沙盒并没有真实的时间流逝续期策略是「加速模式」——苹果文档说沙盒订阅时长被压缩但实际测试中很多开发者遇到了续期间隔接近正式时长的问题尤其是你如果用的订阅周期是 1 年沙盒续期可能要等半天。解决在 App Store Connect 里把订阅周期改为「1 个月以下」的档位用于测试或者直接使用「订阅组」里专门配置的沙盒测试档周期设置为 3 分钟需要联系 App Store 技术支持开通加速模式。另外真机测试时要保持系统时间跟网络时间一致不要手动改时间否则订阅判断会混乱。4.4 用户取消支付后交易处理完了但界面还卡在 loading现象点击购买弹窗的「取消」按钮后购买回调迟迟不来或者 UI 一直转圈直到超时。原因SKPaymentQueueObserver的updatedTransactions没有在取消后正确回调因为取消本身是走failed状态但你的观察者可能被释放了或者你在purchasing状态里加了自己的逻辑导致状态机卡住。解决确保观察者已经是单例并且生命周期跟随 App同时观察transaction.error的code .paymentCancelled在收到这个错误时立即关闭 loading如果你自定义了弹窗要优先处理它不要等待finishTransaction的反馈因为取消时 Apple 不会给成功回调。4.5 审核被拒App 内的虚拟商品没有走内购或者引导外部支付现象审核反馈你的 App 「使用了非 App 内购买机制」或「包含第三方支付链接」。原因很多 App 会把微信支付、支付宝的 SDK 放进同一个包用于实物商品但审核机器人只要能扫描到非 IAP 的支付入口就可能触发误伤另一个原因是虚拟商品页面写了「前往官网购买」之类的文案。解决把第三方支付 SDK 的初始化放在仅限实物交易的流程里并用远程开关控制确保审核时包内不出现支付宝/微信的别名虚拟商品页面的按钮文案只能是「购买」或「订阅」不能出现「支付」「购买点卡」等字样。工具包自带了一套isIAPOnly配置项打包上传时把它设为 true并把非内购入口的代码通过编译宏排除掉。5. 进阶给内购工具加上收据自检和购买流程埋点到这一步交易队列已经能跑通了但上线前你要做一轮自检。我建议在工具里加一个debugReceipt()方法在测试模式下打印当前收据的原始信息然后用一个测试按钮触发「沙盒收据验证」它会把当前收据发送到你的服务端校验接口然后把status和latest_receipt_info打印到日志。这样不用每次想验证都去查后台能直接看到这笔交易的服务端视角。下面是收据读取与自检的简洁实现func debugReceipt() { guard let receiptURL Bundle.main.appStoreReceiptURL, let receiptData try? Data(contentsOf: receiptURL) else { print(当前没有本地收据) return } let base64 receiptData.base64EncodedString() // 沙盒环境用 receive URL: https://sandbox.itunes.apple.com/verifyReceipt // 生产环境用 https://buy.itunes.apple.com/verifyReceipt print(Receipt(base64): \(base64)) sendToServerForValidation(base64) }这里要注意两个环境的收据校验地址不同线上服务器应该只信任购买验证接口但调试时你可以加一个isSandbox开关让服务端在校验失败且有沙盒标记时自动重试沙盒地址。Apple 建议严谨做法是先调生产环境如果返回 21007沙盒 receipt再调沙盒环境。工具里在客户端没有做这件事因为生产 / 沙盒跳转最好在服务端统一管理客户端只管传原始 receipt否则你把服务器信息暴露在客户端并不是好事。另外建议在购买开始的入口、回调成功、失败、恢复购买四个位置统一记录自定义事件。你可以用轻量级的日志模块把productIdentifier、transactionDate、transactionState记到本地文件方便以后排查用户反馈的「我买了没到账」问题。我的习惯是每次发版前强制走一遍完整的沙盒购买流程购买消耗型 - 杀进程重开 - 验证收据购买非消耗型 - 恢复购买 - 验证不重复发放订阅 - 确认开始日期和到期日期正确。从那以后我每次都在真机上做一遍这三个流程确认无误后才敢提审希望帮到你。本文还有配套的精品资源点击获取
返回列表