iOS 内购怎么接?StoreKit 2 接入应用内购买与沙盒测试
最后更新
iOS 内购用 StoreKit 2 接入,分四步:签好付费 App 协议并填完银行和税务信息,在 App Store Connect 创建商品,用 Product.products(for:) 取商品、purchase() 购买、Transaction.updates 监听交易,最后把 Debug 包装到 iPhone,用沙盒账号买一次。StoreKit 2 要求 iOS 15 及以上,沙盒测试不扣钱。下文的 Apple 规则截至 2026 年 10 月,都附了官方链接。
开始前准备好这几样东西。
- 已加入 Apple Developer Program 的账号,App Store Connect 里已经建好这个 App 的记录。还没建的先看在 Windows 上上架 App Store。
- 一个能用 KXBuild 装到 iPhone 上运行的 SwiftUI 工程,部署目标 iOS 15 或更高。还没有的按 SwiftUI 从新建工程到装上 iPhone做一个。
- 一部打开了开发者模式的 iPhone,开关位置见开发者模式怎么打开。
iOS 内购接入要走哪几步?
| 步骤 | 在哪里做 | 谁来做 |
|---|---|---|
| 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 | 开发者 |
为什么要先签付费 App 协议?
Apple 的 沙盒测试说明把这一条列为测试的前提,账户持有人必须签署付费 App 协议(Paid Apps Agreement)。免费 App 的协议是默认生效的,加内购必须另签这一份。
- 用账户持有人的账号登录 App Store Connect,点顶部的「商务」(Business)。
- 在「协议」标签页找到 Paid Apps 一行,点「查看并同意条款」。步骤见 Apple 的签署和更新协议。
- 在同一页添加银行账户。必须先签协议才能添加,信息要和银行开户资料完全一致,见输入银行信息。
- 填写税务表格。Apple 要求提交完所需的税务表格后,银行信息才会被处理。
协议状态以查看协议状态为准。显示「待提供用户信息」(Pending User Info)说明签了但资料没填完,协议还没生效。显示「处理中」(Processing)是在等 Apple 审核。变成「有效」(Active)才算生效。协议没生效时,代码里取商品经常拿到空数组,这是商品取不到最常见的原因。
内购对 App ID 和描述文件有什么要求?
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,不带星号。
怎么在 App Store Connect 创建内购商品?
三种常用商品怎么选
| 类型 | 英文名 | 特点 | 例子 | 能否恢复购买 |
|---|---|---|---|---|
| 消耗型 | Consumable | 用一次就没了,可以重复买 | 游戏金币、一次性加速 | 不能,要你自己记账 |
| 非消耗型 | Non-Consumable | 买一次永久有效 | 解锁专业版、去广告 | 能 |
| 自动续期订阅 | Auto-Renewable Subscription | 按周期自动扣费,用户取消前一直续 | 会员月卡、年卡 | 能 |
| 非续期订阅 | Non-Renewing Subscription | 固定时长,不会自动续 | 一年期资料库 | 能,但到期时间要你自己算 |
定义以 Apple 的 In-App Purchase 类型为准。大多数 App 用前三种就够了,本文的代码也按前三种写。
创建消耗型和非消耗型
- 在 App Store Connect 的「App」里选中你的 App,左侧栏「营利」(Monetization)下点「App 内购买项目」(In-App Purchases)。
- 点「+」,类型选「消耗型」或「非消耗型」。
- 填参考名称(只给你自己看)和产品 ID,点「创建」。
- 在详情页填价格、至少一种语言的显示名称和描述,以及审核信息(截图和说明)。
产品 ID 建议用 Bundle ID 加后缀,例如 com.example.myapp.coins100、com.example.myapp.pro。它是代码里用来取商品的唯一依据,一旦创建就当它不能改。
沙盒测试最少要填好参考名称、产品 ID、本地化名称和价格。改完商品信息后,最长要等 1 小时才会在沙盒环境里出现,见创建消耗型或非消耗型项目。
创建自动续期订阅
订阅必须放在订阅群组里,同一群组里的订阅用户同时只能拥有一个,适合月卡、年卡这种互相升降级的档位。
- 左侧栏「营利」下点「订阅」(Subscriptions),点「+」新建订阅群组,填参考名称。
- 点进群组,点「创建」,填订阅的参考名称和产品 ID,例如
com.example.myapp.vip.monthly。 - 在订阅详情页选择时长(1 周、1 个月、2 个月、3 个月、6 个月、1 年),设置价格和销售地区,添加本地化的显示名称和描述,填审核信息,保存。
- 订阅群组本身也要添加至少一种语言的显示名称。
步骤以 Apple 的提供自动续期订阅为准。
StoreKit 2 有哪些核心 API?
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 文档。所以消耗型要在购买成功时立刻发货并记账。
写一个完整的 StoreManager
下面的代码适合部署目标 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 }
99
100 private func handle(_ result: VerificationResult<Transaction>) async {
101 guard let transaction = try? checkVerified(result) else {
102 print("收到一笔未通过校验的交易,已忽略")
103 return
104 }
105 await deliver(transaction)
106 await transaction.finish()
107 }
108
109 /// 发货:消耗型加金币,其他类型刷新权益
110 private func deliver(_ transaction: Transaction) async {
111 if transaction.productType == .consumable {
112 guard transaction.revocationDate == nil else { return }
113 coins += 100
114 UserDefaults.standard.set(coins, forKey: "coins")
115 } else {
116 await refreshEntitlements()
117 }
118 }
119}
几个容易写错的地方。
- 先发货,再 finish。顺序反过来的话,App 在两步之间被杀掉,用户付了钱却没到账,交易也不会再送来。
- 消耗型的余额要自己存。示例存在
UserDefaults只是为了演示,正式 App 应该把交易信息发给自己的服务器记账,否则用户删 App 重装后金币就没了。 .pending不是失败。开了「询问后购买」的家庭成员、需要额外验证的付款都会走到这里,批准后交易从Transaction.updates送来,已经被handle接住。- 非续期订阅没有
expirationDate。它会一直留在currentEntitlements里,到期时间要按购买日期自己算。
部署目标 iOS 17 以上时用 @Observable
把类声明改成下面这样,删掉所有 @Published,import Foundation 后加上 import Observation,其余代码不变。
1@MainActor
2@Observable
3final class StoreManager {
4 // 属性前去掉 @Published,其余不变
5}
用的地方把 @StateObject 换成 @State,.environmentObject(store) 换成 .environment(store),@EnvironmentObject 换成 @Environment(StoreManager.self)。
SwiftUI 购买界面
新建 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 in
14 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 }
33
34 Section("我的权益") {
35 Text("金币:\(store.coins)")
36 Text(store.isPro ? "专业版:已解锁" : "专业版:未解锁")
37 Text(store.isVIP ? "会员:订阅中" : "会员:未订阅")
38 }
39
40 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 账户,买东西不扣钱。
- 在 App Store Connect 点顶部的「用户和访问」,再点顶部导航的「沙盒」(Sandbox),点「+」。第一次添加时点「创建测试账户」。
- 填名字、邮箱、密码,选择 App Store 国家或地区,点「创建」。
邮箱不能是已经注册过 Apple 账户的地址,也不能用来买过 App Store 内容。
邮箱支持加号子地址的,可以用 yourname+sandbox1@example.com 这种写法一个邮箱建多个测试员。创建后名字、邮箱、密码都不能改,只能改国家或地区。沙盒账户只能测试创建它的那个开发者账号下的 App。步骤见 Apple 的创建沙盒 Apple 账户。
怎么在 iPhone 上做沙盒测试?
装 Debug 包
用开发描述文件签名的 App(也就是按 F5 装上去的 Debug 包)和 TestFlight 安装的 App 都走沙盒环境。在 KXBuild 视图的「构建配置」节点选 Debug,运行 KXBuild: 构建并运行,装到手机上。调试方法见 Windows 上真机调试 iOS App。
App 打开后,商品列表应该显示三个商品和价格。列表一直是空的,在「输出 → KXBuild Console」里找「没取到的商品」这一行,对照本文最后的排查表处理。
登录沙盒账号
不用退出手机上自己的 Apple 账户。
- 在 App 里点一个价格按钮,系统弹出购买框并要求登录,输入沙盒测试员的邮箱和密码。
- 登录成功后购买框里会出现
[Environment: Sandbox],说明连的是沙盒环境。没有这行字就是连到了正式环境,不要继续付款。 - 确认购买,App 里显示「购买成功」,金币增加或商品变成「已拥有」。
按 Apple 当前的文档,第一次在开发签名的 App 里尝试购买之后,沙盒账户会出现在「设置 → 开发者」里,可以在那里登录、退出或切换测试员。较早的 iOS 版本把它放在「设置 → App Store」页面最底部的「沙盒账户」。两处都找一下即可。
在「设置 → 开发者」里点沙盒账户,选「管理」,能改订阅续期速度、清除购买记录。清除购买记录后退出沙盒账户再登录,才会清掉手机上的缓存。
测试 TestFlight 包
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 上架前要检查什么?
- 第一个内购项目或订阅必须和一个新的 App 版本一起提交审核。在 App 版本页的「App 内购买项目和订阅」部分勾选它们,再提交,见 Apple 的提交 App 内购买项目。
- 每个商品都要有审核截图和说明,状态不完整的商品不能提交。
- 审核员用沙盒环境测试你的购买流程,商品取不到、点了没反应都会被退回。常见的被拒原因见 App 审核被拒的常见原因。
- 出 Release 包和上传的步骤见发布与上架。
商品取不到、买不了怎么查
| 现象 | 原因 | 处理 |
|---|---|---|
| 所有商品都取不到 | 付费 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 最低支持哪个 iOS 版本?
StoreKit 2 的 Product、Transaction、AppStore.sync() 都从 iOS 15 开始提供。部署目标低于 iOS 15 的 App 只能用旧的 StoreKit 接口,或者把内购功能限制在 iOS 15 及以上的设备上。
沙盒测试员在 iPhone 的哪里登录?
不用提前登录,在开发签名的 App 里第一次点购买时,系统会要求登录,输入沙盒测试员账号即可。按 Apple 当前文档,之后在「设置 → 开发者」里管理沙盒账户,较早的系统在「设置 → App Store」页面底部。
Product.products(for:) 为什么返回空数组?
最常见的是付费 App 协议没有生效,其次是产品 ID 和 App Store Connect 不一致、Bundle ID 不对、商品缺名称或价格。刚创建的商品最长要等 1 小时才会在沙盒里出现。
沙盒里的订阅多久续一次?
默认档位下 1 个月的订阅 5 分钟续一次,1 年的 1 小时续一次,最多自动续 12 次后停止。续期速度可以在 App Store Connect 或手机上的沙盒账户设置里调整。
没有调用 finish() 会怎样?
App Store 会认为你还没发货,这笔交易在下次启动时会通过 Transaction.updates 再送来,未完成的交易也会一直留在 Transaction.unfinished 里。发货完成后一定要调用 finish()。