mcpField
S
storekit

storekit

github.com/dpearson2699开发1.2k4.1k 次安装13 周前更新
代码仓库

关于 storekit

面向 Swift 开发者的 StoreKit 2 内购与订阅实现指南,介绍产品加载、购买验证、权益管理和恢复流程,并涵盖 SwiftUI 商店视图及常见错误。

主要功能

  1. 01加载内购产品并发起购买
  2. 02验证交易并处理交易更新
  3. 03检查订阅状态与用户权益
  4. 04使用商店视图构建购买界面
  5. 05恢复购买并处理退款撤销

适用场景

  1. 01实现消耗型或永久解锁内购
  2. 02创建自动续订订阅与订阅页
  3. 03在退款或续订后更新访问权限

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/dpearson2699/swift-ios-skills --skill storekit

安装后在 Claude Code 中输入/storekit确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

evals/
evals.json
references/
app-review-guidelines.md
storekit-advanced.md

详细介绍

StoreKit 2 应用内购买和订阅

使用 StoreKit 2 实现应用内购买、订阅、付费墙和 StoreKit 测试。使用现代的基于 Swift 的 Product、Transaction、 PurchaseAction、StoreView 和 SubscriptionStoreView API。除非需要支持旧版操作系统,否则请避免使用原始的 应用内购买 API(SKProduct、SKPaymentQueue)。

StoreKit 视图会自动发起购买。对于自定义控件,在 SwiftUI 中使用 PurchaseAction,在 UIKit/AppKit 中使用 purchase(confirmIn:options:),在 watchOS 上使用 product.purchase(options:)。

目录

产品类型

类型枚举成员行为
消耗型.consumable使用一次,可再次购买(宝石、金币)
非消耗型.nonConsumable一次性购买,永久有效(高级功能解锁)
自动续期订阅.autoRenewable定期计费并自动续期
非续期订阅.nonRenewing限时访问,不自动续期

加载产品

将产品 ID 定义为常量。使用 Product.products(for:) 获取产品。

import StoreKit

enum ProductID {
    static let premium = "com.myapp.premium"
    static let gems100 = "com.myapp.gems100"
    static let monthlyPlan = "com.myapp.monthly"
    static let yearlyPlan = "com.myapp.yearly"
    static let all: [String] = [premium, gems100, monthlyPlan, yearlyPlan]
}

let products = try await Product.products(for: ProductID.all)
for product in products {
    print("\(product.displayName): \(product.displayPrice)")
}

购买流程

标准付费墙优先使用 StoreKit 视图,因为它们会自动发起购买、恢复购买项目并显示政策控件。对于自定义 SwiftUI 购买按钮,优先使用环境中的 PurchaseAction。在 watchOS 上使用直接的 product.purchase(options:),在 UIKit 或 AppKit 中使用 purchase(confirmIn:options:) 进行确认。务必处理每种 PurchaseResult,在授予访问权限前进行验证,可靠地交付购买内容,然后完成交易。

@Environment(\.purchase) private var purchase

func purchaseProduct(_ product: Product) async throws {
    let result = try await purchase(product, options: [
        .appAccountToken(userAccountToken)
    ])
    switch result {
    case .success(let verification):
        let transaction = try checkVerified(verification)
        await deliverContent(for: transaction)
        await transaction.finish()
    case .userCancelled:
        break
    case .pending:
        // Ask to Buy or deferred approval: show pending UI, no unlock yet.
        showPendingApprovalMessage()
    @unknown default:
        break
    }
}

func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
    switch result {
    case .verified(let value): return value
    case .unverified(_, let error): throw error
    }
}

Transaction.updates 监听器

应在应用启动时启动,而不是等到付费墙出现时再启动。它会捕获来自其他设备的购买、家庭共享变更、续期、“询问购买”批准、退款、撤销,以及 Apple 在启动后立即发送一次的未完成交易。让此任务在应用的整个生命周期内保持持有状态。

@main
struct MyApp: App {
    private let transactionListener: Task<Void, Never>

    init() {
        transactionListener = Self.listenForTransactions()
    }

    var body: some Scene {
        WindowGroup { ContentView() }
    }

    static func listenForTransactions() -> Task<Void, Never> {
        Task(priority: .background) {
            for await result in Transaction.updates {
                guard case .verified(let transaction) = result else { continue }
                await StoreManager.shared.updateEntitlements()
                await transaction.finish()
            }
        }
    }
}

权益检查

Transaction.currentEntitlements 会发出非消耗型产品、有效或处于宽限期的自动续期订阅,以及最新的非续期订阅交易(包括已完成的交易)。它不包括消耗型产品以及已退款或已撤销的产品。请单独跟踪消耗型产品的交付情况,并在授予访问权限前,对非续期订阅应用应用的过期政策。

