news 2026/10/27 2:08:25

iOS内购全链路实践:StoreKit 2、JWS校验与交易关单

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
iOS内购全链路实践:StoreKit 2、JWS校验与交易关单

简介:这份面向iOS开发者的Swift内购示例工程,围绕App Store应用内购买(IAP)的接入流程展开,适合需要为应用添加消耗品、非消耗品或订阅购买能力的初中级开发者参考。压缩包共31个文件、约66KB,以9个Swift源码为核心,配合plist配置、storyboard界面、entitlements权限文件及桥接头文件等,呈现内购模块在Xcode工程中的组织方式,并附带测试与工程配置内容便于整体查看。已有1704人学习,说明其在iOS内购入门与联调场景中具有一定参考价值。示例覆盖StoreKit导入、产品信息请求、支付队列监听、交易状态处理、恢复购买及收据验证思路,也对订阅续订、取消和错误提示等环节作出示范。读者可据此梳理从配置商品、发起支付到回调处理的完整链路,结合界面与权限配置理解接入要点,并对照交易观察者注册、恢复购买和合规校验等关键步骤查漏补缺。

1. 内购按钮点下去没有反应,问题往往不在支付本身

有个做工具类 App 的开发者找我排查:内购按钮点下去转圈十几秒,随后没有下文,控制台也没有报错。追到最后发现,他把购买回调塞进了某个页面的viewDidLoad,页面一销毁,交易就悬在半空,钱扣了、权益没到。这类问题在 iOS 内购里非常典型——它并不仅仅是拉起一个支付框,而是一条从商品查询、支付请求、凭证校验到交易关单的完整链路,任何一环断掉都会变成扣款不到账。这份用 Swift 写的苹果内购支付工具,做的就是把这套链路整合成一个可复用模块:商品加载、购买状态机、JWS 凭证校验、交易长驻监听、恢复购买、日志埋点各自分层,接入方只需要关心自己的权益怎么发。适合正在做 iOS 内购、被沙盒环境和丢单折磨过的开发者。

2. 工程结构与分层:从 Product 到 Entitlement 的四层拆分

内购代码写成一团,根源通常是「谁都能拿到 Transaction」。页面直接调product.purchase(),回调里直接改 UI 状态,服务端校验又写在另一个单例里,最后没人说得清一笔交易到底走完了没。这个项目的做法是先分层,再写逻辑,把 StoreKit 的原始类型关在 Core 层里,上层只看到自己定义的模型。

2.1 目录结构与模块职责

IAPKit/ ├── Sources/ │ ├── Core/ │ │ ├── StoreKitClient.swift # StoreKit 2 唯一入口,包住 Product / Transaction │ │ ├── PurchaseState.swift # 购买状态机,UI 只读这个枚举 │ │ └── ProductCatalog.swift # 商品 ID 与业务权益的映射表 │ ├── Transaction/ │ │ ├── TransactionObserver.swift # Transaction.updates 长驻监听 │ │ ├── TransactionVerifier.swift # JWS 解包 + 转发服务端 │ │ └── PendingStore.swift # 未完成交易的本地兜底队列 │ ├── Server/ │ │ ├── APIClient.swift # 把 jwsRepresentation 发给自家后端 │ │ └── EntitlementSync.swift # 权益拉取与本地缓存合并 │ └── UI/ │ ├── PaywallViewModel.swift # 商品展示 + 按钮可用状态 │ └── PaywallView.swift └── Tests/ └── StoreKitTestSupport.swift # 用 .storekit 配置文件跑本地测试

四层的边界很清楚:Core负责和 StoreKit 对话,Transaction负责「这笔交易到底算不算数」,Server负责和自家后端同步,UI只订阅PurchaseState,不持有任何Transaction对象。这样拆的好处是测试能单独跑——StoreKitTest框架可以在模拟器里伪造购买、退款、续订失败,不需要真机也不需要沙盒账号。

ProductCatalog是一张简单的映射表,把苹果后台的商品 ID 映射到业务侧的权益枚举。别小看这一层,商品 ID 拼错、后台协议没签、商品被下架,最终表现都是Product.products(for:)返回空数组,没有这张表,排查时你连该拿哪个 ID 去对都不知道。

2.2 技术选型:为什么是 StoreKit 2 而不是老收据方案

