关于 axiom-in-app-purchases
面向 iOS 开发者的 StoreKit 2 参考技能,用于实现和测试应用内购买。它强调先创建 StoreKit 配置并验证购买流程,再编写正式代码。
主要功能
- 01StoreKit 2 购买模式
- 02优先测试的 .storekit 工作流
- 03消耗型、非消耗型及订阅
- 04交易验证与权益跟踪
- 05恢复购买与同步指南
适用场景
- 01构建消耗型或高级功能购买流程
- 02实现并跟踪自动续期订阅
- 03在本地测试购买与恢复场景
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ charleswiltgen/ axiom --skill axiom-in-app-purchases安装后在 Claude Code 中输入/axiom-in-app-purchases确认已加载。
详细介绍
StoreKit 2 应用内购买实现
目的:指导如何实现稳健且可测试的应用内购买 StoreKit 版本:StoreKit 2 iOS 版本:iOS 15+(最新功能需要 iOS 18.4+) Xcode:Xcode 13+(推荐使用 Xcode 16+) 背景:WWDC 2025-241、2025-249、2023-10013、2021-10114
何时使用此技能
✅ 在以下情况下使用此技能:
- 实现任何应用内购买功能(新功能或现有功能)
- 添加消耗型产品(金币、提示、加成)
- 添加非消耗型产品(高级功能、关卡包)
- 添加自动续期订阅(月度/年度套餐)
- 调试购买失败、缺少交易或恢复问题
- 设置 StoreKit 测试配置
- 实现订阅状态跟踪
- 添加促销优惠或新用户优惠
- 服务端收据验证
- 支持家人共享
❌ 以下情况不要使用此技能:
- StoreKit 1(旧版 API)——此技能专注于 StoreKit 2
- App Store Connect 产品配置(另见相关文档)
- 定价策略或商业模式决策
⚠️ 创建 .storekit 配置前已经编写了代码?
如果你在创建 .storekit 配置前就编写了购买代码,可以选择以下三种做法:
选项 A:删除并重新开始(强烈推荐)
删除所有 IAP 代码,并按照下面的测试优先工作流操作。这有助于培养正确的习惯,并确保你能充分体验先创建 .storekit 再开发的优势。
这是最佳选择的原因:
- 验证你是否理解此工作流
- 发现可能遗漏的产品 ID 问题
- 为今后的 IAP 实现培养熟练度
- 有经验的开发者只需 15–30 分钟
选项 B:现在创建 .storekit 配置(可以接受,但需谨慎)
现在使用现有产品 ID 创建 .storekit 文件。测试所有功能能否在本地正常运行,并在 PR 中说明你先进行了沙盒测试。
权衡:
- ✅ 保留可正常运行的代码
- ✅ 支持本地测试
- ❌ 无法享受产品 ID 验证带来的好处
- ❌ 延续了先开发、后测试的模式
- ❌ 代码审查时需要格外谨慎
如果选择此方案:立即创建 .storekit,在本地验证,并提交一条说明此做法的备注。
选项 C:完全跳过 .storekit(不推荐)
不添加 .storekit 配置就提交,只在沙盒中测试。
这会带来问题,原因如下:
- 团队成员无法在本地测试购买流程
- 无法在运行时之前验证产品 ID
- 迭代更困难(需要使用 App Store Connect)
- 缺少产品结构文档
总之:尽可能选择选项 A;务实起见可选择选项 B;绝不要选择选项 C。
核心理念:测试优先工作流
最佳实践:在编写生产环境购买代码之前,先创建并测试 StoreKit 配置。
先创建 .storekit 的重要性
建议在编写任何购买代码之前创建 .storekit 配置。这并非随意规定,而是能带来切实好处:
立即验证产品 ID:
- 在 Xcode 中发现拼写错误,而不是等到运行时
- 在项目中直观查看产品配置
- 测试时不依赖 App Store Connect
加快迭代:
- 可立即在模拟器中测试购买
- 开发期间无需网络请求
- 可加速订阅续期,以便测试
对团队的好处:
- 任何人都能在本地测试购买流程
- 产品目录记录在代码中
- 代码审查包含购买测试
回应常见疑问:
❓ “我已经在沙盒中测试过了”——沙盒测试很有价值,但应在之后进行。使用 .storekit 本地测试更快,也有助于真正践行 TDD。
❓ “我的代码能正常运行”——代码能正常运行当然很好!添加 .storekit 后,团队成员更容易验证和维护代码。
❓ “我以前做过这些”——经验很宝贵。先创建 .storekit 的工作流能让经验丰富的开发者效率更高。
❓ “时间紧迫”——创建 .storekit 只需 10–15 分钟。迭代节省下来的时间很快就能弥补这部分投入。
推荐的工作流
StoreKit Config → Local Testing → Production Code → Unit Tests → Sandbox Testing
↓ ↓ ↓ ↓ ↓
.storekit Test purchases StoreManager Mock store Integration test
此顺序的优势:
- 先配置 StoreKit:无需依赖 App Store Connect 即可定义产品
- 本地测试:立即验证产品 ID 和购买流程
- 生产代码:基于已验证的产品配置进行实现
- 单元测试:使用模拟的商店响应验证业务逻辑
- Sandbox 测试:在 App Store 环境中进行最终验证
遵循此工作流的好处:
- 编写代码前验证产品 ID
- 加快开发迭代
- 便于团队协作
- 提高测试覆盖率
必查清单
在将 IAP 实现标记为完成之前,必须确认所有项目均已验证:
阶段 1:测试基础
- 已创建包含所有产品的
.storekit配置文件 - 已在 StoreKit 预览中验证每种产品类型都能正确显示
- 已在 Xcode 中测试每种产品的成功购买流程
- 已测试购买失败场景(余额不足、取消)
- 已测试恢复购买流程
- 对于订阅:已测试续订、到期和升级/降级
阶段 2:架构
- 存在集中管理的 StoreManager 类(单一事实来源)
- StoreManager 是 ObservableObject(SwiftUI)或使用 NotificationCenter
- 事务观察器通过
Transaction.updates监听更新 - 所有事务验证均使用
VerificationResult - 授予权益后,所有事务都会调用
.finish() - 在应用启动时或显示商店之前加载产品
阶段 3:购买流程
- 购买使用带有 UI 上下文的新版
purchase(confirmIn:options:)(iOS 18.2+) - 购买流程处理所有
PurchaseResult情况(成功、userCancelled、pending) - 授予权益前验证购买事务签名
- 保存购买事务收据/标识符,以便提供支持
- 为所有购买设置 appAccountToken(如果使用服务器后端)
阶段 4:订阅管理(如适用)
- 通过
Product.SubscriptionInfo.Status跟踪订阅状态 - 通过
Transaction.currentEntitlements(for:)检查当前权益 - 获取续订信息,以查询到期时间、续订日期和优惠状态
- 订阅视图使用 ProductView 或 SubscriptionStoreView
- 为已过期的订阅提供挽回优惠
- 处理宽限期和账单重试状态
阶段 5:恢复与同步
- 已实现恢复购买(App Store Review 要求)
- 恢复流程使用
Transaction.currentEntitlements或Transaction.all - 已识别家庭共享事务(如支持)
- 已实现服务器同步(如果使用后端)
- 已测试跨设备权益同步
阶段 6:错误处理
- 妥善处理网络错误(重试、向用户提示)
- 检测并记录无效的产品 ID
- 购买失败时向用户显示易于理解的错误消息
- 记录并报告事务验证失败
- 处理退款通知(通过 App Store Server Notifications)
阶段 7:测试与验证
- 单元测试使用模拟的 Product/Transaction 验证购买逻辑
- 单元测试验证订阅状态判定
- 使用 StoreKit 配置的集成测试通过
- 已使用真实 Apple ID 完成 Sandbox 测试
- 已在生产发布前完成 TestFlight 测试
步骤 1:创建 StoreKit 配置(第一步!)
请在编写任何购买代码之前完成此操作。
创建配置文件
- Xcode → File → New → File → StoreKit Configuration File
- 保存为:
Products.storekit(或使用你的应用名称) - 添加到目标:✅(测试时将其包含在应用包中)
添加产品
点击“+”并添加每种产品类型:
消耗型
Product ID: com.yourapp.coins_100
Reference Name: 100 Coins
Price: $0.99
非消耗型
Product ID: com.yourapp.premium
Reference Name: Premium Upgrade
Price: $4.99
自动续期订阅
Product ID: com.yourapp.pro_monthly
Reference Name: Pro Monthly
Price: $9.99/month
Subscription Group ID: pro_tier
立即测试
- 在模拟器中运行应用
- Scheme → Edit Scheme → Run → Options
- StoreKit Configuration:选择
Products.storekit - 验证:商品已加载、购买已完成、交易已显示
第 2 步:实现 StoreManager 架构
必需模式:集中式 StoreManager
所有购买逻辑都必须通过单个 StoreManager 处理。 不要在整个应用中零散调用 Product.purchase()。
import StoreKit
@MainActor
final class StoreManager: ObservableObject {
// Published state for UI
@Published private(set) var products: [Product] = []
@Published private(set) var purchasedProductIDs: Set<String> = []
// Product IDs from StoreKit configuration
private let productIDs = [
"com.yourapp.coins_100",
"com.yourapp.premium",
"com.yourapp.pro_monthly"
]
private var transactionListener: Task<Void, Never>?
init() {
// Start transaction listener immediately
transactionListener = listenForTransactions()
Task {
await loadProducts()
await updatePurchasedProducts()
}
}
deinit {
transactionListener?.cancel()
}
}
为什么使用 @MainActor:已发布的属性必须在主线程上更新,才能与 UI 绑定。
加载商品(启动时)
extension StoreManager {
func loadProducts() async {
do {
// Load products from App Store
let loadedProducts = try await Product.products(for: productIDs)
// Update published property on main thread
self.products = loadedProducts
} catch {
print("Failed to load products: \(error)")
// Show error to user
}
}
}
调用位置:App.init() 或首个视图的 .task 修饰符
监听交易(必需)
extension StoreManager {
func listenForTransactions() -> Task<Void, Never> {
Task.detached { [weak self] in
// Listen for ALL transaction updates
for await verificationResult in Transaction.updates {
await self?.handleTransaction(verificationResult)
}
}
}
@MainActor
private func handleTransaction(_ result: VerificationResult<Transaction>) async {
// Verify transaction signature
guard let transaction = try? result.payloadValue else {
print("Transaction verification failed")
return
}
// Grant entitlement to user
await grantEntitlement(for: transaction)
// CRITICAL: Always finish transaction
await transaction.finish()
// Update purchased products
await updatePurchasedProducts()
}
}
为什么使用 detached:交易监听器独立于视图生命周期运行
第 3 步:实现购买流程
带 UI 上下文的购买(iOS 18.2+)
extension StoreManager {
func purchase(_ product: Product, confirmIn scene: UIWindowScene) async throws -> Bool {
// Perform purchase with UI context for payment sheet
let result = try await product.purchase(confirmIn: scene)
switch result {
case .success(let verificationResult):
// Verify the transaction
guard let transaction = try? verificationResult.payloadValue else {
print("Transaction verification failed")
return false
}
// Grant entitlement
await grantEntitlement(for: transaction)
// CRITICAL: Finish transaction
await transaction.finish()
// Update state
await updatePurchasedProducts()
return true
case .userCancelled:
// User tapped "Cancel" in payment sheet
return false
case .pending:
// Purchase requires action (Ask to Buy, payment issue)
// Will be delivered via Transaction.updates when approved
return false
@unknown default:
return false
}
}
}
SwiftUI 购买(使用 Environment)
struct ProductRow: View {
let product: Product
@Environment(\.purchase) private var purchase
var body: some View {
Button("Buy \(product.displayPrice)") {
Task {
do {
let result = try await purchase(product)
// Handle result
} catch {
print("Purchase failed: \(error)")
}
}
}
}
}
设置 appAccountToken(如果使用后端)
func purchase(
_ product: Product,
confirmIn scene: UIWindowScene,
accountToken: UUID
) async throws -> Bool {
// Purchase with appAccountToken for server-side association
let result = try await product.purchase(
confirmIn: scene,
options: [
.appAccountToken(accountToken)
]
)
// ... handle result
}
使用时机:当后端需要将购买与用户账户关联时
第 4 步:验证交易(强制)
始终使用 VerificationResult
func handleTransaction(_ result: VerificationResult<Transaction>) async {
switch result {
case .verified(let transaction):
// ✅ Transaction signed by App Store
await grantEntitlement(for: transaction)
await transaction.finish()
case .unverified(let transaction, let error):
// ❌ Transaction signature invalid
print("Unverified transaction: \(error)")
// DO NOT grant entitlement
// DO finish transaction to clear from queue
await transaction.finish()
}
}
为什么要验证:防止因以下情况授予权益:
- 欺诈收据
- 越狱设备的收据
- 中间人攻击
检查交易字段
func grantEntitlement(for transaction: Transaction) async {
// Check transaction hasn't been revoked
guard transaction.revocationDate == nil else {
print("Transaction was refunded")
await revokeEntitlement(for: transaction.productID)
return
}
// Grant based on product type
switch transaction.productType {
case .consumable:
await addConsumable(productID: transaction.productID)
case .nonConsumable:
await unlockFeature(productID: transaction.productID)
case .autoRenewable:
await activateSubscription(productID: transaction.productID)
default:
break
}
}
第 5 步:跟踪当前权益
检查用户拥有的内容
extension StoreManager {
func updatePurchasedProducts() async {
var purchased: Set<String> = []
// Iterate through all current entitlements
for await result in Transaction.currentEntitlements {
guard let transaction = try? result.payloadValue else {
continue
}
// Only include active entitlements (not revoked)
if transaction.revocationDate == nil {
purchased.insert(transaction.productID)
}
}
self.purchasedProductIDs = purchased
}
}
检查特定商品
func isEntitled(to productID: String) async -> Bool {
// Check current entitlements for specific product
for await result in Transaction.currentEntitlements(for: productID) {
if let transaction = try? result.payloadValue,
transaction.revocationDate == nil {
return true
}
}
return false
}
第 6 步:实现订阅管理
跟踪订阅状态
extension StoreManager {
func checkSubscriptionStatus(for groupID: String) async -> Product.SubscriptionInfo.Status? {
// Get subscription statuses for group
guard let result = try? await Product.SubscriptionInfo.status(for: groupID),
let status = result.first else {
return nil
}
return status.state
}
}
处理订阅状态
func updateSubscriptionUI(for status: Product.SubscriptionInfo.Status) {
switch status.state {
case .subscribed:
// User has active subscription
showSubscribedContent()
case .expired:
// Subscription expired - show win-back offer
showResubscribeOffer()
case .inGracePeriod:
// Billing issue - show payment update prompt
showUpdatePaymentPrompt()
case .inBillingRetryPeriod:
// Apple retrying payment - maintain access
showBillingRetryMessage()
case .revoked:
// Family Sharing access removed
removeAccess()
@unknown default:
break
}
}
使用 StoreKit 视图(iOS 17+)
struct SubscriptionView: View {
var body: some View {
SubscriptionStoreView(groupID: "pro_tier") {
// Marketing content
VStack {
Image("premium-icon")
Text("Unlock all features")
}
}
.subscriptionStoreControlStyle(.prominentPicker)
}
}
第 7 步:实现恢复购买(必需)
恢复流程
extension StoreManager {
func restorePurchases() async {
// Sync all transactions from App Store
try? await AppStore.sync()
// Update current entitlements
await updatePurchasedProducts()
}
}
UI 按钮
struct SettingsView: View {
@StateObject private var store = StoreManager()
var body: some View {
Button("Restore Purchases") {
Task {
await store.restorePurchases()
}
}
}
}
App Store 要求:包含 IAP 的应用必须为非消耗型商品和订阅提供恢复功能。
第 8 步:处理退款
监听退款通知
extension StoreManager {
func listenForTransactions() -> Task<Void, Never> {
Task.detached { [weak self] in
for await verificationResult in Transaction.updates {
await self?.handleTransaction(verificationResult)
}
}
}
@MainActor
private func handleTransaction(_ result: VerificationResult<Transaction>) async {
guard let transaction = try? result.payloadValue else {
return
}
// Check if transaction was refunded
if let revocationDate = transaction.revocationDate {
print("Transaction refunded on \(revocationDate)")
await revokeEntitlement(for: transaction.productID)
} else {
await grantEntitlement(for: transaction)
}
await transaction.finish()
}
}
第 9 步:单元测试
模拟 Store 响应
protocol StoreProtocol {
func products(for ids: [String]) async throws -> [Product]
func purchase(_ product: Product) async throws -> PurchaseResult
}
// Production
final class StoreManager: StoreProtocol {
func products(for ids: [String]) async throws -> [Product] {
try await Product.products(for: ids)
}
}
// Testing
final class MockStore: StoreProtocol {
var mockProducts: [Product] = []
var mockPurchaseResult: PurchaseResult?
func products(for ids: [String]) async throws -> [Product] {
mockProducts
}
func purchase(_ product: Product) async throws -> PurchaseResult {
mockPurchaseResult ?? .userCancelled
}
}
测试购买逻辑
@Test func testSuccessfulPurchase() async {
let mockStore = MockStore()
let manager = StoreManager(store: mockStore)
// Given: Mock successful purchase
mockStore.mockPurchaseResult = .success(.verified(mockTransaction))
// When: Purchase product
let result = await manager.purchase(mockProduct)
// Then: Entitlement granted
#expect(result == true)
#expect(manager.purchasedProductIDs.contains("com.app.premium"))
}
@Test func testCancelledPurchase() async {
let mockStore = MockStore()
let manager = StoreManager(store: mockStore)
// Given: User cancels
mockStore.mockPurchaseResult = .userCancelled
// When: Purchase product
let result = await manager.purchase(mockProduct)
// Then: No entitlement granted
#expect(result == false)
#expect(manager.purchasedProductIDs.isEmpty)
}
常见反模式(绝对不要这样做)
❌ 不创建 StoreKit 配置
// ❌ WRONG: Writing purchase code without .storekit file
let products = try await Product.products(for: productIDs)
// Can't test this without App Store Connect setup!
✅ 正确做法:先创建 .storekit 文件,在 Xcode 中测试,然后再实现。
❌ 在创建 .storekit 配置前编写代码
// ❌ Less ideal: Write code, test in sandbox, add .storekit later
let products = try await Product.products(for: productIDs)
let result = try await product.purchase(confirmIn: scene)
// "I tested this in sandbox, it works! I'll add .storekit config later."
✅ 建议做法:先创建 .storekit 配置,然后再编写代码。
如果你正处于这种情况:请参阅上文“已在创建 .storekit 配置前编写代码?”部分,了解你的选项(A、B 或 C)。
为什么先创建 .storekit 更好:
- 在 Xcode 中发现商品 ID 拼写错误,而不是等到运行时
- 无需网络请求即可更快迭代
- 团队成员可以在本地测试
- 在代码中记录商品结构
沙盒测试很有价值——它能针对真实的 App Store 基础设施进行验证。但先使用 .storekit 会让沙盒测试更简单,因为你已经在本地验证了商品 ID。
❌ 零散调用购买
// ❌ WRONG: Purchase calls scattered throughout app
Button("Buy") {
try await product.purchase() // In view 1
}
Button("Subscribe") {
try await subscriptionProduct.purchase() // In view 2
}
✅ 正确做法:所有购买都通过集中式 StoreManager 处理。
❌ 忘记完成交易
// ❌ WRONG: Never calling finish()
func handleTransaction(_ transaction: Transaction) {
grantEntitlement(for: transaction)
// Missing: await transaction.finish()
}
✅ 正确做法:授予权益后,始终调用 transaction.finish()。
❌ 不验证交易
// ❌ WRONG: Using unverified transaction
for await transaction in Transaction.all {
grantEntitlement(for: transaction) // Unsafe!
}
✅ 正确做法:授予权益前,始终检查 VerificationResult。
❌ 忽略交易监听器
// ❌ WRONG: Only handling purchases in purchase() method
func purchase() {
let result = try await product.purchase()
// What about pending purchases, family sharing, restore?
}
✅ 正确做法:监听 Transaction.updates,以处理所有来源的交易。
❌ 未实现恢复功能
// ❌ WRONG: No restore button
// App Store will REJECT your app!
✅ 正确做法:在设置中提供醒目的“恢复购买”按钮。
验证
在将 IAP 实现标记为完成前,请确认:
代码检查
运行以下搜索以验证是否符合要求:
# Check StoreKit configuration exists
find . -name "*.storekit"
# Check transaction.finish() is called
rg "transaction\.finish\(\)" --type swift
# Check VerificationResult usage
rg "VerificationResult" --type swift
# Check Transaction.updates listener
rg "Transaction\.updates" --type swift
# Check restore implementation
rg "AppStore\.sync|Transaction\.all" --type swift
功能测试
- 可以在 StoreKit 配置中购买每种商品类型
- 可以取消购买,且状态保持一致
- 可以恢复购买并重新获得访问权限
- 订阅续订/到期按预期运行
- 退款交易会撤销访问权限
- 能识别“家人共享”交易(如果支持)
沙盒测试
- 完成真实 Apple ID 沙盒购买
- TestFlight 测试人员确认购买流程正常
- 服务器端验证正常(如果使用后端)
App Store Connect 提交(完整检查清单请参阅 app-store-submission)
- 为每个 IAP 产品上传审核截图(显示购买界面——仅供审核,不会显示在 App Store 上)
- 将 IAP 产品添加到此版本(首次提交:App Version → In-App Purchases 部分 → Select → 勾选每个产品)
- 购买界面上包含使用条款和隐私政策链接(DPLA Schedule 2 要求;
SubscriptionStoreView会自动处理) - 明确说明订阅条款:价格、周期、自动续订、取消方式
资源
WWDC:2025-241、2025-249、2023-10013、2021-10114
文档:/storekit、/appstoreserverapi
技能:axiom-storekit-ref