ios-monetization

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

iOS Monetization — StoreKit 2

iOS 变现方案 — StoreKit 2

<!-- dual-compat-start -->
<!-- dual-compat-start -->

Use When

适用场景

  • StoreKit 2 in-app purchases, subscriptions, and monetization for iOS apps. Use when implementing consumables, non-consumables, auto-renewable subscriptions, paywall UI, receipt validation, or App Store Connect configuration.
  • The task needs reusable judgment, domain constraints, or a proven workflow rather than ad hoc advice.
  • 针对iOS应用的StoreKit 2内购、订阅与变现方案。适用于实现消耗型产品、非消耗型产品、自动续订订阅、付费墙UI、收据验证或App Store Connect配置场景。
  • 任务需要可复用的判断逻辑、领域约束或成熟工作流,而非临时建议。

Do Not Use When

不适用场景

  • The task is unrelated to
    ios-monetization
    or would be better handled by a more specific companion skill.
  • The request only needs a trivial answer and none of this skill's constraints or references materially help.
  • 任务与
    ios-monetization
    无关,或更适合由特定配套技能处理。
  • 请求仅需简单答案,本技能的约束或参考内容无法提供实质性帮助。

Required Inputs

必要输入

  • Gather relevant project context, constraints, and the concrete problem to solve.
  • Confirm the desired deliverable: design, code, review, migration plan, audit, or documentation.
  • 收集相关项目背景、约束条件及具体待解决问题。
  • 确认期望交付物:设计方案、代码、评审意见、迁移计划、审计报告或文档。

Workflow

工作流

  • Read this
    SKILL.md
    first, then load only the referenced deep-dive files that are necessary for the task.
  • Apply the ordered guidance, checklists, and decision rules in this skill instead of cherry-picking isolated snippets.
  • Produce the deliverable with assumptions, risks, and follow-up work made explicit when they matter.
  • 首先阅读本
    SKILL.md
    ,仅加载完成任务所需的相关深度文档。
  • 遵循本技能中的有序指导、检查清单和决策规则,而非零散选取片段内容。
  • 交付成果需明确标注关键假设、风险及后续工作。

Quality Standards

质量标准

  • Keep outputs execution-oriented, concise, and aligned with the repository's baseline engineering standards.
  • Preserve compatibility with existing project conventions unless the skill explicitly requires a stronger standard.
  • Prefer deterministic, reviewable steps over vague advice or tool-specific magic.
  • 输出内容以可执行为导向,简洁清晰,并与仓库的基准工程标准保持一致。
  • 除非技能明确要求更高标准,否则需兼容现有项目惯例。
  • 优先采用可确定、可评审的步骤,而非模糊建议或工具专属的“魔法操作”。

Anti-Patterns

反模式

  • Treating examples as copy-paste truth without checking fit, constraints, or failure modes.
  • Loading every reference file by default instead of using progressive disclosure.
  • 将示例视为可直接复制粘贴的正确实现,未检查适配性、约束条件或失败场景。
  • 默认加载所有参考文件,而非按需逐步披露内容。

Outputs

输出成果

  • A concrete result that fits the task: implementation guidance, review findings, architecture decisions, templates, or generated artifacts.
  • Clear assumptions, tradeoffs, or unresolved gaps when the task cannot be completed from available context alone.
  • References used, companion skills, or follow-up actions when they materially improve execution.
  • 符合任务需求的具体结果:实现指南、评审发现、架构决策、模板或生成的工件。
  • 当现有上下文无法完成任务时,需明确标注假设、权衡或未解决的缺口。
  • 列出对执行有实质性帮助的参考资料、配套技能或后续行动。

Evidence Produced

生成的证据

