news 2026/10/31 2:19:14

Swift内购支付工具:StoreKit 1与2统一封装,解决审核被拒与丢单问题

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Swift内购支付工具:StoreKit 1与2统一封装,解决审核被拒与丢单问题

简介:面向iOS开发者的Swift内购支付实践工程,聚焦应用内购买(IAP)从产品配置到交易回调的完整链路,适合已具备Swift基础、希望打通StoreKit接入与收据验证环节的中级开发者参考。压缩包共31个文件、约66KB,以9个swift源码文件为核心,配合storyboard界面、plist配置、entitlements与工程文件,另有少量Objective-C头文件与实现用于混编演示,整体结构贴近真实项目分层。内容围绕SKProductsRequestDelegate与SKPaymentTransactionObserver展开,涵盖产品请求、支付队列监听、交易状态处理及恢复购买等关键节点,并预留服务端收据验证与订阅管理的扩展思路。已有1704人学习,可作为内购模块的起步脚手架,帮助读者快速理解支付流程与常见错误处理方式。

1. 从一次审核被拒说起:这个 Swift 内购工具到底解决什么问题

去年帮一个做工具类 App 的朋友处理上架,卡在审核环节整整两周,理由翻来覆去就一句:内购流程不符合规范。翻代码才发现,StoreKit 的调用散落在三个 ViewController 里,恢复购买没做,交易队列没监听,沙盒测试和线上环境混着跑。这类问题在 iOS 内购开发里太常见了——StoreKit 的 API 本身不难,难的是把「商品拉取、购买、验证、恢复、队列监听」这条链路串成一套稳定可复用的结构。这份 Swift 内购支付工具就是干这个的:把 StoreKit 1 和 StoreKit 2 两套接口封装成统一的门面,业务层只关心「买哪个商品、买成功了没」,底层的交易状态机、票据校验、异常重试全部收进工具内部。适合正在做订阅制或消耗型内购、又不想在每个页面重复写一遍 StoreKit 胶水代码的 iOS 开发者,尤其适合刚接手一个内购逻辑已经写乱了的项目、需要重构的人。

2. 工程结构与技术选型:为什么是 Swift Concurrency 加分层封装

2.1 目录骨架与模块职责

拿到这份源码,第一件事是看目录。工具本身不是一个完整 App,而是一个可嵌入的 Swift Package,外层配了一个 Demo 工程用来跑通流程。典型的目录结构长这样:

IAPKit/ ├── Package.swift ├── Sources/ │ └── IAPKit/ │ ├── Core/ │ │ ├── IAPManager.swift // 统一门面,业务层唯一入口 │ │ ├── IAPConfiguration.swift // 环境、商品 ID、日志开关 │ │ └── IAPError.swift // 错误枚举,统一错误语义 │ ├── StoreKit1/ │ │ ├── SK1TransactionObserver.swift │ │ └── SK1ProductLoader.swift │ ├── StoreKit2/ │ │ ├── SK2PurchaseService.swift │ │ └── SK2TransactionListener.swift │ ├── Receipt/ │ │ ├── ReceiptValidator.swift // 本地/服务端校验抽象 │ │ └── ReceiptModels.swift │ └── Persistence/ │ └── TransactionStore.swift // 交易记录落盘,防丢单 └── Demo/ └── IAPDemo/ ├── ProductListView.swift └── PurchaseViewModel.swift

分层意图很清楚:Core是对外契约,StoreKit1和StoreKit2是两个实现分支,Receipt负责票据校验,Persistence兜底交易记录。业务层只 importIAPKit,不直接碰 StoreKit,这样以后苹果再改 API,改动面被锁在工具内部。我一般会把这种「门面 + 多实现」的结构当成内购模块的默认起点,因为内购最怕的就是业务代码和系统 API 耦合,一旦耦合,测试和重构都是血泪经验。

2.2 技术选型:StoreKit 2 为主,StoreKit 1 兜底

选型上这份工具做了双轨:iOS 15 及以上走 StoreKit 2,用async/await和Transaction.updates监听;iOS 15 以下回落到 StoreKit 1 的SKPaymentQueue观察者模式。为什么不全用 StoreKit 2?因为存量设备里还有一批跑 iOS 14 的,直接砍掉会丢用户。为什么不全用 StoreKit 1?因为它的回调式 API 在 Swift Concurrency 下写起来别扭,交易验证还得手动解析appStoreReceiptURL,容易翻车。