@Observable
@MainActor
class StoreManager {
    static let shared = StoreManager()
    var purchasedProductIDs: Set<String> = []
    var isPremium: Bool { purchasedProductIDs.contains(ProductID.premium) }

    func updateEntitlements() async {
        var purchased = Set<String>()
        for await result in Transaction.currentEntitlements {
            if case .verified(let transaction) = result,
               transaction.revocationDate == nil {
                if transaction.productType == .nonRenewing,
                   transaction.expirationDate.map({ $0 <= .now }) ?? true {
                    continue
                }
                purchased.insert(transaction.productID)
            }
        }
        purchasedProductIDs = purchased
    }
}
SwiftUI .currentEntitlementTask 修饰符
struct PremiumGatedView: View {
    @State private var state: EntitlementTaskState<VerificationResult<Transaction>?> = .loading

    var body: some View {
        Group {
            switch state {
            case .loading: ProgressView()
            case .failure: PaywallView()
            case .success(.some(.verified(let transaction))) where transaction.revocationDate == nil:
                PremiumContentView()
            case .success:
                PaywallView()
            }
        }
        .currentEntitlementTask(for: ProductID.premium) { state in
            self.state = state
        }
    }
}

SubscriptionStoreView(iOS 17+)

用于订阅付费墙的内置 SwiftUI 视图。它会自动处理产品加载、购买界面和恢复购买项目。

SubscriptionStoreView(groupID: "YOUR_GROUP_ID")
    .subscriptionStoreControlStyle(.prominentPicker)
    .subscriptionStoreButtonLabel(.multiline)
    .storeButton(.visible, for: .restorePurchases)
    .storeButton(.visible, for: .redeemCode)
    .subscriptionStorePolicyDestination(url: termsURL, for: .termsOfService)
    .subscriptionStorePolicyDestination(url: privacyURL, for: .privacyPolicy)
    .onInAppPurchaseCompletion { product, result in
        if case .success(.success(.verified(let transaction))) = result {
            await deliverContent(for: transaction)
            await transaction.finish()
        }
    }
自定义营销内容

请使用 SubscriptionStoreView 控件样式中的容器背景和页眉模式。

层级布局

使用 SubscriptionOptionGroup、SubscriptionOptionSection 或 SubscriptionPeriodGroupSet 来组织 iOS 18+ 选项;请参阅 订阅组管理。

StoreView(iOS 17+)

以本地化名称、价格和购买按钮展示多个产品。

StoreView(ids: [ProductID.gems100, ProductID.premium], prefersPromotionalIcon: true)
    .productViewStyle(.large)
    .storeButton(.visible, for: .restorePurchases)
    .onInAppPurchaseCompletion { product, result in
        if case .success(.success(.verified(let transaction))) = result {
            await deliverContent(for: transaction)
            await transaction.finish()
        }
    }
单个产品的 ProductView
ProductView(id: ProductID.premium) { iconPhase in
    switch iconPhase {
    case .success(let image): image.resizable().scaledToFit()
    case .loading: ProgressView()
    default: Image(systemName: "star.fill")
    }
}
.productViewStyle(.large)

订阅状态检查

func checkSubscriptionActive(groupID: String) async throws -> Bool {
    let statuses = try await Product.SubscriptionInfo.status(for: groupID)
    for status in statuses {
        guard case .verified = status.renewalInfo,
              case .verified = status.transaction else { continue }
        if status.state == .subscribed || status.state == .inGracePeriod {
            return true
        }
    }
    return false
}
续订状态
状态含义
.subscribed订阅有效
.expired订阅已过期
.inBillingRetryPeriod付款失败,Apple 正在重试
.inGracePeriod付款失败,但在宽限期内仍可访问
.revokedApple 已退款或撤销订阅

恢复购买

StoreKit 2 通过 Transaction.currentEntitlements 处理恢复。添加恢复按钮,或显式调用 AppStore.sync()。

func restorePurchases() async throws {
    try await AppStore.sync()
    await StoreManager.shared.updateEntitlements()
}

在商店视图中:.storeButton(.visible, for: .restorePurchases)

App 交易(App 购买验证)

验证 App 安装的合法性。用于业务模式变更或检测被篡改的安装(iOS 16+)。

func verifyAppPurchase() async {
    do {
        let result = try await AppTransaction.shared
        switch result {
        case .verified(let appTransaction):
            let originalVersion = appTransaction.originalAppVersion
            let purchaseDate = appTransaction.originalPurchaseDate
            // Migration logic for users who paid before subscription model
        case .unverified:
            // Potentially tampered -- restrict features as appropriate
            break
        }
    } catch { /* Could not retrieve app transaction */ }
}

购买选项

// App account token for server-side reconciliation
try await product.purchase(options: [.appAccountToken(UUID())])

// Consumable quantity
try await product.purchase(options: [.quantity(5)])