CategoryArtifactFormatExample
CorrectnessStoreKit 2 purchase flow test planMarkdown doc covering consumable, non-consumable, subscription, and restore flows
docs/ios/storekit-tests-checkout.md
Release evidenceApp Store subscription configuration recordMarkdown doc capturing product IDs, pricing tiers, and intro offers per region
docs/ios/subscription-config-2026-04-16.md
类别工件格式示例
正确性StoreKit 2购买流程测试计划涵盖消耗型、非消耗型、订阅及恢复流程的Markdown文档
docs/ios/storekit-tests-checkout.md
发布证据App Store订阅配置记录记录各地区产品ID、定价层级及首月优惠的Markdown文档
docs/ios/subscription-config-2026-04-16.md

References

参考资料

  • Use the links and companion skills already referenced in this file when deeper context is needed.
<!-- dual-compat-end -->
  • 当需要更深入的上下文时,使用本文档中已引用的链接和配套技能。
<!-- dual-compat-end -->

Architecture Principles

架构原则

StoreKit 2 is async/await-native. The entire
SKPaymentQueue
/delegate callback model is abandoned. Every purchase, verification, and entitlement check is a Swift concurrency operation.
Transaction observer is not optional. It must start at app entry point — before any UI renders. Transactions that completed while the app was terminated are delivered via
Transaction.updates
on next launch. Starting this loop in the paywall means you silently drop those deliveries.
swift
// App.swift — Swift 6 actor-isolated entry point
@main
struct MyApp: App {
    @StateObject private var store = StoreService()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(store)
                .task { await store.observeTransactions() }
        }
    }
}

StoreKit 2 原生支持async/await。整套
SKPaymentQueue
/代理回调模型已被弃用。所有购买、验证和权限检查操作均基于Swift并发实现。
事务观察者为必填项。必须在应用入口点启动——早于任何UI渲染。应用终止期间完成的事务会在下次启动时通过
Transaction.updates
交付。若在付费墙中启动此循环,会导致这些交付被静默丢弃。
swift
// App.swift — Swift 6 actor-isolated entry point
@main
struct MyApp: App {
    @StateObject private var store = StoreService()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(store)
                .task { await store.observeTransactions() }
        }
    }
}

StoreService — Core Implementation

StoreService — 核心实现

swift
import StoreKit

@MainActor
final class StoreService: ObservableObject {
    @Published private(set) var products: [Product] = []
    @Published private(set) var purchasedProductIDs: Set<String> = []

    private var transactionObserver: Task<Void, Never>?

    init() {
        transactionObserver = Task { await observeTransactions() }
    }

    deinit {
        transactionObserver?.cancel()
    }

    // MARK: - Product Loading

    func loadProducts(ids: [String]) async {
        do {
            products = try await Product.products(for: ids)
            // Products come back unordered — sort by price or custom order
            products.sort { $0.price < $1.price }
        } catch {
            // StoreKitError.networkError — retry with backoff
            // StoreKitError.notEntitled — sandbox/config issue
        }
    }

    // MARK: - Transaction Observer (MUST run for app lifetime)

    func observeTransactions() async {
        for await result in Transaction.updates {
            await process(result)
        }
    }

    // MARK: - Purchase

    func purchase(_ product: Product,
                  options: Set<Product.PurchaseOption> = []) async throws -> Transaction? {
        let result = try await product.purchase(options: options)
        switch result {
        case .success(let verification):
            let transaction = try checkVerified(verification)
            await updateEntitlements(transaction)
            await transaction.finish()   // CRITICAL — unfinished = re-delivered on next launch
            return transaction
        case .userCancelled:
            return nil
        case .pending:
            // Awaiting Ask to Buy or billing fix — show "payment pending" UI
            return nil
        @unknown default:
            return nil
        }
    }

    // MARK: - Entitlement Refresh

    func refreshEntitlements() async {
        purchasedProductIDs.removeAll()
        for await result in Transaction.currentEntitlements {
            guard case .verified(let transaction) = result else { continue }
            guard transaction.revocationDate == nil else { continue }  // Apple refunded
            purchasedProductIDs.insert(transaction.productID)
        }
    }

    // MARK: - Restore

    func restore() async throws {
        // AppStore.sync() triggers re-validation + re-delivery of current entitlements
        // Do NOT use SKPaymentQueue.restoreCompletedTransactions — it is deprecated
        try await AppStore.sync()
        await refreshEntitlements()
    }

