mcpField
A
axiom-in-app-purchases

axiom-in-app-purchases

github.com/charleswiltgen开发1.2k198 次安装今天更新
代码仓库

关于 axiom-in-app-purchases

面向 iOS 开发者的 StoreKit 2 参考技能,用于实现和测试应用内购买。它强调先创建 StoreKit 配置并验证购买流程,再编写正式代码。

主要功能

  1. 01StoreKit 2 购买模式
  2. 02优先测试的 .storekit 工作流
  3. 03消耗型、非消耗型及订阅
  4. 04交易验证与权益跟踪
  5. 05恢复购买与同步指南

适用场景

  1. 01构建消耗型或高级功能购买流程
  2. 02实现并跟踪自动续期订阅
  3. 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

此顺序的优势:

  1. 先配置 StoreKit:无需依赖 App Store Connect 即可定义产品
  2. 本地测试:立即验证产品 ID 和购买流程
  3. 生产代码:基于已验证的产品配置进行实现
  4. 单元测试:使用模拟的商店响应验证业务逻辑
  5. 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 配置(第一步!)

请在编写任何购买代码之前完成此操作。

创建配置文件
  1. Xcode → File → New → File → StoreKit Configuration File
  2. 保存为:Products.storekit(或使用你的应用名称)
  3. 添加到目标:✅(测试时将其包含在应用包中)
添加产品

点击“+”并添加每种产品类型:

消耗型
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
立即测试
  1. 在模拟器中运行应用
  2. Scheme → Edit Scheme → Run → Options
  3. StoreKit Configuration:选择 Products.storekit
  4. 验证:商品已加载、购买已完成、交易已显示

第 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

评论

支持 Markdown