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 月,都附了官方链接。
开始前准备好这几样东西。
- 已加入 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 里填的。
importFoundationimportStoreKit/// 解开 VerificationResult,没通过 Apple 签名校验的交易直接抛错funccheckVerified<T>(_result:VerificationResult<T>)throws->T{switchresult{case.verified(letvalue):returnvaluecase.unverified(_,leterror):throwerror}}@MainActorfinalclassStoreManager:ObservableObject{// 必须和 App Store Connect 里的产品 ID 一字不差staticletcoinsID="com.example.myapp.coins100"// 消耗型staticletproID="com.example.myapp.pro"// 非消耗型staticletvipID="com.example.myapp.vip.monthly"// 自动续期订阅staticletallIDs:Set<String>=[coinsID,proID,vipID]@Publishedprivate(set)varproducts:[Product]=[]@Publishedprivate(set)varpurchasedIDs:Set<String>=[]@Publishedprivate(set)varcoins:Int=UserDefaults.standard.integer(forKey:"coins")@Publishedvarmessage:String?privatevarupdatesTask:Task<Void,Never>?varisPro:Bool{purchasedIDs.contains(Self.proID)}varisVIP:Bool{purchasedIDs.contains(Self.vipID)}init(){// 一创建就开始监听,接住上次没 finish 的交易和 App 外发生的交易updatesTask=Task{[weakself]inforawaitresultinTransaction.updates{awaitself?.handle(result)}}}/// 取商品。产品 ID 不存在不会报错,只是不出现在结果里funcloadProducts()async{do{letlist=tryawaitProduct.products(for:Self.allIDs)products=list.sorted{$0.price<$1.price}letmissing=Self.allIDs.subtracting(list.map(\.id))if!missing.isEmpty{print("没取到的商品:\(missing.sorted())")}}catch{message="加载商品失败:\(error.localizedDescription)"}}/// 发起购买funcpurchase(_product:Product)async{do{letresult=tryawaitproduct.purchase()switchresult{case.success(letverification):lettransaction=trycheckVerified(verification)awaitdeliver(transaction)awaittransaction.finish()message="购买成功:\(product.displayName)"case.userCancelled:breakcase.pending:message="等待批准(例如家长批准),批准后会自动到账"@unknowndefault:break}}catch{message="购买失败:\(error.localizedDescription)"}}/// 恢复购买,只在用户点按钮时调用funcrestore()async{do{tryawaitAppStore.sync()awaitrefreshEntitlements()message="已同步购买记录"}catch{message="恢复失败:\(error.localizedDescription)"}}/// 重新计算用户当前拥有的非消耗型和订阅funcrefreshEntitlements()async{varids:Set<String>=[]forawaitresultinTransaction.currentEntitlements{guardlettransaction=try?checkVerified(result)else{continue}iftransaction.revocationDate!=nil{continue}ifletexpiration=transaction.expirationDate,expiration<Date(){continue}ids.insert(transaction.productID)}purchasedIDs=ids}privatefunchandle(_result:VerificationResult<Transaction>)async{guardlettransaction=try?checkVerified(result)else{print("收到一笔未通过校验的交易,已忽略")return}awaitdeliver(transaction)awaittransaction.finish()}/// 发货:消耗型加金币,其他类型刷新权益privatefuncdeliver(_transaction:Transaction)async{iftransaction.productType==.consumable{guardtransaction.revocationDate==nilelse{return}coins+=100UserDefaults.standard.set(coins,forKey:"coins")}else{awaitrefreshEntitlements()}}}几个容易写错的地方。
- 先发货,再 finish。顺序反过来的话,App 在两步之间被杀掉,用户付了钱却没到账,交易也不会再送来。
- 消耗型的余额要自己存。示例存在
UserDefaults只是为了演示,正式 App 应该把交易信息发给自己的服务器记账,否则用户删 App 重装后金币就没了。 .pending不是失败。开了「询问后购买」的家庭成员、需要额外验证的付款都会走到这里,批准后交易从Transaction.updates送来,已经被handle接住。- 非续期订阅没有
expirationDate。它会一直留在currentEntitlements里,到期时间要按购买日期自己算。
部署目标 iOS 17 以上时用 @Observable
把类声明改成下面这样,删掉所有@Published,import Foundation后加上import Observation,其余代码不变。
@MainActor@ObservablefinalclassStoreManager{// 属性前去掉 @Published,其余不变}用的地方把@StateObject换成@State,.environmentObject(store)换成.environment(store),@EnvironmentObject换成@Environment(StoreManager.self)。
SwiftUI 购买界面
新建StoreView.swift,列出商品、价格按钮、已拥有状态和恢复购买按钮。
importSwiftUIimportStoreKitstructStoreView:View{@EnvironmentObjectprivatevarstore:StoreManagervarbody:someView{List{Section("商品"){ifstore.products.isEmpty{Text("正在加载商品…").foregroundStyle(.secondary)}ForEach(store.products){productinHStack{VStack(alignment:.leading,spacing:4){Text(product.displayName).font(.headline)Text(product.description).font(.caption).foregroundStyle(.secondary)}Spacer()ifstore.purchasedIDs.contains(product.id){Text("已拥有").foregroundStyle(Color.green)}else{Button(product.displayPrice){Task{awaitstore.purchase(product)}}.buttonStyle(.borderedProminent)}}}}Section("我的权益"){Text("金币:\(store.coins)")Text(store.isPro?"专业版:已解锁":"专业版:未解锁")Text(store.isVIP?"会员:订阅中":"会员:未订阅")}Section{Button("恢复购买"){Task{awaitstore.restore()}}}footer:{ifletmessage=store.message{Text(message)}}}.task{awaitstore.loadProducts()awaitstore.refreshEntitlements()}}}再把 App 入口文件(例如MyAppApp.swift)改成下面这样,让StoreManager随 App 启动创建,整个运行期间只有一个。
importSwiftUI@mainstructMyAppApp:App{@StateObjectprivatevarstore=StoreManager()varbody:someScene{WindowGroup{StoreView().environmentObject(store)}}}价格按钮用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()。
更多信息可以查看官网文章,https://www.kxbuild.net/zh/tutorial/191