    // MARK: - Private

    private func process(_ result: VerificationResult<Transaction>) async {
        await updateEntitlements(try? checkVerified(result))
    }

    private func updateEntitlements(_ transaction: Transaction?) async {
        guard let transaction else { return }
        if transaction.revocationDate == nil {
            purchasedProductIDs.insert(transaction.productID)
        } else {
            purchasedProductIDs.remove(transaction.productID)
        }
    }

    private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
        switch result {
        case .unverified:
            // JWS signature invalid — tampered receipt or configuration error
            throw StoreError.failedVerification
        case .verified(let value):
            return value
        }
    }
}

enum StoreError: LocalizedError {
    case failedVerification
    var errorDescription: String? { "Purchase could not be verified." }
}

swift
import StoreKit

@MainActor
final class StoreService: ObservableObject {
    @Published private(set) var products: [Product] = []
    @Published private(set) var purchasedProductIDs: Set<String> = []

    private var transactionObserver: Task<Void, Never>?

    init() {
        transactionObserver = Task { await observeTransactions() }
    }

    deinit {
        transactionObserver?.cancel()
    }

    // MARK: - Product Loading

    func loadProducts(ids: [String]) async {
        do {
            products = try await Product.products(for: ids)
            // Products come back unordered — sort by price or custom order
            products.sort { $0.price < $1.price }
        } catch {
            // StoreKitError.networkError — retry with backoff
            // StoreKitError.notEntitled — sandbox/config issue
        }
    }

    // MARK: - Transaction Observer (MUST run for app lifetime)

    func observeTransactions() async {
        for await result in Transaction.updates {
            await process(result)
        }
    }

    // MARK: - Purchase

    func purchase(_ product: Product,
                  options: Set<Product.PurchaseOption> = []) async throws -> Transaction? {
        let result = try await product.purchase(options: options)
        switch result {
        case .success(let verification):
            let transaction = try checkVerified(verification)
            await updateEntitlements(transaction)
            await transaction.finish()   // CRITICAL — unfinished = re-delivered on next launch
            return transaction
        case .userCancelled:
            return nil
        case .pending:
            // Awaiting Ask to Buy or billing fix — show "payment pending" UI
            return nil
        @unknown default:
            return nil
        }
    }

    // MARK: - Entitlement Refresh

    func refreshEntitlements() async {
        purchasedProductIDs.removeAll()
        for await result in Transaction.currentEntitlements {
            guard case .verified(let transaction) = result else { continue }
            guard transaction.revocationDate == nil else { continue }  // Apple refunded
            purchasedProductIDs.insert(transaction.productID)
        }
    }

    // MARK: - Restore

    func restore() async throws {
        // AppStore.sync() triggers re-validation + re-delivery of current entitlements
        // Do NOT use SKPaymentQueue.restoreCompletedTransactions — it is deprecated
        try await AppStore.sync()
        await refreshEntitlements()
    }

    // MARK: - Private

    private func process(_ result: VerificationResult<Transaction>) async {
        await updateEntitlements(try? checkVerified(result))
    }

    private func updateEntitlements(_ transaction: Transaction?) async {
        guard let transaction else { return }
        if transaction.revocationDate == nil {
            purchasedProductIDs.insert(transaction.productID)
        } else {
            purchasedProductIDs.remove(transaction.productID)
        }
    }

    private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
        switch result {
        case .unverified:
            // JWS signature invalid — tampered receipt or configuration error
            throw StoreError.failedVerification
        case .verified(let value):
            return value
        }
    }
}

enum StoreError: LocalizedError {
    case failedVerification
    var errorDescription: String? { "Purchase could not be verified." }
}

Subscription Status — Full Detail

订阅状态 — 详细说明

Transaction.currentEntitlements
gives current owned state.
Product.subscription?.status
gives renewal metadata.
swift
struct SubscriptionStatus {
    let isActive: Bool
    let willAutoRenew: Bool
    let expirationDate: Date?
    let isInBillingRetry: Bool
    let scheduledDowngradeProductID: String?
}

