ios-monetization
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseiOS 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 or would be better handled by a more specific companion skill.
ios-monetization - 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 first, then load only the referenced deep-dive files that are necessary for the task.
SKILL.md - 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
生成的证据
| Category | Artifact | Format | Example |
|---|---|---|---|
| Correctness | StoreKit 2 purchase flow test plan | Markdown doc covering consumable, non-consumable, subscription, and restore flows | |
| Release evidence | App Store subscription configuration record | Markdown doc capturing product IDs, pricing tiers, and intro offers per region | |
| 类别 | 工件 | 格式 | 示例 |
|---|---|---|---|
| 正确性 | StoreKit 2购买流程测试计划 | 涵盖消耗型、非消耗型、订阅及恢复流程的Markdown文档 | |
| 发布证据 | App Store订阅配置记录 | 记录各地区产品ID、定价层级及首月优惠的Markdown文档 | |
References
参考资料
- Use the links and companion skills already referenced in this file when deeper context is needed.
- 当需要更深入的上下文时,使用本文档中已引用的链接和配套技能。
Architecture Principles
架构原则
StoreKit 2 is async/await-native. The entire /delegate callback model is abandoned. Every purchase, verification, and entitlement check is a Swift concurrency operation.
SKPaymentQueueTransaction 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 on next launch. Starting this loop in the paywall means you silently drop those deliveries.
Transaction.updatesswift
// 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。整套/代理回调模型已被弃用。所有购买、验证和权限检查操作均基于Swift并发实现。
SKPaymentQueue事务观察者为必填项。必须在应用入口点启动——早于任何UI渲染。应用终止期间完成的事务会在下次启动时通过交付。若在付费墙中启动此循环,会导致这些交付被静默丢弃。
Transaction.updatesswift
// 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.currentEntitlementsProduct.subscription?.statusswift
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:
| State | Meaning | Action |
|---|---|---|
| Active | Full access |
| Billing failed, grace period active | Full access + soft prompt |
| Grace expired, Apple retrying | Restricted access + hard prompt |
| Lapsed | Paywall |
| Family sharing revoked | Remove access immediately |
Transaction.currentEntitlementsProduct.subscription?.statusswift
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
}需要处理的订阅状态:
| 状态 | 含义 | 操作 |
|---|---|---|
| 已激活 | 提供完整访问权限 |
| 计费失败,宽限期已激活 | 提供完整访问权限 + 软性提示 |
| 宽限期已过,Apple正在重试 | 限制访问权限 + 硬性提示 |
| 已过期 | 展示付费墙 |
| 家庭共享已撤销 | 立即移除访问权限 |
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 + endpoint.
appReceipt/verifyReceiptClient-side (sufficient for most apps):
means Apple's signature checked out locally. Use this.
VerificationResult.verifiedServer-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_EXPIREDREVOKE
Register the endpoint in App Store Connect > App Information > App Store Server Notifications.
StoreKit 2会为每个事务生成JWS(JSON Web Signature)签名。你无需使用旧的base64格式 + 接口。
appReceipt/verifyReceipt客户端验证(适用于大多数应用):
表示Apple的签名已在本地验证通过。直接使用此结果即可。
VerificationResult.verified服务器端验证(高价值权限、防欺诈场景必填):
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_EXPIREDREVOKE
在App Store Connect > 应用信息 > App Store服务器通知中注册端点。
Consumables — Delivery Pattern
消耗型产品 — 交付模式
Consumables are not tracked by . You must persist delivery yourself.
Transaction.currentEntitlementsswift
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.currentEntitlementsswift
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配置 — 关键步骤
- Create IAPs before running on device — Xcode cannot synthesise them
- Subscription Group required before adding Auto-Renewable subscriptions; group name is user-visible in cancellation flow
- All tiers in the same subscription group share one active subscription; Apple handles proration on upgrades automatically
- Localisations on products are required — missing localisation = product not returned by
Product.products(for:) - Tax categories must be set (Software, Newspaper, etc.) — affects storefront availability
- Pricing: set a base territory first, then "Sync" to all territories — do not set each manually
- Sandbox testers created in App Store Connect > Users and Access > Sandbox Testers — use a new Apple ID, not your own
- 在设备上运行前创建IAP——Xcode无法自动生成
- 添加自动续订订阅前需先创建订阅组;组名会在取消流程中对用户可见
- 同一订阅组内的所有层级共享一个活跃订阅;Apple会自动处理升级时的按比例计费
- 产品必须配置本地化——缺少本地化会导致无法返回该产品
Product.products(for:) - 必须设置税务类别(软件、报纸等)——会影响店铺上架范围
- 定价:先设置基准地区,然后"同步"到所有地区——不要手动逐个设置
- 在App Store Connect > 用户与访问 > Sandbox测试员中创建沙盒测试账号——使用新的Apple ID,不要用自己的账号
StoreKit Configuration File (Local Testing)
StoreKit配置文件(本地测试)
Add a file to the Xcode project, configure via Edit Scheme > Run > Options > StoreKit Configuration. This bypasses App Store Connect entirely.
.storekitNon-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
}
}将文件添加到Xcode项目中,通过Edit Scheme > Run > Options > StoreKit Configuration进行配置。这可以完全绕过App Store Connect。
.storekit配置文件的实用功能:
- 模拟中断的购买流程(需要启用进程内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-Pattern | Consequence | Fix |
|---|---|---|
Start | Miss offline/terminated-app purchases | Start in App init or |
Forget | Re-delivered on every launch, double grants | Always finish after delivery |
Use | Deprecated, triggers App Store login alert unnecessarily | Use |
Poll | Rate limiting, perf degradation | Cache state, invalidate on |
Trust | Security hole — spoofed purchase | Always throw/ignore unverified |
Consumable delivery after | Lost delivery if crash between them | Deliver first, then finish |
| Test with production Apple ID | Real charges, irreversible | Always use sandbox account |
| One product ID for multiple tiers | Cannot offer upgrade pricing or group logic | Separate product per tier |
Not handling | User sees no feedback; assume purchase failed | Show "payment pending" UI |
| Client-side promo offer signatures | Rejected by App Store | Server-generated only |
| Infer subscription active from purchase date + duration | Clock skew, grace periods, billing retry | Use |
| Show intro offer without eligibility check | Offer silently fails; user confused | Always check |
| 反模式 | 后果 | 修复方案 |
|---|---|---|
在付费墙中启动 | 错过离线/应用终止期间的购买 | 在App初始化或 |
忘记调用 | 每次启动都会重新交付,导致重复授权 | 交付完成后务必调用finish |
使用 | 已废弃,会不必要地触发App Store登录弹窗 | 使用 |
在每次 | 触发速率限制,导致性能下降 | 缓存状态,在 |
信任 | 安全漏洞——购买请求可能被伪造 | 始终抛出错误或忽略未验证的事务 |
调用 | 若两者之间应用崩溃,会丢失交付记录 | 先交付,再调用finish |
| 使用生产环境Apple ID测试 | 产生真实费用,且无法撤销 | 始终使用沙盒账号 |
| 用一个产品ID对应多个层级 | 无法提供升级定价或组逻辑 | 为每个层级设置单独的产品ID |
未处理 | 用户看不到反馈,误以为购买失败 | 展示"支付处理中"UI |
| 在客户端生成促销优惠签名 | 被App Store拒绝 | 仅允许服务器生成签名 |
| 通过购买日期+时长推断订阅是否活跃 | 时钟偏差、宽限期、计费重试等场景会导致错误 | 使用 |
| 未检查资格就展示首月优惠 | 优惠会静默失败,导致用户困惑 | 展示优惠UI前务必检查 |
Launch Checklist
发布检查清单
- loop started at app entry point, not in paywall
Transaction.updates - All transactions finished with after delivery
transaction.finish() - queried on app launch to restore entitlement state
Transaction.currentEntitlements - check before granting access
transaction.revocationDate != nil - Introductory offer eligibility verified before displaying offer UI
- called from Restore Purchases button
AppStore.sync() - purchase state handled with visible user feedback
.pending - 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中配置订阅组
- 所有产品本地化已完成——缺少本地化会导致产品被静默丢弃