老方案的核心是appStoreReceiptURL拿到的整包收据,Base64 之后丢给服务端打verifyReceipt,再解析in_app数组。问题在于收据是「整个 App 的」,随着购买次数增长会越来越大,而且旧收据里混着历史交易,服务端得自己算哪笔是新的。StoreKit 2 换成了按交易签发的 JWS,每笔交易一个transactionId,天然幂等。

选型的代价是最低版本。Product.products(for:)、Transaction.updates、VerificationResult都是 iOS 15 起才有的 API,Product.SubscriptionInfo相关能力还要更高。项目的处理方式是:主链路走 StoreKit 2,对 iOS 14 及以下直接下掉购买入口,只保留已购权益的读取。这比维护两套并行的支付代码划算得多——我见过强行双栈的项目,最后两边的权益状态对不上,退款了老版本还能用。

另外一个细节是并发模型。整个StoreKitClient标了@MainActor,购买状态的变化天然在主线程,UI 不用再做DispatchQueue.main.async的套娃。真正耗时的网络校验放到Task.detached里,靠await回主线程改状态。

2.3 状态机:让 UI 只有一个真相来源

enum PurchaseState: Equatable { case idle // 空闲,可以点购买 case loadingProducts // 正在拉商品列表 case purchasing(String) // 正在支付,携带商品 ID case pending // 待家长批准 / 银行二次验证 case purchased(String) // 已购且权益已发放 case failed(IAPError) // 失败,带可展示的错误 }

UI 层只做一件事:switch state决定按钮文案和是否可点。purchasing状态下按钮禁用,避免用户连点两次触发两笔交易。pending单独拎出来很重要——家长控制(Ask to Buy)场景下purchase()会立刻返回.pending,真正的交易要等几分钟甚至几小时后从Transaction.updates回来,如果把它和失败混在一起,用户会以为没买成功又点一次。

3. 核心链路拆解:一次购买从点击到关单

链路顺序是固定的:拉商品 → 发起购买 → 校验签名 → 发权益 → 上报服务端 → 关单。少一步都会出问题,顺序错了更麻烦。

3.1 商品加载与 ID 集中管理

import StoreKit @MainActor final class StoreKitClient: ObservableObject { @Published private(set) var state: PurchaseState = .idle // 商品 ID 集中一处,禁止散落在各个 View 里 private let productIDs: Set<String> = [ "app.pro.monthly", "app.pro.yearly", "app.coin.60" ] func loadProducts() async throws -> [Product] { state = .loadingProducts let products = try await Product.products(for: productIDs) // 协议未签、ID 拼错、商品被下架,这里都会是空数组 guard !products.isEmpty else { state = .failed(.emptyCatalog) throw IAPError.emptyCatalog } state = .idle return products.sorted { $0.price < $1.price } } }

Product.products(for:)接受的是Set<String>,传重复 ID 不会报错但也没意义。返回的数组顺序不保证和传入顺序一致,所以展示前要自己排序——按价格排是最常见的做法,年付通常要放前面做推荐,那就按product.type和自定义权重排。

Product对象本身只包含展示信息:displayName、displayPrice、description。价格一定要用displayPrice而不是自己拼字符串,它会按用户所在区域的货币符号和格式本地化,自己拼"$" + price在日元区会显示成四不像。

商品列表建议做一次内存缓存,页面每次出现都去拉一遍没必要,苹果侧对这个接口有频率限制。缓存的失效时机是「购买成功」和「App 从后台恢复」,前者可能改了订阅状态,后者可能用户在 App Store 里改了付款方式。

3.2 发起购买与签名校验分支

func purchase(_ product: Product) async throws -> PurchaseOutcome { state = .purchasing(product.id) let result = try await product.purchase() switch result { case .success(let verification): let transaction = try checkVerified(verification) // 顺序不能换:先发权益,再上报,最后关单 await entitlement.apply(transaction) try await api.upload(jws: verification.jwsRepresentation) await transaction.finish() state = .purchased(transaction.productID) return .completed case .userCancelled: state = .idle return .cancelled case .pending: // 家长批准 / 银行验证,稍后从 updates 回来,别当成失败 state = .pending return .pending @unknown default: state = .idle return .unknown } } private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T { switch result { case .unverified(_, let error): throw IAPError.unverified(error) // 越狱设备伪造 / 中间人篡改 case .verified(let safe): return safe } }

purchase()返回的是VerificationResult<Transaction>,不是裸的Transaction。这个包装类是 StoreKit 2 的核心设计:苹果在设备本地用证书链验了签,但把结果交给你自己判断。unverified分支必须显式处理并终止流程,直接try!或者忽略会让越狱设备上的伪造交易畅通无阻。

transaction.finish()的位置最容易写错。它的语义是「这笔交易我处理完了,可以从队列里移除」。如果在发放权益之前就关单,一旦后面发权益失败,这笔交易就永远消失了,用户只能走客服。正确顺序是先确认权益落库、服务端确认收到,再关单。

userCancelled和pending都不能算错误。前者是用户主动放弃,弹错误提示很打扰;后者是流程还没结束,UI 上应该显示「等待批准」而不是「支付失败」。

3.3 长驻监听与关单兜底

func startObserving() { guard updatesTask == nil else { return } // 只能起一个,重复起会重复发权益 updatesTask = Task(priority: .background) { [weak self] in for await update in Transaction.updates { guard let self else { return } do { let transaction = try await self.verify(update) await self.entitlement.apply(transaction) try await self.api.upload(jws: update.jwsRepresentation) await transaction.finish() } catch { // 失败就落盘,下次启动重试,绝不留悬空交易 await self.pendingStore.enqueue(update.jwsRepresentation) } } } }

Transaction.updates是一个永不结束的异步序列,它会把「所有不在当前会话里完成的交易」推给你:待批准的购买被家长同意、跨设备购买、订阅自动续订、退款、家庭共享。这个监听必须在 App 启动的最早期挂上,通常放在App.init或者didFinishLaunching里,挂在某个页面 Controller 上就意味着页面销毁时丢单。

verify里的签名校验和 3.2 完全一致,抽成私有方法复用。失败时把 JWS 原文写进本地队列,App 下次冷启动第一件事就是把队列里的交易重放一遍。这一层兜底是整个模块里最值钱的部分,没有它,网络抖动一次就是一笔客诉。

3.4 恢复购买与当前权益读取

func restore() async throws { try await AppStore.sync() // 会弹系统账号密码框,别在启动时偷偷调 for await result in Transaction.currentEntitlements { guard case .verified(let transaction) = result else { continue } // 只认未撤销、未过期的交易 guard transaction.revocationDate == nil else { continue } await entitlement.apply(transaction) } }

AppStore.sync()会强制刷新收据并要求用户重新登录 App Store 账号,属于强打扰操作,只能由用户点「恢复购买」按钮触发。恢复购买入口是审核硬性要求,订阅类 App 没有这个按钮会被打回。

Transaction.currentEntitlements才是判断「用户当前拥有什么」的正确姿势,它已经帮你过滤掉了过期和退款的交易。别再自己解析收据数组算过期时间,时区、宽限期、账单重试这些边界能把你算疯。

4. 凭证校验与服务端对接:JWS、Server API 与回调

客户端拿到verified只代表「苹果签的,没被篡改」,不代表「这笔交易该给权益」。重复使用同一个 JWS、跨账号转移、退款后继续用,都得服务端说了算。

4.1 客户端只做搬运,判定放服务端

客户端的职责边界要划死:拿到verification.jwsRepresentation原样上传,服务端返回「给不给权益、给多少、给多久」。客户端不要在本地算订阅到期时间,也不要把「本地时间」当依据——用户改个系统时间就能白嫖。

func upload(jws: String) async throws { var request = URLRequest(url: baseURL.appendingPathComponent("/iap/verify")) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.timeoutInterval = 8 // 内购校验不能无限等,超时走重试队列 request.httpBody = try JSONEncoder().encode(["signedTransaction": jws]) let (data, response) = try await URLSession.shared.data(for: request) guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { throw IAPError.serverRejected } _ = data // 服务端返回权益快照,交给 EntitlementSync 合并 }

超时时间设短一点是有讲究的。内购校验接口一旦挂起,用户就卡在转圈界面,体验比直接失败还差。8 秒拿不到结果就走本地队列,UI 提示「处理中,稍后自动到账」,比干等强。请求体里别塞用户 ID 之外的敏感信息,交易凭证本身已经能唯一定位这笔购买。

4.2 服务端两条校验路径

# 服务端:用客户端传来的 transactionId 反查,比信任客户端字段靠谱 import requests def fetch_transaction(transaction_id: str, token: str) -> dict: url = f"https://api.storekit.itunes.apple.com/inApps/v1/transactions/{transaction_id}" resp = requests.get(url, headers={"Authorization": f"Bearer {token}"}, timeout=5) resp.raise_for_status() # 返回的是 signedTransactionInfo,仍需验签后解析 return resp.json()["signedTransactionInfo"]

App Store Server API 需要一串用 ES256 私钥签出来的 JWT,header 里放kid,payload 里放签发方标识、Bundle ID 和过期时间。这个 token 有效期很短,建议缓存复用而不是每次请求现签,签名开销不小。

老项目可能还在用verifyReceipt,那就必须处理状态码21007:沙盒环境的收据打到生产地址会返回这个码,正确做法是收到 21007 后自动切到沙盒地址重试一次。反过来的21008是生产收据打到了沙盒地址。这两个码是内购联调阶段最常见的「玄学」问题,日志里不打印原始状态码根本查不出来。

4.3 回调 V2 与幂等落库

订阅类商品不能只靠客户端上报,用户卸载 App、换设备、自动续订,客户端根本没机会发请求。App Store Server Notifications V2 会把SUBSCRIBED、DID_RENEW、EXPIRED、DID_FAIL_TO_RENEW、REFUND这些事件推到你配置的地址,body 里是一个signedPayload。

处理要点有三个:一是回调会重复投递,必须用transactionId做唯一键,重复的直接返回 200;二是要先回 200 再处理业务,处理超时会被判定失败并重投;三是退款事件不能只标记「已退款」,要同步收回权益,订阅场景下还要决定是立即失效还是等当前周期结束——这个策略得产品定,但代码里必须先留好分支。

5. 避坑与排查:内购上线前最容易出问题的五件事

5.1 商品列表返回空数组

现象是付费页一片空白,Product.products(for:)返回[]也不抛错。原因通常有三种:App Store Connect 里的付费应用协议没签或者税务信息没填,商品 ID 和后台不完全一致(大小写、前后空格),以及新创建的商品有几分钟到几小时的生效延迟。排查顺序是先登后台确认协议状态为「有效」,再用Product.products(for:)单独跑一个只含一个 ID 的调试页面,能拉到就说明是 ID 集合里有脏数据。

5.2 沙盒购买报 21007 或者校验一直失败

现象是沙盒账号能正常付款,服务端校验却返回21007。原因是客户端在沙盒环境下产生的收据,被服务端发到了生产校验地址。解决方法是服务端按状态码做双地址重试,而不是靠客户端上报「我是沙盒」——客户端的自述不可信。另外appStoreReceiptURL的路径里含sandboxReceipt可以辅助判断,但别把它当成唯一依据。

5.3 同一笔交易反复弹窗、反复发货

现象是用户登录后不断收到「购买成功」提示,服务端订单表里同一笔交易多条记录。原因是transaction.finish()没调用,或者调用在发货失败的分支里被跳过了。未关单的交易会在每次 App 启动时重新出现在Transaction.updates里。解决办法是把finish()放在发货成功之后,并在服务端用transactionId做幂等,两头都做才保险。

5.4 用户付款成功但权益不到账

现象是苹果扣款邮件都收到了,App 里还是免费版。八成是Transaction.updates挂得太晚,或者挂在了会被销毁的对象上,交易推送过来时没有接收者。还有一种情况是pending状态被当成了失败,用户以为没买成又点了一次。排查方法是加一条启动日志,打印currentEntitlements的数量和每笔的transactionId,和后台订单对一遍就知道差在哪。

5.5 测试账号切换不生效

现象是换了沙盒测试账号,购买还是报「无法完成购买」。原因是系统设置里的 App Store 登录态还停留在旧账号,AppStore.sync()也刷不掉。正确操作是到系统设置的 App Store 里退出登录,再从 App 内触发购买,这时才会弹出输入新账号的框。这个坑几乎每个做内购的人都遇到过,写在接入文档第一页能省掉一半的答疑。

6. 二次开发:把单次购买改成订阅加虚拟币的双模式

二次开发最常见需求是「既要订阅又要消耗型商品」。这个模块的改造点集中在三处,不需要动 StoreKit 层。

第一处是ProductCatalog,把权益映射从单一枚举改成带类型的结构:

enum Entitlement { case subscription(level: Int, expiresAt: Date) // 订阅,带到期时间 case coins(amount: Int) // 消耗型,一次性加币 case none } struct ProductRule { let productID: String let entitlement: (Transaction) -> Entitlement }

消耗型商品的特殊之处在于必须「先发货再关单」,且发货要幂等。transactionId作为唯一键写进服务端,重复请求直接返回成功,客户端再关单。订阅则相反,关单可以早一点,因为续订是苹果主动推的,不依赖这次关单。

第二处是EntitlementSync,加一个本地缓存加服务端快照的合并策略。启动时先读本地缓存让 UI 立刻可用,同时异步拉服务端权威数据,两者不一致以服务端为准。缓存里记lastSyncedAt,超过 24 小时强制刷新一次。

第三处是订阅特有的边界:宽限期(Billing Grace Period)和账单重试。用户在宽限期内应该继续享有权益,Transaction.currentEntitlements已经帮你处理了,但服务端落库时要把expiresDate和gracePeriodExpiresDate分开存,否则宽限期一过就会误判为过期。

改造点消耗型自动续期订阅
关单时机发货成功后校验通过即可
幂等键transactionIdoriginalTransactionId
权益回收不涉及退款 / 过期事件触发
本地缓存余额单独存到期时间 + 宽限期

验证方法建议用 Xcode 的 StoreKit 配置文件跑:新建一个.storekit文件,配好订阅周期和消耗型商品,把 Scheme 里的 StoreKit Configuration 指向它,模拟器里就能完整走通购买、续订、退款、Ask to Buy 全流程,不用反复切沙盒账号。上线前再拿真实沙盒账号走一遍真机流程,两条路径都过一遍才算稳。

从那以后我每次接内购,都强制先把「未关单交易的重放」这条路径写好,再写购买按钮——订单丢了可以补,用户信任丢了补不回来。希望帮到你。

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

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

基于JDBC+Servlet的教室管理系统课程设计:从建表到预约链路实战

简介&#xff1a;这份数据库应用课程设计资源面向计算机相关专业学生与Web开发初学者&#xff0c;围绕教室管理系统这一典型课程项目&#xff0c;帮助读者完成从数据库设计到Web实现的完整实践。压缩包共39个文件&#xff0c;约1.94MB&#xff0c;以jsp页面、css样式、js脚本及…

作者头像 李华
网站建设 2026/10/27 2:07:23

libnids源码解析:IP分片与TCP流重组的底层实现原理

简介&#xff1a;本资源是一份深度注释版的libnids-1.20源码包&#xff0c;面向网络安全开发者、协议分析学习者及NIDS系统定制人员&#xff0c;聚焦解决原始源码可读性低、TCP/IP重组逻辑难理解等核心痛点。包内共65个文件&#xff0c;涵盖15个C源文件&#xff08;如tcp.c、li…

作者头像 李华
网站建设 2026/10/27 2:05:37

800V高压快充全面爆发:比亚迪1000V/10C、小鹏800V、理想5C、小米5.2C,谁的技术更强?从SiC功率器件到兆瓦闪充深度解析

800V高压快充全面爆发&#xff1a;比亚迪1000V/10C、小鹏800V、理想5C、小米5.2C&#xff0c;谁的技术更强&#xff1f;从SiC功率器件到兆瓦闪充深度解析 新能源汽车的竞争&#xff0c;正在从续航里程转向补能速度。 过去消费者最关心的是一辆电动车能跑多少公里&#xff0c;现…

作者头像 李华
网站建设 2026/10/27 2:05:32

AI产业周度动态:从“更强模型”走向“可执行、可治理、可计价”的系统竞争 (2026年10月19日—10月25日)

目录 一、开源模型的竞争&#xff0c;正在向推理基础设施下沉 二、Agent 进入执行层&#xff0c;但“能做事”带来新的边界问题 三、安全攻防进入双向加速&#xff1a;AI 既是研究员&#xff0c;也是攻击面 四、资本继续追逐算力&#xff0c;但市场开始要求“算力能产生什么”…

作者头像 李华
网站建设 2026/10/27 2:05:18

Go零基础实战第24期:综合项目实战(四)用版本号处理并发更新,避免旧请求覆盖新状态

文章目录 引言:没有数据竞争,为什么还会改错? 一、本篇学习目标与准备 二、锁和版本号,分别解决什么问题? 1. Mutex 保护正在发生的内存操作 2. 版本号判断读取之后有没有发生修改 3. 两个页面的请求会怎样变化? 三、先把版本规则约定清楚 1. 什么时候增加版本? 2. 旧版…

作者头像 李华