func subscriptionStatus(for product: Product) async -> SubscriptionStatus? {
    guard let subscription = product.subscription,
          let statusArray = try? await subscription.status else { return nil }

    // statusArray contains one entry per subscription in the group
    for status in statusArray {
        guard case .verified(let renewalInfo) = status.renewalInfo,
              case .verified(let transaction) = status.transaction else { continue }

        let isActive = status.state == .subscribed || status.state == .inGracePeriod
        return SubscriptionStatus(
            isActive: isActive,
            willAutoRenew: renewalInfo.willAutoRenew,
            expirationDate: transaction.expirationDate,
            isInBillingRetry: status.state == .inBillingRetryPeriod,
            scheduledDowngradeProductID: renewalInfo.autoRenewProductID != product.id
                ? renewalInfo.autoRenewProductID : nil
        )
    }
    return nil
}
Subscription states to handle:
StateMeaningAction
.subscribed
ActiveFull access
.inGracePeriod
Billing failed, grace period activeFull access + soft prompt
.inBillingRetryPeriod
Grace expired, Apple retryingRestricted access + hard prompt
.expired
LapsedPaywall
.revoked
Family sharing revokedRemove access immediately

Transaction.currentEntitlements
提供当前拥有状态。
Product.subscription?.status
提供续订元数据。
swift
struct SubscriptionStatus {
    let isActive: Bool
    let willAutoRenew: Bool
    let expirationDate: Date?
    let isInBillingRetry: Bool
    let scheduledDowngradeProductID: String?
}

func subscriptionStatus(for product: Product) async -> SubscriptionStatus? {
    guard let subscription = product.subscription,
          let statusArray = try? await subscription.status else { return nil }

    // statusArray contains one entry per subscription in the group
    for status in statusArray {
        guard case .verified(let renewalInfo) = status.renewalInfo,
              case .verified(let transaction) = status.transaction else { continue }

        let isActive = status.state == .subscribed || status.state == .inGracePeriod
        return SubscriptionStatus(
            isActive: isActive,
            willAutoRenew: renewalInfo.willAutoRenew,
            expirationDate: transaction.expirationDate,
            isInBillingRetry: status.state == .inBillingRetryPeriod,
            scheduledDowngradeProductID: renewalInfo.autoRenewProductID != product.id
                ? renewalInfo.autoRenewProductID : nil
        )
    }
    return nil
}
需要处理的订阅状态:
状态含义操作
.subscribed
已激活提供完整访问权限
.inGracePeriod
计费失败,宽限期已激活提供完整访问权限 + 软性提示
.inBillingRetryPeriod
宽限期已过,Apple正在重试限制访问权限 + 硬性提示
.expired
已过期展示付费墙
.revoked
家庭共享已撤销立即移除访问权限

Introductory Offers

首月优惠

Intro offers are Apple ID-scoped — once consumed, the user is ineligible forever. Check before displaying.
swift
func introOfferDetails(for product: Product) async -> Product.SubscriptionOffer? {
    guard let subscription = product.subscription,
          await subscription.isEligibleForIntroOffer == true else { return nil }
    return subscription.introductoryOffer
}

// Render based on paymentMode
func introLabel(_ offer: Product.SubscriptionOffer) -> String {
    switch offer.paymentMode {
    case .freeTrial:
        return "Free for \(offer.period.localizedDescription)"
    case .payAsYouGo:
        return "\(offer.displayPrice)/\(offer.period.value) \(offer.period.unit) for \(offer.periodCount) periods"
    case .payUpFront:
        return "\(offer.displayPrice) for \(offer.periodCount) periods"
    @unknown default:
        return offer.displayPrice
    }
}

首月优惠基于Apple ID判定——一旦使用,用户将永久失去资格。展示前需先检查。
swift
func introOfferDetails(for product: Product) async -> Product.SubscriptionOffer? {
    guard let subscription = product.subscription,
          await subscription.isEligibleForIntroOffer == true else { return nil }
    return subscription.introductoryOffer
}

