iOS 内购用 StoreKit 2 接入分四步:签付费 App 协议并填好银行税务,在 App Store Connect 创建商品,用 Product.products(for:)、purchase() 和 Transaction.updates 写代码,再在 iPhone 上用沙盒账号测试。
iOS 内购用 StoreKit 2 接入,分四步:签好付费 App 协议并填完银行和税务信息,在 App Store Connect 创建商品,用
Product.products(for:) 取商品、
purchase() 购买、
Transaction.updates 监听交易,最后把 Debug 包装到 iPhone,用沙盒账号买一次。StoreKit 2 要求 iOS 15 及以上,沙盒测试不扣钱。下文的 Apple 规则截至 2026 年 10 月,都附了官方链接。
开始前准备好这几样东西。
| 步骤 | 在哪里做 | 谁来做 |
|---|---|---|
| 1. 签付费 App 协议,填银行和税务信息 | App Store Connect 的「商务」 | 账户持有人(银行信息也可由管理或财务角色填写) |
| 2. 确认 App ID 是精确 ID,生成描述文件 | developer.apple.com | 开发者 |
| 3. 创建商品 | App Store Connect 的 App 页面 | 开发者、App 管理等角色 |
| 4. 写 StoreKit 2 代码 | VS Code | 开发者 |
| 5. 创建沙盒测试员 | App Store Connect 的「用户和访问」 | 开发者及以上角色 |
| 6. 真机购买测试 | iPhone | 开发者 |
Apple 的 [沙盒测试说明]把这一条列为测试的前提,账户持有人必须签署付费 App 协议(Paid Apps Agreement)。免费 App 的协议是默认生效的,加内购必须另签这一份。
协议状态以[查看协议状态]为准。显示「待提供用户信息」(Pending User Info)说明签了但资料没填完,协议还没生效。显示「处理中」(Processing)是在等 Apple 审核。变成「有效」(Active)才算生效。协议没生效时,代码里取商品经常拿到空数组,这是商品取不到最常见的原因。
Apple 的[注册 App ID]写明,精确 App ID 默认就开启了 In-App Purchase,在 Capabilities 列表里这一项是灰色勾选状态,不用手动开。内购也不需要在 entitlements 文件里加任何键。
要注意的只有一点,App ID 必须是精确的 Bundle ID,例如
com.example.myapp。通配 App ID(
com.example.*)不支持内购。如果你的 Development 描述文件是通配的,在 Identifiers 页给这个 Bundle ID 注册一个精确 App ID,再在 Profiles 页给它生成 Development 描述文件,放进工程的
keychain 目录,删掉旧的通配文件。完整步骤见[在 Windows 上申请 iOS 开发证书、描述文件和 p12]和[证书与签名]。
在 VS Code 里双击
.mobileprovision 可以查看它绑定的 App ID,确认里面写的是完整的 Bundle ID,不带星号。
| 类型 | 英文名 | 特点 | 例子 | 能否恢复购买 |
|---|---|---|---|---|
| 消耗型 | Consumable | 用一次就没了,可以重复买 | 游戏金币、一次性加速 | 不能,要你自己记账 |
| 非消耗型 | Non-Consumable | 买一次一直有效 | 解锁专业版、去广告 | 能 |
| 自动续期订阅 | Auto-Renewable Subscription | 按周期自动扣费,用户取消前一直续 | 会员月卡、年卡 | 能 |
| 非续期订阅 | Non-Renewing Subscription | 固定时长,不会自动续 | 一年期资料库 | 能,但到期时间要你自己算 |
定义以 Apple 的 [In-App Purchase 类型]为准。大多数 App 用前三种就够了,本文的代码也按前三种写。
产品 ID 建议用 Bundle ID 加后缀,例如
com.example.myapp.coins100、
com.example.myapp.pro。它是代码里用来取商品的唯一依据,一旦创建就当它不能改。
沙盒测试最少要填好参考名称、产品 ID、本地化名称和价格。改完商品信息后,最长要等 1 小时才会在沙盒环境里出现,见[创建消耗型或非消耗型项目]。
订阅必须放在订阅群组里,同一群组里的订阅用户同时只能拥有一个,适合月卡、年卡这种互相升降级的档位。
com.example.myapp.vip.monthly。步骤以 Apple 的[提供自动续期订阅]为准。
StoreKit 2 从 iOS 15 开始提供,全部是 async/await 写法,交易自带 Apple 的签名校验,不用再自己解析收据。
| API | 作用 |
|---|---|
Product.products(for:) |
按产品 ID 向 App Store 取商品,拿到名称、描述和本地化价格 |
product.purchase() |
弹出系统购买确认框,返回成功、用户取消或待批准 |
VerificationResult |
包着交易,
.verified 是通过签名校验的,
.unverified 没通过 |
transaction.finish() |
告诉 App Store 已经发货。不调用的话下次启动还会收到这笔交易 |
Transaction.updates |
App 运行期间收到的新交易,例如家长批准、在另一台设备上买的、续订 |
Transaction.currentEntitlements |
用户当前拥有的权益,用来判断解锁了什么 |
AppStore.sync() |
恢复购买,会弹出 Apple 账户验证,只能在用户点按钮时调用 |
Transaction.updates 要在 App 一启动就开始监听。Apple 的 [updates 文档]说明,上次没 finish 的交易会在启动后立刻通过它送来一次,没监听就会漏掉。
currentEntitlements 只包含非消耗型、仍在订阅期或宽限期的自动续期订阅、以及非续期订阅,不包含消耗型,已退款或被撤销的也不在里面,见 [currentEntitlements 文档]。所以消耗型要在购买成功时立刻发货并记账。
下面的代码适合部署目标 iOS 15 及以上,用
ObservableObject 加
@MainActor,在 Swift 6 严格并发模式下也能编译。部署目标是 iOS 17 及以上的,可以改用
@Observable,写法见本节末尾。
在 App 源码文件夹里新建
StoreManager.swift,把三个产品 ID 换成你在 App Store Connect 里填的。
1import Foundation 2import StoreKit 3 4/// 解开 VerificationResult,没通过 Apple 签名校验的交易直接抛错 5func checkVerified<T>(_ result: VerificationResult<T>) throws -> T { 6 switch result { 7 case .verified(let value): 8 return value 9 case .unverified(_, let error): 10 throw error 11 } 12} 13 14@MainActor 15final class StoreManager: ObservableObject { 16 // 必须和 App Store Connect 里的产品 ID 一字不差 17 static let coinsID = "com.example.myapp.coins100" // 消耗型 18 static let proID = "com.example.myapp.pro" // 非消耗型 19 static let vipID = "com.example.myapp.vip.monthly" // 自动续期订阅 20 static let allIDs: Set<String> = [coinsID, proID, vipID] 21 22 @Published private(set) var products: [Product] = [] 23 @Published private(set) var purchasedIDs: Set<String> = [] 24 @Published private(set) var coins: Int = UserDefaults.standard.integer(forKey: "coins") 25 @Published var message: String? 26 27 private var updatesTask: Task<Void, Never>? 28 29 var isPro: Bool { purchasedIDs.contains(Self.proID) } 30 var isVIP: Bool { purchasedIDs.contains(Self.vipID) } 31 32 init() { 33 // 一创建就开始监听,接住上次没 finish 的交易和 App 外发生的交易 34 updatesTask = Task { [weak self] in 35 for await result in Transaction.updates { 36 await self?.handle(result) 37 } 38 } 39 } 40 41 /// 取商品。产品 ID 不存在不会报错,只是不出现在结果里 42 func loadProducts() async { 43 do { 44 let list = try await Product.products(for: Self.allIDs) 45 products = list.sorted { $0.price < $1.price } 46 let missing = Self.allIDs.subtracting(list.map(\.id)) 47 if !missing.isEmpty { 48 print("没取到的商品: \(missing.sorted())") 49 } 50 } catch { 51 message = "加载商品失败:\(error.localizedDescription)" 52 } 53 } 54 55 /// 发起购买 56 func purchase(_ product: Product) async { 57 do { 58 let result = try await product.purchase() 59 switch result { 60 case .success(let verification): 61 let transaction = try checkVerified(verification) 62 await deliver(transaction) 63 await transaction.finish() 64 message = "购买成功:\(product.displayName)" 65 case .userCancelled: 66 break 67 case .pending: 68 message = "等待批准(例如家长批准),批准后会自动到账" 69 @unknown default: 70 break 71 } 72 } catch { 73 message = "购买失败:\(error.localizedDescription)" 74 } 75 } 76 77 /// 恢复购买,只在用户点按钮时调用 78 func restore() async { 79 do { 80 try await AppStore.sync() 81 await refreshEntitlements() 82 message = "已同步购买记录" 83 } catch { 84 message = "恢复失败:\(error.localizedDescription)" 85 } 86 } 87 88 /// 重新计算用户当前拥有的非消耗型和订阅 89 func refreshEntitlements() async { 90 var ids: Set<String> = [] 91 for await result in Transaction.currentEntitlements { 92 guard let transaction = try? checkVerified(result) else { continue } 93 if transaction.revocationDate != nil { continue } 94 if let expiration = transaction.expirationDate, expiration < Date() { continue } 95 ids.insert(transaction.productID) 96 } 97 purchasedIDs = ids 98 } 99100 private func handle(_ result: VerificationResult<Transaction>) async {101 guard let transaction = try? checkVerified(result) else {102 print("收到一笔未通过校验的交易,已忽略")103 return104 }105 await deliver(transaction)106 await transaction.finish()107 }108109 /// 发货:消耗型加金币,其他类型刷新权益110 private func deliver(_ transaction: Transaction) async {111 if transaction.productType == .consumable {112 guard transaction.revocationDate == nil else { return }113 coins += 100114 UserDefaults.standard.set(coins, forKey: "coins")115 } else {116 await refreshEntitlements()117 }118 }119}
几个容易写错的地方。
UserDefaults 只是为了演示,正式 App 应该把交易信息发给自己的服务器记账,否则用户删 App 重装后金币就没了。.pending 不是失败。开了「询问后购买」的家庭成员、需要额外验证的付款都会走到这里,批准后交易从
Transaction.updates 送来,已经被
handle 接住。expirationDate。它会一直留在
currentEntitlements 里,到期时间要按购买日期自己算。把类声明改成下面这样,删掉所有
@Published,
import Foundation 后加上
import Observation,其余代码不变。
1@MainActor2@Observable3final class StoreManager {4 // 属性前去掉 @Published,其余不变5}
用的地方把
@StateObject 换成
@State,
.environmentObject(store) 换成
.environment(store),
@EnvironmentObject 换成
@Environment(StoreManager.self)。
新建
StoreView.swift,列出商品、价格按钮、已拥有状态和恢复购买按钮。
1import SwiftUI 2import StoreKit 3 4struct StoreView: View { 5 @EnvironmentObject private var store: StoreManager 6 7 var body: some View { 8 List { 9 Section("商品") {10 if store.products.isEmpty {11 Text("正在加载商品…").foregroundStyle(.secondary)12 }13 ForEach(store.products) { product in14 HStack {15 VStack(alignment: .leading, spacing: 4) {16 Text(product.displayName).font(.headline)17 Text(product.description)18 .font(.caption)19 .foregroundStyle(.secondary)20 }21 Spacer()22 if store.purchasedIDs.contains(product.id) {23 Text("已拥有").foregroundStyle(Color.green)24 } else {25 Button(product.displayPrice) {26 Task { await store.purchase(product) }27 }28 .buttonStyle(.borderedProminent)29 }30 }31 }32 }3334 Section("我的权益") {35 Text("金币:\(store.coins)")36 Text(store.isPro ? "专业版:已解锁" : "专业版:未解锁")37 Text(store.isVIP ? "会员:订阅中" : "会员:未订阅")38 }3940 Section {41 Button("恢复购买") {42 Task { await store.restore() }43 }44 } footer: {45 if let message = store.message {46 Text(message)47 }48 }49 }50 .task {51 await store.loadProducts()52 await store.refreshEntitlements()53 }54 }55}
再把 App 入口文件(例如
MyAppApp.swift)改成下面这样,让
StoreManager 随 App 启动创建,整个运行期间只有一个。
1import SwiftUI 2 3@main 4struct MyAppApp: App { 5 @StateObject private var store = StoreManager() 6 7 var body: some Scene { 8 WindowGroup { 9 StoreView()10 .environmentObject(store)11 }12 }13}
价格按钮用
product.displayPrice,它已经按用户所在地区的货币格式化好了,不要自己拼「¥」和数字。App Store 审核指南 [3.1.1] 要求可恢复的购买项目提供恢复机制,「恢复购买」按钮要保留。
部署目标 iOS 17 及以上的 SwiftUI 工程,Apple 推荐用环境值
@Environment(\.purchase) 发起购买,它返回的结果和
product.purchase() 一样,处理代码不用改。Xcode 的
.storekit 本地配置文件测试只能在 Xcode 里用,本文全程用沙盒环境测试。
沙盒测试员是专门用来测内购的虚拟 Apple 账户,买东西不扣钱。
邮箱不能是已经注册过 Apple 账户的地址,也不能用来买过 App Store 内容。
邮箱支持加号子地址的,可以用
yourname+sandbox1@example.com 这种写法一个邮箱建多个测试员。创建后名字、邮箱、密码都不能改,只能改国家或地区。沙盒账户只能测试创建它的那个开发者账号下的 App。步骤见 Apple 的[创建沙盒 Apple 账户]。
用开发描述文件签名的 App(也就是按 F5 装上去的 Debug 包)和 TestFlight 安装的 App 都走沙盒环境。在 KXBuild 视图的「构建配置」节点选 Debug,运行
KXBuild: 构建并运行,装到手机上。调试方法见 [Windows 上真机调试 iOS App]。
App 打开后,商品列表应该显示三个商品和价格。列表一直是空的,在「输出 → KXBuild Console」里找「没取到的商品」这一行,对照本文最后的排查表处理。
不用退出手机上自己的 Apple 账户。
[Environment: Sandbox],说明连的是沙盒环境。没有这行字就是连到了正式环境,不要继续付款。按 Apple 当前的文档,第一次在开发签名的 App 里尝试购买之后,沙盒账户会出现在「设置 → 开发者」里,可以在那里登录、退出或切换测试员。较早的 iOS 版本把它放在「设置 → App Store」页面底部的「沙盒账户」。两处都找一下即可。
在「设置 → 开发者」里点沙盒账户,选「管理」,能改订阅续期速度、清除购买记录。清除购买记录后退出沙盒账户再登录,才会清掉手机上的缓存。
TestFlight 安装的 App 总是走沙盒环境,默认用你的正式 Apple 账户在沙盒里购买,同样不扣钱。
要用沙盒测试员和沙盒设置,要先在「设置 → 你的名字 → 媒体与购买项目」里退出,再到「设置 → 开发者」登录沙盒账户。退出媒体与购买项目可能影响你访问已购内容,最好用专门的测试机。TestFlight 的分发方法见 [TestFlight 内测分发教程]。
沙盒里的订阅会加速续期,默认档位「每 5 分钟续期」下,1 个月的订阅 5 分钟续一次。自动续期最多 12 次,第 13 次续期时自动关闭。速度可以在 App Store Connect 的沙盒测试员设置里改,也可以在手机上沙盒账户的「管理」里改,见 [管理沙盒 Apple 账户设置]。
| 订阅时长 | 每 3 分钟档 | 每 5 分钟档(默认) | 每 30 分钟档 | 每小时档 |
|---|---|---|---|---|
| 1 周 | 3 分钟 | 3 分钟 | 10 分钟 | 15 分钟 |
| 1 个月 | 3 分钟 | 5 分钟 | 30 分钟 | 1 小时 |
| 2 个月 | 6 分钟 | 10 分钟 | 1 小时 | 2 小时 |
| 3 个月 | 9 分钟 | 15 分钟 | 1 小时 30 分钟 | 3 小时 |
| 6 个月 | 18 分钟 | 30 分钟 | 3 小时 | 6 小时 |
| 1 年 | 36 分钟 | 1 小时 | 6 小时 | 12 小时 |
测续期时,买下月卡后让 App 留在前台,几分钟后
Transaction.updates 会收到续期交易。在
handle 里打一个断点或加一行
print(transaction.productID, transaction.expirationDate ?? ""),就能看到新的到期时间。12 次续完后订阅过期,「会员」显示回「未订阅」,可以借此测试过期后的界面。
| 现象 | 原因 | 处理 |
|---|---|---|
| 所有商品都取不到 | 付费 App 协议没签,或银行、税务信息没填完,状态不是「有效」 | 到「商务 → 协议」补齐,等状态变成有效 |
| 所有商品都取不到 | 构建的 Bundle ID 和 App Store Connect 里 App 记录的不一致 | 在[工程设置编辑器]的 General 页核对 Bundle Identifier |
| 所有商品都取不到 | 用的是通配 App ID 的描述文件 | 注册精确 App ID,重新生成 Development 描述文件 |
| 个别商品取不到 | 代码里的产品 ID 有拼写、大小写或空格差异 | 从 App Store Connect 复制产品 ID 粘贴进代码 |
| 个别商品取不到 | 商品缺本地化名称或价格 | 在商品详情页补齐 |
| 刚建或刚改的商品取不到 | 沙盒同步有延迟,最长约 1 小时 | 等一会儿再试,重启 App |
| 某个地区的测试员取不到 | 商品没在测试员所在的国家或地区销售 | 改商品的销售地区,或在沙盒测试员设置里改地区后重新登录 |
| 订阅取不到 | 订阅群组或订阅缺显示名称 | 给群组和订阅都添加本地化信息 |
购买框里没有
[Environment: Sandbox] |
用的是正式环境,例如装的是 App Store 版 | 删掉后用 KXBuild 重新装 Debug 包 |
| 点购买直接失败 | 手机开了屏幕使用时间的内容限制,禁止 App 内购买 | 在「设置 → 屏幕使用时间」里放开 App 内购买 |
| 登录沙盒账号失败或提示账号无效 | 邮箱已注册过 Apple 账户,或测试员不是在这个开发者账号下创建的 | 用全新邮箱在本账号的「用户和访问 → 沙盒」里重新创建 |
按表查完仍然取不到商品,先回到「商务 → 协议」确认付费 App 协议是「有效」状态,这是 iOS 内购接入时最常见的卡点。协议、商品和代码都确认无误后,再换一个新建的沙盒测试员重试。
StoreKit 2 的
Product、
Transaction、
AppStore.sync() 都从 iOS 15 开始提供。部署目标低于 iOS 15 的 App 只能用旧的 StoreKit 接口,或者把内购功能限制在 iOS 15 及以上的设备上。
不用提前登录,在开发签名的 App 里第一次点购买时,系统会要求登录,输入沙盒测试员账号即可。按 Apple 当前文档,之后在「设置 → 开发者」里管理沙盒账户,较早的系统在「设置 → App Store」页面底部。
最常见的是付费 App 协议没有生效,其次是产品 ID 和 App Store Connect 不一致、Bundle ID 不对、商品缺名称或价格。刚创建的商品最长要等 1 小时才会在沙盒里出现。
默认档位下 1 个月的订阅 5 分钟续一次,1 年的 1 小时续一次,最多自动续 12 次后停止。续期速度可以在 App Store Connect 或手机上的沙盒账户设置里调整。
App Store 会认为你还没发货,这笔交易在下次启动时会通过
Transaction.updates 再送来,未完成的交易也会一直留在
Transaction.unfinished 里。发货完成后一定要调用
finish()。
来自 “ ITPUB博客 ” ,链接:https://blog.itpub.net/70044672/viewspace-3187657/ ,如需转载,请注明出处,否则将追究法律责任。