简介:面向 iOS 开发者的 Swift 内购支付工具代码包,完整覆盖 StoreKit 框架集成、SKProductsRequest 产品请求、SKPaymentQueue 交易监听与购买恢复等核心环节,适合希望快速接入应用内购买功能的中初级开发者对照使用。压缩包内共 31 个文件,以 9 个 Swift 源码文件为主体,另含 Objective-C 的 .m/.h 文件、storyboard 界面布局、plist 配置文件、Xcode 工程描述与 entitlements 权限声明,整体仅 66KB,是一个可直接打开运行的轻量示例工程。作者在代码中实现了支付流程回调、交易状态处理、恢复购买与订阅管理,并给出内购收据验证及错误处理的思路;工程还包含多个视图控制器和 ApplePayManager 管理类,便于观察不同页面下的调用方式与跳转逻辑。已有 1702 人学习下载,适合作为从零搭建 IAP 功能时的脚手架,也可作为学习 StoreKit 内部机制的参考代码。
1. 苹果内购接入:为什么支付回调没触发,才是大多数工程的第一步坑
做 Swift 内购支付,代码本身并不复杂,真正卡住人的往往是回调不触发、商品列表拉不到、沙盒和线上环境分不清这类基础问题。这套 Swift 内购支付工具的核心,是把 StoreKit 1.0 的完整链路串起来:从 SKProductsRequest 拉取商品、SKPaymentQueue 发起支付、transactionObserver 统一处理回调,再到服务端二次收据校验。适合刚接触内购的新手直接照抄流程,也适合被审核拒绝后想重新梳理支付链路的老手对照排查。因为绝大多数支付事故,都发生在支付弹窗出现之前。
2. 工程初始化与商品拉起:三处开关、商品标识与 SKProductsRequest 的首次配置
2.1 苹果开发者后台与 Xcode 的两侧配置
内购不是写完代码就能跑的,第一个容易被忽略的步骤是后台商品配置。登录苹果开发者后台,进入“App 内购买项目”,新建一个商品时,需要先选对类型。消耗型项目适合道具、金币这类买完就消失的;非消耗型项目适合解锁功能、去广告这类永久有效的;自动续期订阅适合会员这类周期性扣费的;非续期订阅则是买一次生效一段时间、不自动续费。选错类型会导致后面逻辑全部跟着错,尤其是订阅类型,服务端的票据校验字段都不一样。
商品创建完之后,要填写显示名称、价格、审核截图等信息,一直把状态推进到“准备提交”或“已批准”,沙盒测试才能拉到这个商品。很多开发者在这里翻车:商品 ID 建了,但详情没填完,状态还是“缺失元数据”,客户端怎么请求都拿不到。
Xcode 侧就简单了,Target 的 Signing & Capabilities 里添加 In-App Purchase 能力,然后确认 build 的 Bundle Identifier 和后台 App 的 ID 一致。商品 ID 在后台创建时是挂在 App ID 下面的,如果 Bundle ID 不匹配,SKProductsRequest 会静默失败,返回 invalidProductIdentifiers。
2.2 拉取商品列表:SKProductsRequest 的一次调用
商品 ID 在后台维护,客户端这边用一个 ProductFetcher 统一处理。最核心的代码是下面这段。
import StoreKit final class ProductFetcher: NSObject, SKProductsRequestDelegate { private var productIDs: Set<String> private var completion: (([SKProduct]) -> Void)? init(productIDs: Set<String>) { self.productIDs = productIDs super.init() } func fetch(completion: @escaping ([SKProduct]) -> Void) { self.completion = completion let request = SKProductsRequest(productIdentifiers: productIDs) request.delegate = self request.start() } func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) { if !response.invalidProductIdentifiers.isEmpty { print("无效商品标识: \(response.invalidProductIdentifiers)") } completion?(response.products) } }这段代码的逻辑是:把后台维护的商品 ID 集合传给 SKProductsRequest,系统会去 App Store 拉取对应商品的元数据。回调里如果 invalidProductIdentifiers 非空,说明这些 ID 在后台不存在或状态不可用,需要回后台逐个核对。
启动请求用的是 request.start(),它和 perform() 的区别在于 start() 是异步发起且不需要在调用前设置 runloop 模式,日常使用写 start() 就行。completion 用逃逸闭包传回来,避免把 delegate 回调的结果散落在各个页面里。
2.3 展示价格:绕开 displayPrice 的隐性问题
拉取到 SKProduct 之后,商品标题、描述可以直接用 localizedTitle 和 localizedDescription,但价格处理要小心。SKProduct.price 是 NSDecimalNumber,priceLocale 是当前商店区域的 Locale,正确的做法是用 NumberFormatter 格式化。
let formatter = NumberFormatter() formatter.numberStyle = .currency formatter.locale = product.priceLocale let priceText = formatter.string(from: product.price) ?? "\(product.price)"有些版本的习惯是把 product.displayPrice 直接拿来用,这个属性在 iOS 11.2 之后可用,但在部分系统版本和地区下可能返回空字符串,而且它内部用的是设备当前区域,不是商店区域,可能出现设备改语言后价格显示和实际扣费不一致的情况。我一般会坚持用 NumberFormatter 手动格式化,虽然多三行代码,但显示稳定性好很多。
2.4 发起支付:canMakePayments 检查与交易入队
用户点了购买按钮之后,不是直接把 SKPayment 丢进队列,先检查设备是否允许内购。家长控制、企业限制都会让 canMakePayments() 返回 false。
guard SKPaymentQueue.canMakePayments() else { // 弹出提示:当前设备不允许应用内购买 return } let payment = SKMutablePayment(product: product) payment.quantity = 1 SKPaymentQueue.default().add(payment)SKMutablePayment 是可变的支付对象,product 参数决定这次买的是哪个商品,quantity 默认是 1,消耗型商品如果允许批量购买可以调大,但非消耗型和订阅类型不要改这个值。add(payment) 会把支付请求投递到系统队列,这时候系统会自动弹出付款确认框,后续的结果由 paymentQueue 的 observer 回调分发。
这里有一个非常关键的细节:paymentQueue 的 observer 必须在 App 启动时就挂上,而不是等用户点购买按钮才挂。很多人把 addObserver 写在商品详情页的 viewDidLoad 里,结果支付完成的回调晚于页面释放,交易队列里的事务永远没人处理,越积越多。最稳妥的做法是在 AppDelegate 的 didFinishLaunchingWithOptions 里挂一个全局单例 observer。
3. 收据校验与服务端发货:支付成功后的十秒时序
3.1 观察者回调:交易状态机的六个分支
SKPaymentQueue 的 observer 回调是内购的核心事件源,它会在交易状态变化时触发 paymentQueue(_:updatedTransactions:)。
extension IAPManager: SKPaymentTransactionObserver { func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) { for transaction in transactions { switch transaction.transactionState { case .purchased: // 支付成功,走收据上报逻辑 handlePurchased(transaction) case .failed: if let error = transaction.error as? SKError { print("支付失败: \(error.code.rawValue) - \(error.localizedDescription)") } SKPaymentQueue.default().finishTransaction(transaction) case .restored: // 恢复购买成功,走恢复逻辑 handleRestored(transaction) case .deferred: // 家长同意或询问流程,等待外部结果 break case .purchasing: // 正在处理中,什么都不做 break @unknown default: break } } } }purchased 分支里不要立刻调 finishTransaction。正确顺序是:拿出收据、上报服务端、等服务端返回验证通过并完成发货之后,再调用 finishTransaction。finishTransaction 的意义是告诉系统这笔交易已经处理完毕,可以从队列里移除。如果客户端只发货不上报,中途 App 被杀,服务端就永远收不到这笔交易。反过来如果服务端已经发货了,finishTransaction 又迟迟不调用,系统会在下次启动时重新投递这个事务,造成重复发货的隐患。
failed 分支一定要调用 finishTransaction,否则失败的交易会一直卡在队列里,后续新的购买会被阻塞。
3.2 客户端读取收据文件
交易状态确认之后,客户端要做的是把收据数据取出来,传给自己的服务端。收据不是从 SKPaymentTransaction 对象里拿,而是从 App 的 sandbox 目录里读取。
guard let receiptURL = Bundle.main.appStoreReceiptURL, let receiptData = try? Data(contentsOf: receiptURL) else { // 这里需要发起收据刷新请求 let refreshRequest = SKReceiptRefreshRequest() refreshRequest.delegate = self refreshRequest.start() return } let receiptString = receiptData.base64EncodedString() // 把 receiptString 传到自己的服务端appStoreReceiptURL 在生产环境和沙盒环境下指向不同的物理路径,这套逻辑对两端都适用。极少情况下收据文件还没生成,会走到 else 分支,这时候用 SKReceiptRefreshRequest 向 App Store 请求重新生成收据。等到回调完成后再重试上报。
3.3 服务端二次验证:verifyReceipt 的参数与沙盒回退
客户端拿到的 receiptString 是不能直接信任的,真正的发货依据来自服务端向 App Store 验证的结果。下面是 Python 侧常见的验证写法。
import requests def verify_receipt(receipt_data, shared_secret): payload = { "receipt-data": receipt_data, "password": shared_secret, "exclude-old-transactions": True, } # 生产环境验证 resp = requests.post( "https://buy.itunes.apple.com/verifyReceipt", json=payload, timeout=10, ) result = resp.json() # 沙盒收据会返回 21007,此时回退到沙盒地址重新验证 if result.get("status") == 21007: resp = requests.post( "https://sandbox.itunes.apple.com/verifyReceipt", json=payload, timeout=10, ) result = resp.json() return result先请求生产环境地址,如果返回 status 21007,再切到沙盒地址验证。这个顺序不能反,因为沙盒地址只接受测试环境的收据,真实线上收据打过去会返回 21008,表示这是一个生产环境的收据,不应该走沙盒。
三个参数的用途:receipt-data 是客户端传来的 base64 字符串,必须原样传,不能做 URL 解码或换行清洗;password 是订阅类商品的共享密钥,在开发者后台的“App 内购买项目”详情页里找到,消耗型和非消耗型商品可以不传;exclude-old-transactions 设为 true 后,验证结果里的 latest_receipt_info 只会包含当前这笔最新交易,避免把历史购买记录都带回来,服务端解析时省很多事。
3.4 验证结果解析:防重复发货与订阅状态
苹果返回的 JSON 结构里有两个关键数组。latest_receipt_info 是最新交易的明细,数组最后一项是当次购买的信息;in_app 是收据里所有交易的集合,订阅续期时每一期都会追加一条。pending_renewal_info 是订阅续期状态数组,里面包含 auto_renew_status、expiration_intent 这些判断下一个月是否还会扣费的字段。
服务端判定发货时要拿 transaction_id 做唯一性校验,这个 ID 在每笔交易里是唯一的,同一笔交易重复上报时不会变。
latest = result["latest_receipt_info"] transaction_id = latest[-1]["transaction_id"] if not already_paid(transaction_id): deliver_goods(latest[-1]) mark_paid(transaction_id)这套逻辑解决的是最头疼的重复发货问题。客户端网络波动时,同一个交易可能被上报两次,如果没有去重,用户就会收到双倍道具。我一般会把 transaction_id 写入带唯一索引的数据库表,或者用 Redis 的 set 结构做判重,双保险。
4. 内购避坑:沙盒环境、审核账号与四个高频翻车记录
4.1 商品列表请求成功但返回空数组
现象:SKProductsRequest 的回调正常执行了,但 response.products 是空数组,invalidProductIdentifiers 也是空的。
原因:后台的商品 ID 状态还是“缺失元数据”或“等待审核”,沙盒环境只能测试“准备提交”和“已批准”状态的商品。另一个常见原因是后台虽然填完了信息,但商品没有提交审核,沙盒环境读不到。
解决:登录开发者后台,打开对应商品的编辑页,确认所有必填项都填完,状态推进到“准备提交”。如果刚改过状态,等一分钟再拉一次,商店缓存不是实时刷新。另外确认代码里传的 Set 里的字符串和后台商品 ID 完全一致,包括大小写和连字符。
4.2 支付成功但 updatedTransactions 一直不触发
现象:付款弹窗正常弹出,指纹或面容验证也通过了,但代码里的 paymentQueue(_:updatedTransactions:) 一直没有走到 purchased 分支。
原因:交易观察者没有被正确持有。如果是在某个商品页面里写的 SKPaymentQueue.default().add(self),这个页面 pop 掉之后 observer 被释放,回调自然就丢了。更隐蔽的情况是 App 启动时系统把上一次遗漏的未完成交易重新投递,但 observer 挂载时机晚了,错过了投递窗口。
解决:把 observer 挂到一个全局单例上,在 AppDelegate 的 didFinishLaunchingWithOptions 里越早越好。单例生命周期和 App 一致,不会因为页面销毁而丢失。还有一点,如果之前测试时产生过未完成交易,重新启动后拿到旧的 transaction,要在处理完调 finishTransaction 清掉,否则同一笔交易每次启动都过来一遍。
4.3 沙盒账号登录后购买弹窗出不来
现象:点击购买按钮后一直转圈,不弹密码输入框,过一会提示“无法连接到 App Store”。
原因:设备上已经登录了一个正式的 Apple ID,并且开启了双重认证,沙盒账号无法直接在设置里切换。或者是沙盒测试账号密码输错多次被临时锁定。
解决:在系统的 App Store 里退出当前账号,注意是“退出登录”,不是“切换地区”。然后回到 App 里触发购买,系统会先弹登录框,在登录界面输入沙盒账号。沙盒账号的密码规则比较宽松,但如果连续输错锁定了,回到开发者后台创建一个新的沙盒账号测试,别在原有账号上等解锁。
4.4 服务端验证返回 21002
现象:客户端上传收据后,服务端调用 verifyReceipt 返回 status 21002,提示 receipt-data 格式异常。
原因:客户端在 base64 编码时做了多余的加工,最常见的是用String(data: encoding: .utf8)把收据二进制先转成字符串再 base64,或者拼接 JSON 时自动转义了换行符,导致服务端拿到的字符串不够“纯”。
解决:客户端严格用Data(contentsOf: receiptURL).base64EncodedString()生成,不要做任何 trim、替换、格式化。服务端在调 verifyReceipt 前可以先用base64.b64decode(receipt_data, validate=True)自检一遍,抛异常就说明客户端数据有问题,直接返回参数错误,比打到苹果接口再排查快得多。
4.5 沙盒与生产环境验证串台
现象:同一套后端代码,测试环境验证通过,线上支付后却报 21008,或者反过来线上收据在测试环境返回 21007。
原因:代码里沙盒地址和生产地址的处理顺序写反了。先把请求打到沙盒地址,线上收据会被拒绝;或者只在配置项里填了一个地址,导致两种环境的收据混着走。
解决:服务端验证固定写成“先生产,后回退”,只有遇到 21007 才切沙盒。不要根据环境变量去猜当前是测试还是线上,因为客户端传到服务端的收据可能是沙盒的也可能是在线上的,以状态码为准最稳妥。收到 21008 时不要盲目切换地址,先检查这段逻辑有没有把地址写反。
5. 上线前验证清单与订阅续期判断:从沙盒到提审前的最后一遍自查
提审前我习惯把内购相关检查项列成表格,逐条过,漏掉任何一项都可能是审核拒绝的理由。
| 验证项 | 通过标准 | 常见遗漏 |
|---|---|---|
| 真机完整购买链路 | 沙盒账号完成下单、支付、发货、掉单补偿 | 只在模拟器上测试 |
| 服务端环境回退 | 生产收据用时验证通过,沙盒收据走 21007 回退 | 地址顺序写反 |
| 商品状态与 ID | 所有商品 ID 在后台处于“准备提交”以上的状态 | 新增商品忘记推进状态 |
| 弱网与断网重试 | 支付中断后重新启动,能恢复未完成交易 | 没有处理事务重投 |
| 重复发货防护 | 同一 transaction_id 重复上报只发一次货 | 数据库没做唯一索引 |
| 审核测试账号 | 后台创建沙盒账号,并在审核备注里写明测试路径 | 只留内部测试账号,审核员无法复现 |
订阅类型的续期判断是内购里最容易出边界问题的地方。很多开发者只判断 expires_date 是否大于当前时间,但这个字段在用户主动取消订阅后的宽限期内仍然显示有效,导致服务端多放行了好几天。更可靠的判断要结合 pending_renewal_info。
def subscription_active(receipt_result): latest = receipt_result["latest_receipt_info"] pending = receipt_result.get("pending_renewal_info", []) expires_ms = latest[-1]["expires_date_ms"] expires_ts = int(expires_ms) / 1000.0 now = time.time() if now >= expires_ts: return False # expiration_intent=1 表示用户已取消,但还没到账期结束 if pending and pending[0].get("expiration_intent"): return False return Trueexpires_date_ms 是毫秒级时间戳,除以 1000 转成秒再和当前时间比,这里很多人会忘记做单位换算。expiration_intent 的值 1 表示用户主动取消,2 表示计费错误,3 表示无法续期,只要这个字段存在,哪怕 expires_date 还在未来,也要按不续期处理。另外订阅订单的 original_transaction_id 是用户在这个 App 里的唯一订阅标识,续期订单的 transaction_id 会不断变化,但 original_transaction_id 从首购到退订始终不变,判断是否同一个用户订阅时只认这个字段。
审核备注里写测试路径也是一门玄学。我会在提审时明确写出:使用提供的沙盒账号登录,进入设置页,点击订阅入口,完成购买,然后到某个页面查看会员状态。沙盒账号密码一起附上,尽量减少审核员的操作步骤。好几次审核被拒都是因为审核员找不到内购入口,而不是功能有问题。
从最早一次因为 observer 挂载位置不对导致线上用户购买后不发道具开始,我每次提审前都强制走一遍这张清单,尤其是重新确认商品状态和服务端环境回退逻辑。这套 Swift 内购支付工具最值钱的部分也正是这些经验沉淀,而不是那几十行能跑的代码。希望帮到你。
本文还有配套的精品资源,点击获取