// Render based on paymentMode
func introLabel(_ offer: Product.SubscriptionOffer) -> String {
    switch offer.paymentMode {
    case .freeTrial:
        return "Free for \(offer.period.localizedDescription)"
    case .payAsYouGo:
        return "\(offer.displayPrice)/\(offer.period.value) \(offer.period.unit) for \(offer.periodCount) periods"
    case .payUpFront:
        return "\(offer.displayPrice) for \(offer.periodCount) periods"
    @unknown default:
        return offer.displayPrice
    }
}

Promotional Offers (Win-Back / Loyalty)

促销优惠(赢回用户/忠诚度计划)

Promotional offers require a server-generated signature. The signature proves your server authorised the discount.
swift
// Server returns: keyID, nonce, signature, timestamp
let offerID = "annual_winback_50"
let signature = try await fetchPromoSignature(productID: product.id, offerID: offerID)

let purchaseOption = Product.PurchaseOption.promotionalOffer(
    offerID: offerID,
    keyID: signature.keyID,
    nonce: signature.nonce,
    signature: signature.data,
    timestamp: signature.timestamp
)
let transaction = try await storeService.purchase(product, options: [purchaseOption])
Never hard-code or generate signatures client-side — App Store will reject them.

促销优惠需要服务器生成的签名。该签名用于证明你的服务器授权了此次折扣。
swift
// Server returns: keyID, nonce, signature, timestamp
let offerID = "annual_winback_50"
let signature = try await fetchPromoSignature(productID: product.id, offerID: offerID)

let purchaseOption = Product.PurchaseOption.promotionalOffer(
    offerID: offerID,
    keyID: signature.keyID,
    nonce: signature.nonce,
    signature: signature.data,
    timestamp: signature.timestamp
)
let transaction = try await storeService.purchase(product, options: [purchaseOption])
切勿在客户端硬编码或生成签名——App Store会拒绝此类请求。

Paywall ViewModel

付费墙ViewModel

swift
@MainActor
final class PaywallViewModel: ObservableObject {
    enum PurchaseState: Equatable {
        case idle, loading, purchasing, purchased, failed(String)
    }

    @Published var products: [Product] = []
    @Published var purchaseState: PurchaseState = .idle
    @Published var selectedProduct: Product?
    @Published var introOffer: Product.SubscriptionOffer?

    private let store: StoreService

    init(store: StoreService) {
        self.store = store
    }

    func load(productIDs: [String]) async {
        purchaseState = .loading
        await store.loadProducts(ids: productIDs)
        products = store.products
        selectedProduct = products.first(where: { $0.type == .autoRenewable })
        if let selected = selectedProduct {
            introOffer = await introOfferDetails(for: selected)
        }
        purchaseState = .idle
    }

    func purchase() async {
        guard let product = selectedProduct else { return }
        purchaseState = .purchasing
        do {
            guard try await store.purchase(product) != nil else {
                // userCancelled or pending — no error, just reset
                purchaseState = .idle
                return
            }
            purchaseState = .purchased
        } catch {
            purchaseState = .failed(error.localizedDescription)
        }
    }

    func restore() async {
        purchaseState = .loading
        do {
            try await store.restore()
            purchaseState = store.purchasedProductIDs.isEmpty ? .idle : .purchased
        } catch {
            purchaseState = .failed(error.localizedDescription)
        }
    }
}

swift
@MainActor
final class PaywallViewModel: ObservableObject {
    enum PurchaseState: Equatable {
        case idle, loading, purchasing, purchased, failed(String)
    }

    @Published var products: [Product] = []
    @Published var purchaseState: PurchaseState = .idle
    @Published var selectedProduct: Product?
    @Published var introOffer: Product.SubscriptionOffer?

    private let store: StoreService

    init(store: StoreService) {
        self.store = store
    }

    func load(productIDs: [String]) async {
        purchaseState = .loading
        await store.loadProducts(ids: productIDs)
        products = store.products
        selectedProduct = products.first(where: { $0.type == .autoRenewable })
        if let selected = selectedProduct {
            introOffer = await introOfferDetails(for: selected)
        }
        purchaseState = .idle
    }

