
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 IDcom.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 有哪些核心 APIStoreKit 2 从 iOS 15 开始提供全部是 async/await 写法交易自带 Apple 的签名校验不用再自己解析收据。API作用Product.products(for:)按产品 ID 向 App Store 取商品拿到名称、描述和本地化价格product.purchase()弹出系统购买确认框返回成功、用户取消或待批准VerificationResult包着交易.verified是通过签名校验的.unverified没通过transaction.finish()告诉 App Store 已经发货。不调用的话下次启动还会收到这笔交易Transaction.updatesApp 运行期间收到的新交易例如家长批准、在另一台设备上买的、续订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 签名校验的交易直接抛错funccheckVerifiedT(_result:VerificationResultT)throws-T{switchresult{case.verified(letvalue):returnvaluecase.unverified(_,leterror):throwerror}}MainActorfinalclassStoreManager:ObservableObject{// 必须和 App Store Connect 里的产品 ID 一字不差staticletcoinsIDcom.example.myapp.coins100// 消耗型staticletproIDcom.example.myapp.pro// 非消耗型staticletvipIDcom.example.myapp.vip.monthly// 自动续期订阅staticletallIDs:SetString[coinsID,proID,vipID]Publishedprivate(set)varproducts:[Product][]Publishedprivate(set)varpurchasedIDs:SetString[]Publishedprivate(set)varcoins:IntUserDefaults.standard.integer(forKey:coins)Publishedvarmessage:String?privatevarupdatesTask:TaskVoid,Never?varisPro:Bool{purchasedIDs.contains(Self.proID)}varisVIP:Bool{purchasedIDs.contains(Self.vipID)}init(){// 一创建就开始监听接住上次没 finish 的交易和 App 外发生的交易updatesTaskTask{[weakself]inforawaitresultinTransaction.updates{awaitself?.handle(result)}}}/// 取商品。产品 ID 不存在不会报错只是不出现在结果里funcloadProducts()async{do{letlisttryawaitProduct.products(for:Self.allIDs)productslist.sorted{$0.price$1.price}letmissingSelf.allIDs.subtracting(list.map(\.id))if!missing.isEmpty{print(没取到的商品:\(missing.sorted()))}}catch{message加载商品失败\(error.localizedDescription)}}/// 发起购买funcpurchase(_product:Product)async{do{letresulttryawaitproduct.purchase()switchresult{case.success(letverification):lettransactiontrycheckVerified(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:SetString[]forawaitresultinTransaction.currentEntitlements{guardlettransactiontry?checkVerified(result)else{continue}iftransaction.revocationDate!nil{continue}ifletexpirationtransaction.expirationDate,expirationDate(){continue}ids.insert(transaction.productID)}purchasedIDsids}privatefunchandle(_result:VerificationResultTransaction)async{guardlettransactiontry?checkVerified(result)else{print(收到一笔未通过校验的交易已忽略)return}awaitdeliver(transaction)awaittransaction.finish()}/// 发货消耗型加金币其他类型刷新权益privatefuncdeliver(_transaction:Transaction)async{iftransaction.productType.consumable{guardtransaction.revocationDatenilelse{return}coins100UserDefaults.standard.set(coins,forKey:coins)}else{awaitrefreshEntitlements()}}}几个容易写错的地方。先发货再 finish。顺序反过来的话App 在两步之间被杀掉用户付了钱却没到账交易也不会再送来。消耗型的余额要自己存。示例存在UserDefaults只是为了演示正式 App 应该把交易信息发给自己的服务器记账否则用户删 App 重装后金币就没了。.pending不是失败。开了「询问后购买」的家庭成员、需要额外验证的付款都会走到这里批准后交易从Transaction.updates送来已经被handle接住。非续期订阅没有expirationDate。它会一直留在currentEntitlements里到期时间要按购买日期自己算。部署目标 iOS 17 以上时用 Observable把类声明改成下面这样删掉所有Publishedimport Foundation后加上import Observation其余代码不变。MainActorObservablefinalclassStoreManager{// 属性前去掉 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:{ifletmessagestore.message{Text(message)}}}.task{awaitstore.loadProducts()awaitstore.refreshEntitlements()}}}再把 App 入口文件例如MyAppApp.swift改成下面这样让StoreManager随 App 启动创建整个运行期间只有一个。importSwiftUImainstructMyAppApp:App{StateObjectprivatevarstoreStoreManager()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 内容。邮箱支持加号子地址的可以用yournamesandbox1example.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