统一门面的核心是一个协议,两个实现都遵守它:

// Core/IAPManager.swift public protocol IAPService { func loadProducts(ids: [String]) async throws -> [IAPProduct] func purchase(productId: String) async throws -> IAPTransactionResult func restore() async throws -> [IAPTransactionResult] var transactionStream: AsyncStream<IAPTransactionResult> { get } } public final class IAPManager: IAPService { private let service: IAPService public init(configuration: IAPConfiguration) { if #available(iOS 15.0, *) { self.service = SK2PurchaseService(config: configuration) } else { self.service = SK1PurchaseService(config: configuration) } } public func loadProducts(ids: [String]) async throws -> [IAPProduct] { try await service.loadProducts(ids: ids) } public func purchase(productId: String) async throws -> IAPTransactionResult { try await service.purchase(productId: productId) } public func restore() async throws -> [IAPTransactionResult] { try await service.restore() } public var transactionStream: AsyncStream<IAPTransactionResult> { service.transactionStream } }

这段代码的关键点在init里的版本分支:#available(iOS 15.0, *)决定注入哪个实现,业务层拿到的永远是IAPManager,感知不到底层差异。IAPProduct和IAPTransactionResult是工具自定义的模型,把 StoreKit 1 的SKProduct和 StoreKit 2 的Product抹平成同一套字段(商品 ID、价格、货币、交易 ID、原始交易 ID)。参数上IAPConfiguration承载商品 ID 列表、是否启用服务端校验、日志级别,这些配置在 Demo 里从Info.plist读,实际项目里我一般改成从远端配置拉,方便运营改商品而不发版。

2.3 依赖与最低版本

Package.swift里没有第三方依赖,纯系统框架:StoreKit、Foundation、OSLog。最低部署版本定在 iOS 14,Swift 工具链 5.7 以上(因为用了AsyncStream和async/await)。这个依赖面很干净,意味着嵌进任何项目都不会引入冲突,也不会因为某个三方库停更而卡住。Demo 工程用 SwiftUI 写,但工具本身不依赖 SwiftUI,UIKit 项目照样能用。

3. 核心链路拆解:一次购买从点击到落单经历了什么

3.1 商品拉取与本地缓存

购买第一步是拿到商品信息。StoreKit 2 的Product.products(for:)一次最多传 100 个 ID,返回顺序不保证,所以工具内部做了 ID 到商品的字典映射,再按传入顺序重排。StoreKit 1 那边则是SKProductsRequest的 delegate 回调,包成withCheckedThrowingContinuation转成 async。

// StoreKit2/SK2PurchaseService.swift @available(iOS 15.0, *) final class SK2PurchaseService: IAPService { private let config: IAPConfiguration private var productCache: [String: Product] = [:] func loadProducts(ids: [String]) async throws -> [IAPProduct] { do { let products = try await Product.products(for: ids) // 按传入顺序重排,避免 UI 列表顺序错乱 let ordered = ids.compactMap { id in products.first { $0.id == id } } for p in ordered { productCache[p.id] = p } return ordered.map { IAPProduct(from: $0) } } catch { throw IAPError.productLoadFailed(underlying: error) } } }

productCache的作用是购买时不用再拉一次商品,直接按 ID 取。这里有个细节:缓存不做过期,因为商品价格变动频率低,真要刷新就在进入商品页时重新调loadProducts。IAPError.productLoadFailed把系统错误包一层,业务层判断错误类型时不用去 matchStoreKitError的各个 case,统一走工具的错误枚举,这是减少业务层分支的常见做法。

3.2 购买与交易验证

购买是整条链路最容易出问题的地方。StoreKit 2 的product.purchase()返回一个Product.PurchaseResult,有.success(VerificationResult<Transaction>)、.userCancelled、.pending三种。工具把.success里的VerificationResult拆开:.verified才继续走校验,.unverified直接抛错并记录日志——这一步是防篡改的关键,跳过它等于把票据校验的门敞开。