    func purchase() async {
        guard let product = selectedProduct else { return }
        purchaseState = .purchasing
        do {
            guard try await store.purchase(product) != nil else {
                // userCancelled or pending — no error, just reset
                purchaseState = .idle
                return
            }
            purchaseState = .purchased
        } catch {
            purchaseState = .failed(error.localizedDescription)
        }
    }

    func restore() async {
        purchaseState = .loading
        do {
            try await store.restore()
            purchaseState = store.purchasedProductIDs.isEmpty ? .idle : .purchased
        } catch {
            purchaseState = .failed(error.localizedDescription)
        }
    }
}

Receipt Validation — JWS vs Legacy

收据验证 — JWS vs 传统方式

StoreKit 2 signs every transaction as a JWS (JSON Web Signature). You do not need the old base64
appReceipt
+
/verifyReceipt
endpoint.
Client-side (sufficient for most apps):
VerificationResult.verified
means Apple's signature checked out locally. Use this.
Server-side (required for high-value entitlements, fraud prevention):
1. Decode JWS: split by ".", base64url-decode payload
2. Verify signature using Apple's public key from WWDR certificate chain
3. Check: environment, bundleID, productID, expirationDate, revocationDate
4. Use App Store Server API for real-time status (not polled receipts)
App Store Server Notifications v2 (webhooks) push events to your server:
  • DID_RENEW
    ,
    EXPIRED
    ,
    REFUND
    ,
    GRACE_PERIOD_EXPIRED
    ,
    REVOKE
Register the endpoint in App Store Connect > App Information > App Store Server Notifications.

StoreKit 2会为每个事务生成JWS(JSON Web Signature)签名。你无需使用旧的base64格式
appReceipt
+
/verifyReceipt
接口。
客户端验证(适用于大多数应用):
VerificationResult.verified
表示Apple的签名已在本地验证通过。直接使用此结果即可。
服务器端验证(高价值权限、防欺诈场景必填):
1. 解码JWS:按"分割",对payload进行base64url解码
2. 使用WWDR证书链中的Apple公钥验证签名
3. 检查:环境、bundleID、productID、expirationDate、revocationDate
4. 使用App Store Server API获取实时状态(而非轮询收据)
App Store Server Notifications v2(Webhooks)会将事件推送到你的服务器:
  • DID_RENEW
    EXPIRED
    REFUND
    GRACE_PERIOD_EXPIRED
    REVOKE
在App Store Connect > 应用信息 > App Store服务器通知中注册端点。

Consumables — Delivery Pattern

消耗型产品 — 交付模式

Consumables are not tracked by
Transaction.currentEntitlements
. You must persist delivery yourself.
swift
func purchaseConsumable(_ product: Product) async throws {
    guard let transaction = try await store.purchase(product) else { return }
    // Deliver immediately before finish — if app crashes between deliver+finish,
    // transaction re-delivers on next launch via Transaction.updates
    await deliverConsumable(transaction.productID, quantity: transaction.purchasedQuantity)
    await transaction.finish()
}

// Idempotency: store transaction.id in your DB — re-delivery must not double-grant
func deliverConsumable(_ productID: String, quantity: Int) async {
    // Check if transaction.id already processed before crediting
}

消耗型产品不会
Transaction.currentEntitlements
追踪。你必须自行持久化交付记录。
swift
func purchaseConsumable(_ product: Product) async throws {
    guard let transaction = try await store.purchase(product) else { return }
    // Deliver immediately before finish — if app crashes between deliver+finish,
    // transaction re-delivers on next launch via Transaction.updates
    await deliverConsumable(transaction.productID, quantity: transaction.purchasedQuantity)
    await transaction.finish()
}

// Idempotency: store transaction.id in your DB — re-delivery must not double-grant
func deliverConsumable(_ productID: String, quantity: Int) async {
    // Check if transaction.id already processed before crediting
}

