iOS内购漏单排查:StoreKit 2交易监听与幂等发货实践
简介:苹果内购支付工具(Swift)示例包面向需要接入 App Store 应用内购买的 iOS 开发者,尤其适合已掌握 Swift 基础、希望理解 StoreKit 交易链路与工程落地方式的中级学习者。包内提供可运行的 Xcode 示例工程,围绕产品信息请求、支付队列监听、交易状态处理、恢复购买、收据验证与订阅管理等环节给出代码组织参考,并包含 Swift 与 Objective-C 混编所需的桥接头文件、界面 Storyboard 及权限配置,便于对照理解内购模块在真实项目中的分层与调用关系。压缩包共31个文件,以 swift 源码、plist 配置、h/m 混编文件、json 数据和 storyboard 界面文件为主,整体约66KB,体量轻便,适合快速导入 Xcode 研究。已有1705人学习,可作为内购功能原型、课程示例或项目改造的起点,帮助读者梳理从商品请求到交易回调的完整实现思路,并补充错误处理、恢复购买和合规校验等常见细节。
1. 深夜排查漏单的起点
上线前的最后一轮回归,测试同学点下购买按钮,转圈二十秒后回到原页面——订单没发、道具没到账、日志里连一行报错都没有。iOS 内购最容易让人崩溃的地方:它不报错,不崩溃,只是“什么都没发生”,你只能靠 Xcode 控制台里一闪而过的交易回调,倒着推断哪一环断了。
这份 Swift 内购支付工具做的事,就是拆解这个黑匣子:商品拉取、下单、交易监听、凭证校验、发货幂等,五个环节各自独立成模块,哪一步断了都能单独打断点、单独重放。它面向已经做完界面、正准备把内购接进业务的 iOS 开发者,也适合接手老项目、面对一团SKPaymentQueue老代码不知道从哪下手的同学。工具本身不绑业务:没有用户体系、没有订单表,只负责把 StoreKit 2 的异步交易流封装成可重试、可补偿的状态机,剩下的交给你的后端。
2. 项目定位与技术选型:一套不依赖第三方库的 StoreKit 2 封装
2.1 内购真正难的不是“调起支付”
很多人以为内购就是调一个接口把弹窗弹出来,验收时也只看“能不能买成功”。真实项目里,成功路径只是最简单的部分,真正消耗开发时间的是下面三件事。
第一是商品配置。产品 ID 散落在各个 ViewController 里,运营改个价格就得发版;商品在商店后台处于“待审核”状态时,Product.products(for:)会静默地少返回一个,你拿到 3 个而不是 4 个,界面上少一个档位,用户投诉“你们那个 6 元档没了”。这套工具把商品 ID 收敛成一个集中管理的清单,加载结果做全量校验:少一个就抛错,而不是安静地缩减。
第二是交易链路。purchase返回的不只有成功和失败,还有.pending(家长控制待批准、需要二次验证)、.userCancelled、.unverified。.pending最容易被写错——很多代码把它当失败处理,直接提示“支付失败”,用户批准之后交易其实会从Transaction.updates补回来,但你的 UI 已经把这单忘了。
第三是发货凭据。客户端收到的Transaction带一个 JWS 签名串,可以直接本地验签,也可以转给服务端做二次校验。绕过这层,等于让客户端自己说“我付过钱了”。同时发货必须幂等——transactionID是天然唯一键,拿它挡重复发货,比任何自增订单号都可靠。
所以这套工具的定位很清楚:它不碰你的业务表,只做“交易从产生到确认发货”这一段管道,并且把每个环节的失败原因暴露出来。
2.2 技术栈与版本要求:为什么这套代码限制在 iOS 15
主线全部基于 StoreKit 2,最低 iOS 15。这个版本要求绕不过去:
Transaction.updates是 iOS 15 才有的 AsyncSequence,取代了SKPaymentQueue的 delegate 回调;VerificationResult<Transaction>这套本地验签结构同样是 15 起;Product.purchase(options:)的.appAccountToken选项、Product.SubscriptionInfo的订阅状态查询,都在 15 之后才完整。
语言层面用 Swift 5.9,重度依赖三个特性:async/await(把回调地狱摊平成线性代码)、actor(商品缓存和订单写入要防并发写)、Task生命周期管理(监听任务必须能取消,否则页面退出后任务还在跑)。整个包不引任何第三方依赖——内购这块引第三方库反而增加审计成本,商店侧的接口变动又频繁,自己封装一层更可控。
老项目怎么办?仓库里留了Legacy/SKQueueBridge.swift,把SKPaymentQueue的回调适配成同一套AsyncStream接口。这样上层业务代码只面向协议编程,底下是新是老无感。常见做法是先用老桥接跑通,再逐个页面迁移到 StoreKit 2,而不是一次性重写。
2.3 目录结构:每个文件负责哪一段链路
拿到源码先看目录,能省掉一半读代码的时间。这套工程按“链路阶段”切分,而不是按 MVVM 分层,因为支付本身是一条流水线,按阶段切更容易定位问题。
| 路径 | 职责 | 关键类型 |
|---|---|---|
Sources/PayKit/ProductStore.swift | 商品拉取、缓存、全量校验 | actor ProductStore |
Sources/PayKit/PurchaseService.swift | 下单入口、结果归一化 | PurchaseOutcome |
Sources/PayKit/TransactionObserver.swift | 交易流常驻监听 | TransactionObserver |
Sources/PayKit/ReceiptValidator.swift | JWS 凭证校验与转发 | VerificationResult包装 |
Sources/PayKit/DeliveryService.swift | 幂等发货、落库 | DeliveryService |
Sources/PayKit/OrderState.swift | 订单状态机定义 | enum OrderState |
Sources/PayKit/Legacy/SKQueueBridge.swift | StoreKit 1 兜底适配 | SKPaymentTransactionObserver |
Tests/PayKitTests/PurchaseTests.swift | 本地回放与幂等测试 | SKTestSession |
Products.storekit | 本地商品配置文件 | — |
读代码的顺序建议是OrderState→ProductStore→PurchaseService→TransactionObserver→DeliveryService。先看状态怎么定义,就不会在后面被各种分支绕晕。
3. 核心链路拆解:一次购买从点击到发货的完整代码
3.1 商品加载:缓存、并发与空数组的区分
商品拉取看起来是一行 API 调用,实际有两个问题需要处理:多个页面同时请求会重复拉取,以及“拉取失败”和“拉到了空数组”必须分开处理。
actor ProductStore { private var cache: [String: Product] = [:] private let ids: [String] init(productIDs: [String]) { self.ids = productIDs } // force = true 时跳过缓存,用于商店后台改价后的手动刷新 func load(force: Bool = false) async throws -> [Product] { if !force, !cache.isEmpty { return ids.compactMap { cache[$0] } } let products = try await Product.products(for: ids) // 关键:数量对不上就报错,不能静默返回残缺列表 guard products.count == ids.count else { let missing = Set(ids).subtracting(products.map(\.id)) throw PaymentError.missingProducts(Array(missing)) } cache = Dictionary(uniqueKeysWithValues: products.map { ($0.id, $0) }) return ids.compactMap { cache[$0] } } func product(_ id: String) async throws -> Product { if let cached = cache[id] { return cached } _ = try await load(force: true) guard let target = cache[id] else { throw PaymentError.productNotFound(id) } return target } }用actor而不是class+ 锁,是因为商品加载几乎必然出现在多个入口:首页推荐、充值页、设置页的恢复购买。actor天然串行化,两个并发请求会排队,第二个直接吃缓存,不会打出两次网络请求。
load(force:)的force参数用于“刷新商品列表”按钮。默认走缓存,冷启动只拉一次。PaymentError.missingProducts会带上缺失的 ID 列表,方便你对着商店后台核对——十有八九是商品还没过审,或者 ID 里多了一个空格。
注意:
Product.products(for:)单次传入的 ID 数量有上限,超过上限后多出来的会被直接忽略。商品超过 100 个的工程要自己分批调用再合并。
3.2 下单入口:把四种返回结果显式建模
purchase的返回值如果只用Bool接,后面每加一个分支都得改调用方。这里直接定义成枚举。
enum PurchaseOutcome { case success(Transaction) // 已验签,可以进发货流程 case pending // 待批准,不能发货,也不能提示失败 case cancelled // 用户主动取消 case unverified(String) // 验签失败,通常是中间人环境或越狱设备 } func buy(_ product: Product, accountToken: UUID) async throws -> PurchaseOutcome { let result = try await product.purchase(options: [ // 把用户标识写进交易,后续退款回调能直接对上人,不用再查订单表 .appAccountToken(accountToken) ]) switch result { case .success(let verification): switch verification { case .verified(let transaction): return .success(transaction) case .unverified(_, let error): return .unverified(String(describing: error)) } case .pending: return .pending case .userCancelled: return .cancelled @unknown default: return .cancelled } }.appAccountToken(accountToken)这个选项值得单独说。它会把一个 UUID 塞进交易的元数据里,后续服务端收到退款通知、或者你自己查交易记录时,靠这个 token 就能定位到具体用户,不用再拿originalTransactionId去关联订单表。常见做法是用你自己的用户 ID 做一次哈希映射成 UUID,注意别用可反推的格式。
.pending的处理要特别克制:不要弹“支付失败”,也不要清空当前的下单上下文,只提示“等待批准”。真正到账会从Transaction.updates补回来,那条链路是唯一可信来源。
3.3 交易监听:常驻任务的正确打开方式
Transaction.updates是一个全局的异步序列,凡是未完成的交易(掉线、杀进程、待批准后批准、退款)都会从这里补发。这段代码写错的典型症状是:应用启动后重复发货,或者交易永远不finish()导致每次启动都重放。
final class TransactionObserver { private var task: Task<Void, Never>? func start(handler: @escaping (Transaction) async -> Bool) { guard task == nil else { return } // 防重复启动 task = Task.detached(priority: .background) { for await update in Transaction.updates { guard case .verified(let tx) = update else { continue } let delivered = await handler(tx) // 只有业务确认发货成功才 finish,否则留给下次启动重放 if delivered { await tx.finish() } } } } func stop() { task?.cancel() task = nil } deinit { task?.cancel() } }几个细节。Task.detached避免继承调用方的 actor 上下文,监听任务本身不该被 UI 的优先级拖住。guard task == nil是血泪经验:很多项目在application(_:didFinishLaunchingWithOptions:)和首页onAppear里各调一次start(),结果同一个交易被处理两遍,发货双倍。deinit里取消任务防止泄漏。
handler返回Bool而不是直接在里面finish(),是为了让“发货成功”这个判断留在业务侧。finish()的语义是“这笔交易我处理完了,别再发给我”,一旦调用就不可撤销。发了货再 crash,交易已经 finish,用户拿不到东西也补不回来;反过来先 finish 后发货,中途失败就是永久丢单。所以顺序必须是:发货成功 →finish()。
3.4 校验与发货:transactionID 就是幂等键
发货这一段的核心就一句:任何一笔交易,无论被重放多少次,只能发一次货。
struct DeliveryService { let api: BackendAPI let store: OrderStore func deliver(_ tx: Transaction) async throws { // transactionID 全局唯一且不会变,直接当幂等键用 let key = String(tx.id) if try await store.contains(key) { return } // jwsRepresentation 是签名凭证,交给服务端向商店接口做二次校验 try await api.verify( jws: tx.jwsRepresentation, productID: tx.productID, accountToken: tx.appAccountToken ) // 落库和发货放在同一段逻辑里,落库失败就整笔抛出,等下次重放 try await store.insert(key: key, product: tx.productID, purchasedAt: tx.purchaseDate) try await grant(productID: tx.productID, quantity: tx.quantity) } }参数上,tx.id是UInt64的交易号,同一笔交易在任何设备、任何环境下都不变,比自建订单号可靠。tx.jwsRepresentation是完整签名串,服务端拿它去调校验接口,能同时拿到交易状态、退款标记、订阅有效期。tx.purchaseDate用来判断是不是历史遗留订单——老订单重放时如果已经超过业务允许的补发窗口,可以选择只落库不发货。
本地验签(case .verified)只能证明这笔交易由系统签发、没被篡改,不能证明它没被退过款。退款状态要靠服务端的退款通知或查询接口拿,所以真正的防线在服务端那一次校验,客户端这层只是提前拦掉明显伪造的请求。
4. 跑起来:从空工程到沙盒环境跑通第一笔订单
4.1 工程配置:先把商品配置搬到本地
拿到源码后第一步不是急着跑真机,而是用本地商品配置文件把链路跑通。Xcode 支持新建一个.storekit配置文件,里面定义商品 ID、类型、价格、是否可重复购买。测试时 Scheme 里指定这个文件,所有购买都走本地模拟,不消耗沙盒账号、不需要网络。
新建方式是在 Xcode 里File > New > File,选 StoreKit Configuration File,命名Products.storekit,然后手动添加与线上一致的商品 ID。配置项里最常改的几个:
| 字段 | 说明 | 常见取值 |
|---|---|---|
| Reference Name | 本地显示名,不影响代码 | coin_100 |
| Product ID | 必须与代码和后台完全一致 | com.demo.coin100 |
| Type | 消耗型 / 非消耗型 / 自动续期订阅 | Consumable |
| Price | 本地模拟价格,不参与真实结算 | 6.00 |
| Family Shareable | 非消耗型才需要开 | false |
Scheme 里在Run > Options > StoreKit Configuration选中这个文件。之后模拟器上的购买弹窗是系统模拟的,点两下就成功,非常适合反复验证发货幂等。
4.2 本地联调:用测试会话把交易“回放”出来
手动点弹窗验证效率太低,真正好用的是StoreKitTest框架。它允许你在单元测试里直接创建交易、清空交易、模拟退款,把那些平时很难复现的场景变成可重复执行的用例。
import StoreKitTest import XCTest final class PurchaseTests: XCTestCase { var session: SKTestSession! var service: DeliveryService! override func setUpWithError() throws { session = try SKTestSession(configurationFileNamed: "Products") session.resetToDefaultState() session.clearTransactions() session.disableDialogs = true // 免去手动确认弹窗 session.askToBuyEnabled = false // 关掉待批准,先测主链路 } func testDeliverTwiceOnlyGrantsOnce() async throws { try session.buyProduct(identifier: "com.demo.coin100") // 同一笔交易连续投递两次,验证幂等键生效 for await update in Transaction.updates { guard case .verified(let tx) = update else { continue } try await service.deliver(tx) try await service.deliver(tx) break } let count = try await service.store.count() XCTAssertEqual(count, 1) } }disableDialogs = true让购买自动确认,是跑批量用例的前提。clearTransactions()在setUp里调用,避免上一个用例的残留交易干扰下一个——不加这句,测试跑第二遍就开始出现莫名其妙的重复发货,你会以为是业务代码有问题,其实是环境没清干净。
askToBuyEnabled建议单独写一个用例把它打开,专门验证.pending分支:下单后交易不返回成功,你把应用杀掉重启,再从Transaction.updates里把它捞回来。这条路径线上出错率极高,但很少有人测。
4.3 真机沙盒:把环境切换搞清楚
本地配置只能验证逻辑,最终还得上真机走沙盒。沙盒购买不产生真实扣款,但流程、凭证、回调与生产一致。真机测试要注意的点:
- 设备的系统设置里有专门的沙盒账号登录入口,不是在系统的账号中心。用错地方会一直提示“无法连接”。
- 沙盒账号不能是已经注册过正式账号的邮箱,用一个全新的测试邮箱。
- 沙盒环境的商品审核状态与生产独立,线上已上架的商品在沙盒里可能显示“商品不可用”,需要单独确认。
- 沙盒的交易回调延迟不稳定,从几秒到几分钟都有,别拿“没立刻收到回调”当 bug 报。
提示:切换沙盒账号前先退出当前账号并重启应用。账号缓存很顽固,不退出的话新账号的商品列表可能还是旧的。
4.4 验证发货链路:日志、断点与重放
真机跑通之后,怎么确认“确实只发了一次货”?靠日志。给支付模块设一个独立的日志子系统,然后用命令行过滤,比在 Xcode 控制台里翻要快得多。
# 只看支付模块的日志,过滤系统噪声 xcrun simctl spawn booted log stream --level debug \ --predicate 'subsystem == "com.demo.paykit"'日志里至少要打三类信息:交易号(tx.id)、当前状态(created/pending/paid/delivered)、幂等判断结果(命中缓存 or 新插入)。有了这三样,任何一笔异常订单都能在几分钟内定位到是没收到交易、还是收到了但校验没过、还是校验过了但落库失败。
断点建议只打两处:DeliveryService.deliver的入口,和tx.finish()之后。前者看交易有没有进来,后者看有没有正常关单。中途那些分支分支用日志代替,断点打太多反而会打乱异步时序,尤其是在Transaction.updates的回调里——停太久可能触发超时。
5. 避坑与排查:内购联调时最容易出问题的五个点
5.1 商品列表返回空数组,但后台显示已上架
现象:Product.products(for:)不抛错,返回[],界面上一片空白。
原因:按出现频率排,一是商品 ID 拼错(大小写、多了空格、Bundle ID 前缀不一致),二是商品在后台还处于“缺少元数据”状态,三是测试设备的商店账号地区与商品可售地区不匹配,四是新应用首次配置商品后商店侧有缓存延迟。
解决:把请求的 ID 和后台逐个比对,尤其检查有没有从 Excel 复制带来的不可见字符。确认后台状态是“已准备好提交”而不是“待提交”。换一个同地区的账号复测。刚创建的商品等十几分钟再试,别急着改代码。
5.2 交易卡在 pending,用户批准后没到账
现象:用户点了购买,界面提示“等待批准”,家长批准之后道具没到账,重启应用也没有。
原因:Transaction.updates的监听任务没有在应用启动时第一时间建立,批准后的交易推过来时没有接收方,就一直挂着;或者代码把.pending当失败处理,直接把交易finish()掉了——这是不可逆的,交易被关单,钱扣了货没发。
解决:监听必须在didFinishLaunching阶段启动,早于任何 UI。.pending只提示,不做任何状态变更,更不调finish()。已经踩坑的用户只能靠“恢复购买”兜底:遍历Transaction.currentEntitlements,对非消耗型商品补发。
5.3 沙盒能买,线上买了不发
现象:沙盒环境全部正常,提审上线后真实用户反馈付款成功但没到账。
原因:凭证校验的服务端地址写死成了沙盒环境;或者服务端校验接口对environment字段做了强校验,只认Sandbox,生产环境传Production直接返回失败;还有一种是 Bundle ID 在打包时被改过,交易记录的bundleId与你服务端配置的对不上。
解决:校验接口要做环境自适应,从凭证里的environment字段读取而不是写死。上线前用 TestFlight 走一遍完整流程,TestFlight 用的是生产环境,能提前暴露这类问题。把bundleId、environment、transactionId三个字段一起打进错误日志。
5.4 重复发货,用户白拿两份
现象:部分用户收到两份道具,日志里同一笔交易出现两次。
原因:监听任务被启动了两次(didFinishLaunching和首页onAppear各一次),两个任务同时拿到同一个交易;或者发货逻辑先grant后落库,两次调用都查不到记录,都执行了发货。
解决:监听加单例保护,start()里判空。幂等判断必须在发货之前,且落库与发货要保证原子性——落库成功才发货,发货失败要回滚记录或标记为待补偿。别用内存里的Set做幂等,应用重启就没了。
5.5 退款与订阅过期没有回收权益
现象:用户申请退款成功,道具还在;订阅到期了,会员标识还亮着。
原因:只处理了购买成功,没处理revocationDate和订阅状态。退款交易同样会从Transaction.updates推过来,只是字段里带了撤销时间;订阅的实时状态要用Transaction.currentEntitlements或Product.SubscriptionInfo.status查。
解决:deliver里先判断tx.revocationDate != nil,是撤销就走回收逻辑而不是发货。订阅权益不要只看本地存储,每次冷启动用currentEntitlements重建一次权益快照,本地存储只当缓存。
6. 二次开发:把支付工具接进自己的业务
真正落地时,第一件该做的事是抽协议,而不是改源码。业务侧只依赖PaymentService,具体实现可以随时替换成 StoreKit 1 桥接、或者测试用的 Mock,这是让内购逻辑可测的唯一办法。
protocol PaymentService { func products(for ids: [String]) async throws -> [Product] func purchase(_ id: String, accountToken: UUID) async throws -> PurchaseOutcome func restore() async throws func currentEntitlements() async -> [Transaction] } extension PaymentService { // 恢复购买统一走系统同步,别自己遍历历史交易 func restore() async throws { try await AppStore.sync() } }restore()内部用AppStore.sync()而不是手动遍历,是因为系统同步会处理跨设备、换账号、家庭共享这些边界情况,自己写很难覆盖全。currentEntitlements()用来重建权益快照,非消耗型和订阅类商品必须有。
第二件事是把订单状态机固定下来。状态少了描述不清,多了没人维护,下面这套是实际跑下来比较平衡的划分:
| 状态 | 触发时机 | 允许的下一步 |
|---|---|---|
created | 用户点下购买 | pending/paid/failed |
pending | 返回.pending | paid/failed |
paid | 验签通过 | delivered/refunded |
delivered | 发货成功并finish() | refunded |
refunded | 收到撤销交易 | 终态 |
failed | 校验失败或取消 | 终态 |
状态迁移只允许走表中的路径,任何越级迁移都打日志告警。这套约束帮我抓到过一次隐蔽的 bug:某次改动后paid直接跳到了refunded,因为revocationDate的判断被放到了发货之前,而那个字段在某些历史交易里是脏数据。
验证方法上,我现在固定跑三条用例:正常购买只发一次货、pending 后重启能补发、退款能回收权益。这三条覆盖了线上 90% 的内购事故。补一条技巧——给DeliveryService加一个可以注入时钟的now参数,默认Date(),测试时注入固定时间,补发窗口的边界就不用靠等真实时间了。
从那以后我每次接内购,都强制先跑一遍本地.storekit配置下的三条用例,再上真机走沙盒,最后用 TestFlight 验生产环境。这个顺序被打乱过一次,代价是一个版本的用户补偿。希望帮到你。
本文还有配套的精品资源,点击获取