func purchase(productId: String) async throws -> IAPTransactionResult { guard let product = productCache[productId] else { throw IAPError.productNotFound(id: productId) } let result = try await product.purchase() switch result { case .success(let verification): let transaction = try checkVerified(verification) // 先落盘再 finish,防止中途崩溃丢单 try await TransactionStore.shared.save(transaction) await transaction.finish() return IAPTransactionResult(from: transaction) case .userCancelled: throw IAPError.userCancelled case .pending: throw IAPError.pending @unknown default: throw IAPError.unknown } } private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T { switch result { case .verified(let safe): return safe case .unverified(_, let error): throw IAPError.verificationFailed(underlying: error) } }

顺序很重要:TransactionStore.save在transaction.finish()之前。如果先 finish 再落盘,落盘失败这笔交易就再也拿不回来了,用户付了钱没到账,客诉就来了。checkVerified是泛型函数,StoreKit 2 里Product.PurchaseResult和Transaction.updates都返回VerificationResult,复用同一个校验逻辑。参数上productId必须和loadProducts时传入的一致,否则productCache取不到会抛productNotFound。

3.3 交易队列监听与恢复购买

App 启动时就要挂上交易监听,处理那些「上次没 finish 完」的遗留交易,以及家长批准、跨设备购买等异步到账的场景。StoreKit 2 用Transaction.updates这个 AsyncSequence,工具把它转成自己的AsyncStream往外发:

// StoreKit2/SK2TransactionListener.swift @available(iOS 15.0, *) final class SK2TransactionListener { private var task: Task<Void, Never>? func start(handler: @escaping (IAPTransactionResult) -> Void) { task = Task.detached { for await update in Transaction.updates { guard case .verified(let transaction) = update else { continue } try? await TransactionStore.shared.save(transaction) await transaction.finish() handler(IAPTransactionResult(from: transaction)) } } } func stop() { task?.cancel() } }

Task.detached保证监听不阻塞主线程,for await持续消费更新。注意guard case .verified这里直接continue跳过未验证交易,不抛错也不 finish——未验证交易不该被确认,留着让系统重试或人工排查。恢复购买在 StoreKit 2 里是AppStore.sync(),调用后Transaction.updates会把历史交易重新推一遍,所以恢复逻辑不用单独写,复用监听即可。StoreKit 1 那边则是SKPaymentQueue.restoreCompletedTransactions(),工具在SK1TransactionObserver里做了同样的转换。

4. 票据校验与防丢单:服务端验证和本地兜底怎么配合

4.1 校验策略的两种模式

票据校验分本地和服务端两条路。本地校验用appStoreReceiptURL读票据,解析 ASN.1 结构,但苹果不推荐纯本地校验,因为越狱设备可以伪造。服务端校验是把receipt-dataPOST 到苹果的verifyReceipt接口,工具里ReceiptValidator定义成协议,默认实现走服务端,Demo 里配了一个 mock 端点方便本地跑通。

// Receipt/ReceiptValidator.swift public protocol ReceiptValidator { func validate(receiptData: Data, isSandbox: Bool) async throws -> ReceiptValidationResult } public final class ServerReceiptValidator: ReceiptValidator { private let endpoint: URL public init(endpoint: URL) { self.endpoint = endpoint } public func validate(receiptData: Data, isSandbox: Bool) async throws -> ReceiptValidationResult { var request = URLRequest(url: endpoint) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") let body: [String: Any] = [ "receipt": receiptData.base64EncodedString(), "sandbox": isSandbox ] request.httpBody = try JSONSerialization.data(withJSONObject: body) let (data, response) = try await URLSession.shared.data(for: request) guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { throw IAPError.receiptValidationFailed } return try JSONDecoder().decode(ReceiptValidationResult.self, from: data) } }

endpoint指向你自己的后端,后端再去调苹果接口,这样密钥不落在客户端。isSandbox参数决定后端用沙盒还是生产环境地址,这个值在IAPConfiguration里根据#if DEBUG或构建配置注入。ReceiptValidationResult里至少包含isValid、productId、expiresDate(订阅类需要),业务层据此决定是否解锁功能。

4.2 交易落盘与补单机制

TransactionStore用文件存储(FileManager+Codable写 JSON),没上 CoreData 或 SQLite,因为交易记录量小、结构简单,文件足够。每条记录带transactionId、productId、purchaseDate、isConsumed四个字段。补单逻辑在 App 冷启动时跑:读本地记录,筛出isConsumed == false的,逐条调后端确认,确认成功再标记 consumed。

// Persistence/TransactionStore.swift public final class TransactionStore { public static let shared = TransactionStore() private let fileURL: URL private var records: [TransactionRecord] = [] public func save(_ transaction: Transaction) async throws { let record = TransactionRecord( transactionId: String(transaction.id), productId: transaction.productID, purchaseDate: transaction.purchaseDate, isConsumed: false ) records.append(record) try persist() } public func pendingRecords() -> [TransactionRecord] { records.filter { !$0.isConsumed } } public func markConsumed(_ id: String) throws { guard let idx = records.firstIndex(where: { $0.transactionId == id }) else { return } records[idx].isConsumed = true try persist() } }

persist()把records编码成 JSON 原子写入,atomic: true防止写一半崩溃导致文件损坏。pendingRecords是补单入口,配合后端幂等接口使用——同一个transactionId重复提交,后端要能识别并返回成功,否则补单会重复发货。这套「先落盘、后确认、可重放」的机制是内购防丢单的标准姿势,少了任何一环都可能出现用户付钱没到账的玄学问题。

5. 避坑与排查:内购联调里最容易翻车的五个点

5.1 沙盒环境登录了正式账号,商品列表一直为空

现象是loadProducts返回空数组,控制台没有明显报错。原因通常是设备上登录的 Apple ID 是正式账号,而 App 是从 Xcode 直接跑的,StoreKit 走沙盒环境,两者不匹配。解决方法是到「设置 → App Store → 沙盒账户」里登录一个测试账号,或者在 Xcode 的 Scheme 里配 StoreKit Configuration 文件做本地测试。我一般会在IAPConfiguration里加一个isSandbox标记,启动时打日志把当前环境和账号类型打出来,省得靠猜。

5.2 交易监听没在启动时挂上,冷启动丢单

现象是用户反馈「买了会员,重开 App 又变回免费」。原因是Transaction.updates的监听写在某个页面的onAppear里,用户买完直接杀进程,下次启动没进那个页面,遗留交易没人处理。解决是把监听移到App或SceneDelegate的启动回调里,保证 App 一起来就开始消费。工具里SK2TransactionListener.start就是设计成在IAPManager初始化后立刻调用,Demo 的App结构体里能看到这个调用点。

5.3 先 finish 后校验,校验失败无法回滚

现象是服务端校验偶尔超时,但交易已经被finish(),苹果不再重推,这笔单就丢了。原因是代码顺序写反了。正确顺序是「拿到 verified 交易 → 落盘 → 服务端校验 → 校验通过才 finish」。如果校验失败,交易留在队列里,下次启动Transaction.updates会重新推。工具里purchase方法把save放在finish前,但服务端校验是业务层调的,所以文档里明确要求业务层在校验成功后再调markConsumed,这个约定不能破。

5.4 订阅类商品没处理过期和升降级

现象是用户订阅过期后仍能用高级功能,或者从月度升年度后两笔订阅同时有效。原因是只看了Transaction的购买成功,没读expirationDate和subscriptionGroupID。解决是在IAPTransactionResult里带上expiresDate,业务层每次启动用Transaction.currentEntitlements拉当前有效订阅,而不是只信本地缓存。工具在ReceiptModels里预留了这些字段,但订阅状态机需要业务层自己维护,工具不替你做决策。

5.5 恢复购买按钮点了没反应

现象是用户换设备后点「恢复购买」,界面无变化。原因是 StoreKit 2 下AppStore.sync()只是触发同步,结果通过Transaction.updates异步回来,如果监听没挂或者 UI 没订阅transactionStream,就看不到变化。解决是恢复按钮触发sync()后,让 UI 订阅IAPManager.transactionStream,收到交易再刷新界面。StoreKit 1 下则是等paymentQueueRestoreCompletedTransactionsFinished回调,工具已经把它转成 stream 事件,业务层统一处理即可。

6. 二次开发:把工具接进现有项目并做定制

6.1 接入步骤与最小改动路径

把IAPKit拖进现有工程,或者用 Swift Package 依赖本地路径,然后在启动处初始化并挂监听:

// App 启动入口 let config = IAPConfiguration( productIds: ["com.demo.pro.monthly", "com.demo.pro.yearly"], validator: ServerReceiptValidator(endpoint: URL(string: "https://your-backend/iap/verify")!), isSandbox: true ) let manager = IAPManager(configuration: config) manager.startTransactionListener { result in // 收到异步交易,刷新本地权益 EntitlementManager.shared.apply(result) }

startTransactionListener是工具在IAPManager上暴露的便捷方法,内部调对应实现的监听启动。业务层拿到result后做权益刷新,具体怎么存权益是业务自己的事。最小改动路径是:先只接loadProducts和purchase跑通单次购买,再补监听和补单,最后接服务端校验,分三步走比一次性全接风险低。

6.2 定制点:换校验后端、加商品类型、改存储

三个常见定制方向。换校验后端只需实现ReceiptValidator协议,把ServerReceiptValidator换成自己的实现,IAPManager不用动。加商品类型(比如从订阅扩展到消耗型)要在IAPProduct里补type字段,并在purchase成功后按类型决定是否调markConsumed——消耗型买完即消耗,订阅型要等过期。改存储则是把TransactionStore的文件实现换成数据库,只要保持save、pendingRecords、markConsumed三个方法签名不变,上层无感。

定制需求改动位置是否影响业务层
换服务端校验地址ServerReceiptValidator.endpoint否
新增消耗型商品IAPProduct.type+ 购买后处理是,需处理消耗逻辑
交易记录迁到 SQLiteTransactionStore内部实现否
支持促销优惠SK2PurchaseService加purchase(options:)是,需传优惠参数

6.3 验证方法与一个我踩过的坑

验证改造是否成功,别只靠手点。我一般会写三类检查:一是用 StoreKit Configuration 文件在 Xcode 里模拟购买、取消、pending 三种结果,跑单元测试断言IAPError类型正确;二是用沙盒账号在真机上跑一遍完整链路,重点看杀进程后重开能否补单;三是故意把后端校验接口改成超时,确认交易没被 finish、下次启动能重推。这三步走完,基本能覆盖大部分线上场景。

说个我自己的教训。早前重构一个内购模块,我把transaction.finish()挪到了服务端校验之后,逻辑上更严谨,但忘了处理「校验接口一直失败」的情况——交易永远不 finish,队列越积越多,最后 StoreKit 直接不推新交易了。后来改成校验失败超过三次就落盘标记为「待人工处理」并 finish,把问题从队列里摘出来交给后台对账。从那以后我每次改内购的 finish 时机,都强制走一遍「校验失败 + 重启 + 补单」的完整回归,不敢再凭逻辑直觉拍脑袋。希望这份拆解能帮你少走一遍我走过的弯路,把工具跑起来之后,先从监听和补单这两块改起,收益最直接。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/31 2:17:46

C语言学习笔记——常见操作符和表达式求值

C语言学习笔记——常见操作符和表达式求值 Hello大家好&#xff0c;又是我Mii&#xff0c;最近几天Mii又学习了常见操作符和表达式求值&#xff0c;所以Mii我又来写一篇文章来分享Mii学习到的知识了&#xff0c;话不多说直接开写(&#xff40;・ω・) 上次Mii学习了常亮和变量的…

作者头像 李华
网站建设 2026/10/31 2:17:10

继承和接口(Java)

继承继承是一种由已有的类创建新类的机制。利用继承可以先定义一个共有属性的一般类&#xff0c;根据该一般类再定义具有特殊属性的子类&#xff0c;子类继承一般类的属性和行为&#xff0c;并根据需要增加它自己的新的属性和行为。特点&#xff1a;java不支持多重继承&#xf…

作者头像 李华
网站建设 2026/10/31 2:16:26

山区落石目标检测数据集 助力边坡落石风险智能监测 AI深度学习第10837期

文章目录山区落石目标检测数据集&#xff0c;助力边坡落石风险智能监测数据概括应用价值小白入手训练代码山区落石目标检测数据集&#xff0c;助力边坡落石风险智能监测 边坡落石检测是山区道路地质安防的重要方向&#xff0c;这份山区落石目标检测数据集&#xff0c;面向边坡…

作者头像 李华
网站建设 2026/10/31 2:15:38

Claude 桌面端已经更新,为什么还报错?我把两个问题一起修好了

我明明已经下载了新版 Claude 桌面端&#xff0c;发一句问候&#xff0c;界面却只给我一个 Invalid request。 排查之后才发现&#xff0c;桌面端实际启动的 Claude Code 还是 2.1.237&#xff0c;而当前网关要求的最低版本是 2.1.280。修好这一层&#xff0c;界面又提示 Haiku…

作者头像 李华