App Store Connect Configuration — Critical Steps

App Store Connect配置 — 关键步骤

  1. Create IAPs before running on device — Xcode cannot synthesise them
  2. Subscription Group required before adding Auto-Renewable subscriptions; group name is user-visible in cancellation flow
  3. All tiers in the same subscription group share one active subscription; Apple handles proration on upgrades automatically
  4. Localisations on products are required — missing localisation = product not returned by
    Product.products(for:)
  5. Tax categories must be set (Software, Newspaper, etc.) — affects storefront availability
  6. Pricing: set a base territory first, then "Sync" to all territories — do not set each manually
  7. Sandbox testers created in App Store Connect > Users and Access > Sandbox Testers — use a new Apple ID, not your own

  1. 在设备上运行前创建IAP——Xcode无法自动生成
  2. 添加自动续订订阅前需先创建订阅组;组名会在取消流程中对用户可见
  3. 同一订阅组内的所有层级共享一个活跃订阅;Apple会自动处理升级时的按比例计费
  4. 产品必须配置本地化——缺少本地化会导致
    Product.products(for:)
    无法返回该产品
  5. 必须设置税务类别(软件、报纸等)——会影响店铺上架范围
  6. 定价:先设置基准地区,然后"同步"到所有地区——不要手动逐个设置
  7. 在App Store Connect > 用户与访问 > Sandbox测试员中创建沙盒测试账号——使用新的Apple ID,不要用自己的账号

StoreKit Configuration File (Local Testing)

StoreKit配置文件(本地测试)

Add a
.storekit
file to the Xcode project, configure via Edit Scheme > Run > Options > StoreKit Configuration. This bypasses App Store Connect entirely.
Non-obvious capabilities of the config file:
  • Simulate interrupted purchases (requires StoreKit testing in-process)
  • Set transaction speed to "Monthly" = 1 minute, "Annual" = 12 minutes in sandbox
  • Trigger refunds and revocations from Xcode debug menu
  • Test subscription state transitions without waiting for real time
swift
// In XCTest — use SKTestSession to script scenarios
import StoreKitTest

class SubscriptionTests: XCTestCase {
    var session: SKTestSession!

    override func setUp() async throws {
        session = try SKTestSession(configurationFileNamed: "Products")
        session.resetToDefaultState()
        session.disableDialogs = true
        session.timeRate = .monthlyRenewalEveryThirtySeconds
    }

    func testSubscriptionRenews() async throws {
        let store = StoreService()
        // purchase → wait 30s → verify renewal transaction delivered
    }
}

.storekit
文件添加到Xcode项目中,通过Edit Scheme > Run > Options > StoreKit Configuration进行配置。这可以完全绕过App Store Connect。
配置文件的实用功能:
  • 模拟中断的购买流程(需要启用进程内StoreKit测试)
  • 将事务速度设置为"每月"=1分钟,"每年"=12分钟(沙盒环境)
  • 从Xcode调试菜单触发退款和撤销操作
  • 无需等待真实时间即可测试订阅状态转换
swift
// In XCTest — use SKTestSession to script scenarios
import StoreKitTest

class SubscriptionTests: XCTestCase {
    var session: SKTestSession!

    override func setUp() async throws {
        session = try SKTestSession(configurationFileNamed: "Products")
        session.resetToDefaultState()
        session.disableDialogs = true
        session.timeRate = .monthlyRenewalEveryThirtySeconds
    }

    func testSubscriptionRenews() async throws {
        let store = StoreService()
        // purchase → wait 30s → verify renewal transaction delivered
    }
}

Anti-Patterns

反模式