// Simulate Ask to Buy in sandbox
try await product.purchase(options: [.simulatesAskToBuyInSandbox(true)])

SwiftUI 购买回调

.onInAppPurchaseStart { product in
    await analytics.trackPurchaseStarted(product.id)
}
.onInAppPurchaseCompletion { product, result in
    if case .success(.success(.verified(let transaction))) = result {
        await deliverContent(for: transaction)
        await transaction.finish()
    }
}
.inAppPurchaseOptions { product in
    [.appAccountToken(userAccountToken)]
}

常见错误

1. 未在 App 启动时启动 Transaction.updates
// WRONG: No listener -- misses renewals, refunds, Ask to Buy approvals
@main struct MyApp: App {
    var body: some Scene { WindowGroup { ContentView() } }
}
// CORRECT: Start listener in App init (see Transaction.updates section above)
2. 忘记调用 transaction.finish()
// WRONG: Never finished -- reappears in unfinished queue forever
let transaction = try checkVerified(verification)
unlockFeature(transaction.productID)

// CORRECT: Deliver durably, then finish. If delivery fails, do not finish yet.
let transaction = try checkVerified(verification)
try await recordDelivery(transaction)
await transaction.finish()
3. 忽略验证结果
// WRONG: Using unverified transaction -- security risk
let transaction = verification.unsafePayloadValue

// CORRECT: Verify before using
let transaction = try checkVerified(verification)
4. 在新的 StoreKit 2 代码中使用原版 App 内购买 API
// AVOID: Original In-App Purchase APIs
let request = SKProductsRequest(productIdentifiers: ["com.app.premium"])
SKPaymentQueue.default().add(payment)

// PREFERRED: StoreKit 2
let products = try await Product.products(for: ["com.app.premium"])
let result = try await product.purchase()
5. 未检查 revocationDate
// WRONG: Grants access to refunded purchases
if case .verified(let transaction) = result {
    purchased.insert(transaction.productID)
}

// CORRECT: Skip revoked transactions
if case .verified(let transaction) = result, transaction.revocationDate == nil {
    purchased.insert(transaction.productID)
}
6. 将价格硬编码
// WRONG: Wrong for other currencies and regions
Text("Buy Premium for $4.99")

// CORRECT: Localized price from Product
Text("Buy \(product.displayName) for \(product.displayPrice)")
7. 未处理 .pending 购买结果
// WRONG: Silently drops pending Ask to Buy
default: break

// CORRECT: Explain approval is pending; unlock only after Transaction.updates
case .pending:
    showPendingApprovalMessage()
8. 仅在启动时检查一次权益
// WRONG: Check once, never update
func appDidFinish() { Task { await updateEntitlements() } }

// CORRECT: Re-check on Transaction.updates AND on foreground return
// Transaction.updates listener handles mid-session changes.
// Also use .task { await storeManager.updateEntitlements() } on content views.
9. 缺少恢复购买按钮
// WRONG: No restore option -- App Store rejection risk
SubscriptionStoreView(groupID: "group_id")

// CORRECT
SubscriptionStoreView(groupID: "group_id")
    .storeButton(.visible, for: .restorePurchases)
10. 订阅视图缺少政策链接
// WRONG: No terms or privacy policy
SubscriptionStoreView(groupID: "group_id")

// CORRECT
SubscriptionStoreView(groupID: "group_id")
    .subscriptionStorePolicyDestination(url: termsURL, for: .termsOfService)
    .subscriptionStorePolicyDestination(url: privacyURL, for: .privacyPolicy)

审核清单

  • Transaction.updates 监听器在 App 启动时于 App init 中启动
  • 授予访问权限前验证所有交易
  • 仅在内容已可靠交付后调用 transaction.finish()
  • 排除已撤销或退款的交易,并更新权益状态
  • .pending 结果显示“询问购买”或延迟审批的反馈
  • 付费墙和商店视图中显示恢复购买按钮
  • 订阅视图中包含服务条款和隐私政策链接
  • 使用 product.displayPrice 显示价格,绝不硬编码
  • 清楚显示订阅条款(价格、时长、续订)
  • 清楚说明免费试用结束后的价格
  • 除非旧版 OS 支持需要,否则不使用原版 App 内购买 API(SKProduct、SKPaymentQueue)
  • 将产品 ID 定义为常量,而不是散落在各处的字符串
  • StoreKit 测试覆盖促销优惠、挽回优惠、优惠码、“询问购买”、续订、退款和撤销
  • 在 Transaction.updates 和 App 进入前台时重新检查权益
  • 如适用,服务端验证使用 jwsRepresentation
  • 及时交付并完成消耗型项目的交易
  • 跨并发边界共享时,交易观察器类型和产品模型类型均为 Sendable

参考资料

评论

支持 Markdown