跳到主要内容
教程目录

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 月,都附了官方链接。

开始前准备好这几样东西。

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 的协议是默认生效的,加内购必须另签这一份。

  1. 用账户持有人的账号登录 App Store Connect,点顶部的「商务」(Business)。
  2. 在「协议」标签页找到 Paid Apps 一行,点「查看并同意条款」。步骤见 Apple 的签署和更新协议。
  3. 在同一页添加银行账户。必须先签协议才能添加,信息要和银行开户资料完全一致,见输入银行信息。
  4. 填写税务表格。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 用前三种就够了,本文的代码也按前三种写。

创建消耗型和非消耗型

  1. 在 App Store Connect 的「App」里选中你的 App,左侧栏「营利」(Monetization)下点「App 内购买项目」(In-App Purchases)。
  2. 点「+」,类型选「消耗型」或「非消耗型」。
  3. 填参考名称(只给你自己看)和产品 ID,点「创建」。
  4. 在详情页填价格、至少一种语言的显示名称和描述,以及审核信息(截图和说明)。

产品 ID 建议用 Bundle ID 加后缀,例如 com.example.myapp.coins100、com.example.myapp.pro。它是代码里用来取商品的唯一依据,一旦创建就当它不能改。

沙盒测试最少要填好参考名称、产品 ID、本地化名称和价格。改完商品信息后,最长要等 1 小时才会在沙盒环境里出现,见创建消耗型或非消耗型项目。

创建自动续期订阅

订阅必须放在订阅群组里,同一群组里的订阅用户同时只能拥有一个,适合月卡、年卡这种互相升降级的档位。

  1. 左侧栏「营利」下点「订阅」(Subscriptions),点「+」新建订阅群组,填参考名称。
  2. 点进群组,点「创建」,填订阅的参考名称和产品 ID,例如 com.example.myapp.vip.monthly。
  3. 在订阅详情页选择时长(1 周、1 个月、2 个月、3 个月、6 个月、1 年),设置价格和销售地区,添加本地化的显示名称和描述,填审核信息,保存。
  4. 订阅群组本身也要添加至少一种语言的显示名称。

步骤以 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 账户,买东西不扣钱。

  1. 在 App Store Connect 点顶部的「用户和访问」,再点顶部导航的「沙盒」(Sandbox),点「+」。第一次添加时点「创建测试账户」。
  2. 填名字、邮箱、密码,选择 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 账户。

  1. 在 App 里点一个价格按钮,系统弹出购买框并要求登录,输入沙盒测试员的邮箱和密码。
  2. 登录成功后购买框里会出现 [Environment: Sandbox],说明连的是沙盒环境。没有这行字就是连到了正式环境,不要继续付款。
  3. 确认购买,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()。