Anti-PatternConsequenceFix
Start
Transaction.updates
in paywall
Miss offline/terminated-app purchasesStart in App init or
@main
.task
Forget
transaction.finish()
Re-delivered on every launch, double grantsAlways finish after delivery
Use
SKPaymentQueue.restoreCompletedTransactions
Deprecated, triggers App Store login alert unnecessarilyUse
AppStore.sync()
Poll
isSubscribed()
on every
onAppear
Rate limiting, perf degradationCache state, invalidate on
Transaction.updates
Trust
.unverified
transactions
Security hole — spoofed purchaseAlways throw/ignore unverified
Consumable delivery after
finish()
Lost delivery if crash between themDeliver first, then finish
Test with production Apple IDReal charges, irreversibleAlways use sandbox account
One product ID for multiple tiersCannot offer upgrade pricing or group logicSeparate product per tier
Not handling
.pending
state
User sees no feedback; assume purchase failedShow "payment pending" UI
Client-side promo offer signaturesRejected by App StoreServer-generated only
Infer subscription active from purchase date + durationClock skew, grace periods, billing retryUse
Transaction.currentEntitlements
Show intro offer without eligibility checkOffer silently fails; user confusedAlways check
isEligibleForIntroOffer

反模式后果修复方案
在付费墙中启动
Transaction.updates
错过离线/应用终止期间的购买在App初始化或
@main
.task
中启动
忘记调用
transaction.finish()
每次启动都会重新交付,导致重复授权交付完成后务必调用finish
使用
SKPaymentQueue.restoreCompletedTransactions
已废弃,会不必要地触发App Store登录弹窗使用
AppStore.sync()
在每次
onAppear
时轮询
isSubscribed()
触发速率限制,导致性能下降缓存状态,在
Transaction.updates
时失效缓存
信任
.unverified
事务
安全漏洞——购买请求可能被伪造始终抛出错误或忽略未验证的事务
调用
finish()
后再交付消耗型产品
若两者之间应用崩溃,会丢失交付记录先交付,再调用finish
使用生产环境Apple ID测试产生真实费用,且无法撤销始终使用沙盒账号
用一个产品ID对应多个层级无法提供升级定价或组逻辑为每个层级设置单独的产品ID
未处理
.pending
状态
用户看不到反馈,误以为购买失败展示"支付处理中"UI
在客户端生成促销优惠签名被App Store拒绝仅允许服务器生成签名
通过购买日期+时长推断订阅是否活跃时钟偏差、宽限期、计费重试等场景会导致错误使用
Transaction.currentEntitlements
未检查资格就展示首月优惠优惠会静默失败,导致用户困惑展示优惠UI前务必检查
isEligibleForIntroOffer

Launch Checklist

发布检查清单

  • Transaction.updates
    loop started at app entry point, not in paywall
  • All transactions finished with
    transaction.finish()
    after delivery
  • Transaction.currentEntitlements
    queried on app launch to restore entitlement state
  • transaction.revocationDate != nil
    check before granting access
  • Introductory offer eligibility verified before displaying offer UI
  • AppStore.sync()
    called from Restore Purchases button
  • .pending
    purchase state handled with visible user feedback
  • Consumable delivery is idempotent (transaction ID deduplication)
  • Sandbox test accounts created in App Store Connect
  • StoreKit Config File added for local automated testing
  • App Store Server Notifications v2 endpoint registered for subscriptions
  • Server-side JWS validation implemented for high-value entitlements
  • Subscription group configured in App Store Connect before testing
  • All product localisations complete — missing localisation silently drops product
  • Transaction.updates
    循环在应用入口点启动,而非付费墙中
  • 所有事务在交付完成后调用
    transaction.finish()
  • 应用启动时查询
    Transaction.currentEntitlements
    以恢复权限状态
  • 授予访问权限前检查
    transaction.revocationDate != nil
  • 展示首月优惠UI前验证用户资格
  • 恢复购买按钮调用
    AppStore.sync()
  • .pending
    购买状态已处理并提供可见的用户反馈
  • 消耗型产品交付具备幂等性(事务ID去重)
  • 在App Store Connect中创建了沙盒测试账号
  • 添加了StoreKit配置文件用于本地自动化测试
  • 为订阅注册了App Store Server Notifications v2端点
  • 为高价值权限实现了服务器端JWS验证
  • 测试前已在App Store Connect中配置订阅组
  • 所有产品本地化已完成——缺少本地化会导致产品被静默丢弃