storekit by dpearson2699/swift-ios-skills
npx skills add https://github.com/dpearson2699/swift-ios-skills --skill storekit在 iOS 26+ 上使用 StoreKit 2 实现应用内购买、订阅和付费墙。仅使用现代的 Product、Transaction、StoreView 和 SubscriptionStoreView API。切勿使用已弃用的原始 StoreKit(SKProduct、SKPaymentQueue、SKStoreReviewController)。
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
| 类型 | 枚举案例 | 行为 |
|---|---|---|
| 消耗型 | .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)")
}
调用 product.purchase(options:) 并处理所有三种 PurchaseResult 情况。始终验证并完成交易。
func purchase(_ product: Product) async throws {
let result = try await product.purchase(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:
// 请求购买或延迟批准 -- 暂时不要解锁内容
break
@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
}
}
在应用启动时启动。捕获来自其他设备的购买、家庭共享变更、续订、请求购买批准、退款和撤销。
@main
struct MyApp: App {
private var transactionListener: Task<Void, Error>?
init() {
transactionListener = listenForTransactions()
}
var body: some Scene {
WindowGroup { ContentView() }
}
func listenForTransactions() -> Task<Void, Error> {
Task.detached {
for await result in Transaction.updates {
guard case .verified(let transaction) = result else { continue }
await StoreManager.shared.updateEntitlements()
await transaction.finish()
}
}
}
}
使用 Transaction.currentEntitlements 检查非消耗型购买和有效订阅。始终检查 revocationDate。
@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 {
purchased.insert(transaction.productID)
}
}
purchasedProductIDs = purchased
}
}
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(let transaction):
if transaction != nil { PremiumContentView() }
else { PaywallView() }
}
}
.currentEntitlementTask(for: ProductID.premium) { state in
self.state = state
}
}
}
用于订阅付费墙的内置 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(.verified(let transaction)) = result {
await transaction.finish()
}
}
SubscriptionStoreView(groupID: "YOUR_GROUP_ID") {
VStack {
Image(systemName: "crown.fill").font(.system(size: 60)).foregroundStyle(.yellow)
Text("解锁高级版").font(.largeTitle.bold())
Text("访问所有功能").foregroundStyle(.secondary)
}
}
.containerBackground(.blue.gradient, for: .subscriptionStore)
SubscriptionStoreView(groupID: "YOUR_GROUP_ID") {
SubscriptionPeriodGroupSet()
}
.subscriptionStoreControlStyle(.picker)
展示多个产品,包含本地化名称、价格和购买按钮。
StoreView(ids: [ProductID.gems100, ProductID.premium], prefersPromotionalIcon: true)
.productViewStyle(.large)
.storeButton(.visible, for: .restorePurchases)
.onInAppPurchaseCompletion { product, result in
if case .success(.verified(let transaction)) = result {
await transaction.finish()
}
}
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.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 | 付款失败,但在宽限期内仍可继续访问 |
.revoked | Apple 已退款或撤销订阅 |
StoreKit 2 通过 Transaction.currentEntitlements 处理恢复。添加一个恢复按钮或显式调用 AppStore.sync()。
func restorePurchases() async throws {
try await AppStore.sync()
await StoreManager.shared.updateEntitlements()
}
在商店视图上:.storeButton(.visible, for: .restorePurchases)
验证应用安装的合法性。用于业务模型变更或检测被篡改的安装(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
// 针对在订阅模式之前付费的用户进行迁移逻辑
case .unverified:
// 可能被篡改 -- 酌情限制功能
break
}
} catch { /* 无法检索应用交易 */ }
}
// 用于服务器端对账的应用账户令牌
try await product.purchase(options: [.appAccountToken(UUID())])
// 消耗型产品数量
try await product.purchase(options: [.quantity(5)])
// 在沙盒环境中模拟请求购买
try await product.purchase(options: [.simulatesAskToBuyInSandbox(true)])
.onInAppPurchaseStart { product in
return true // 返回 false 以取消
}
.onInAppPurchaseCompletion { product, result in
if case .success(.verified(let transaction)) = result {
await transaction.finish()
}
}
.inAppPurchaseOptions { product in
[.appAccountToken(userAccountToken)]
}
// 错误:没有监听器 -- 会错过续订、退款、请求购买批准
@main struct MyApp: App {
var body: some Scene { WindowGroup { ContentView() } }
}
// 正确:在 App 的 init 中启动监听器(参见上面的 Transaction.updates 部分)
// 错误:从未完成 -- 会永远出现在未完成队列中
let transaction = try checkVerified(verification)
unlockFeature(transaction.productID)
// 正确:在交付内容后始终完成
let transaction = try checkVerified(verification)
unlockFeature(transaction.productID)
await transaction.finish()
// 错误:使用未经验证的交易 -- 安全风险
let transaction = verification.unsafePayloadValue
// 正确:在使用前验证
let transaction = try checkVerified(verification)
// 错误:原始 StoreKit(已弃用)
let request = SKProductsRequest(productIdentifiers: ["com.app.premium"])
SKPaymentQueue.default().add(payment)
SKStoreReviewController.requestReview()
// 正确:StoreKit 2
let products = try await Product.products(for: ["com.app.premium"])
let result = try await product.purchase()
try await AppStore.requestReview(in: windowScene)
// 错误:授予已退款购买内容的访问权限
if case .verified(let transaction) = result {
purchased.insert(transaction.productID)
}
// 正确:跳过已撤销的交易
if case .verified(let transaction) = result, transaction.revocationDate == nil {
purchased.insert(transaction.productID)
}
// 错误:对其他货币和地区不正确
Text("Buy Premium for $4.99")
// 正确:来自 Product 的本地化价格
Text("Buy \(product.displayName) for \(product.displayPrice)")
// 错误:静默丢弃待处理的请求购买
default: break
// 正确:通知用户购买正在等待批准
case .pending:
showPendingApprovalMessage()
// 错误:只检查一次,从不更新
func appDidFinish() { Task { await updateEntitlements() } }
// 正确:在 Transaction.updates 时重新检查,并在应用返回前台时重新检查
// Transaction.updates 监听器处理会话期间的变更。
// 也可以在内容视图上使用 .task { await storeManager.updateEntitlements() }。
// 错误:没有恢复选项 -- 有被 App Store 拒绝的风险
SubscriptionStoreView(groupID: "group_id")
// 正确
SubscriptionStoreView(groupID: "group_id")
.storeButton(.visible, for: .restorePurchases)
// 错误:没有服务条款或隐私政策
SubscriptionStoreView(groupID: "group_id")
// 正确
SubscriptionStoreView(groupID: "group_id")
.subscriptionStorePolicyDestination(url: termsURL, for: .termsOfService)
.subscriptionStorePolicyDestination(url: privacyURL, for: .privacyPolicy)
Transaction.updates 监听器在应用启动时于 App 的 init 中启动transaction.finish().pending 购买结果product.displayPrice 显示价格,从不硬编码SKProduct、SKPaymentQueue)jwsRepresentationSendable 的references/app-review-guidelines.md 了解应用内购买规则(指南 3.1.1)、订阅显示要求和避免被拒的方法。references/storekit-advanced.md 了解订阅控制样式、优惠管理、测试模式和高级订阅处理。每周安装数
386
代码仓库
GitHub 星标数
269
首次出现
Mar 3, 2026
安全审计
安装于
codex381
kimi-cli378
gemini-cli378
amp378
cline378
github-copilot378
Implement in-app purchases, subscriptions, and paywalls using StoreKit 2 on iOS 26+. Use only the modern Product, Transaction, StoreView, and SubscriptionStoreView APIs. Never use the deprecated original StoreKit (SKProduct, SKPaymentQueue, SKStoreReviewController).
| Type | Enum Case | Behavior |
|---|---|---|
| Consumable | .consumable | Used once, can be repurchased (gems, coins) |
| Non-consumable | .nonConsumable | Purchased once permanently (premium unlock) |
| Auto-renewable | .autoRenewable | Recurring billing with automatic renewal |
| Non-renewing | .nonRenewing | Time-limited access without automatic renewal |
Define product IDs as constants. Fetch products with 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)")
}
Call product.purchase(options:) and handle all three PurchaseResult cases. Always verify and finish transactions.
func purchase(_ product: Product) async throws {
let result = try await product.purchase(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 -- do not unlock content yet
break
@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
}
}
Start at app launch. Catches purchases from other devices, Family Sharing changes, renewals, Ask to Buy approvals, refunds, and revocations.
@main
struct MyApp: App {
private var transactionListener: Task<Void, Error>?
init() {
transactionListener = listenForTransactions()
}
var body: some Scene {
WindowGroup { ContentView() }
}
func listenForTransactions() -> Task<Void, Error> {
Task.detached {
for await result in Transaction.updates {
guard case .verified(let transaction) = result else { continue }
await StoreManager.shared.updateEntitlements()
await transaction.finish()
}
}
}
}
Use Transaction.currentEntitlements for non-consumable purchases and active subscriptions. Always check revocationDate.
@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 {
purchased.insert(transaction.productID)
}
}
purchasedProductIDs = purchased
}
}
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(let transaction):
if transaction != nil { PremiumContentView() }
else { PaywallView() }
}
}
.currentEntitlementTask(for: ProductID.premium) { state in
self.state = state
}
}
}
Built-in SwiftUI view for subscription paywalls. Handles product loading, purchase UI, and restore purchases automatically.
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(.verified(let transaction)) = result {
await transaction.finish()
}
}
SubscriptionStoreView(groupID: "YOUR_GROUP_ID") {
VStack {
Image(systemName: "crown.fill").font(.system(size: 60)).foregroundStyle(.yellow)
Text("Unlock Premium").font(.largeTitle.bold())
Text("Access all features").foregroundStyle(.secondary)
}
}
.containerBackground(.blue.gradient, for: .subscriptionStore)
SubscriptionStoreView(groupID: "YOUR_GROUP_ID") {
SubscriptionPeriodGroupSet()
}
.subscriptionStoreControlStyle(.picker)
Merchandises multiple products with localized names, prices, and purchase buttons.
StoreView(ids: [ProductID.gems100, ProductID.premium], prefersPromotionalIcon: true)
.productViewStyle(.large)
.storeButton(.visible, for: .restorePurchases)
.onInAppPurchaseCompletion { product, result in
if case .success(.verified(let transaction)) = result {
await transaction.finish()
}
}
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.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
}
| State | Meaning |
|---|---|
.subscribed | Active subscription |
.expired | Subscription has expired |
.inBillingRetryPeriod | Payment failed, Apple is retrying |
.inGracePeriod | Payment failed but access continues during grace period |
.revoked | Apple refunded or revoked the subscription |
StoreKit 2 handles restoration via Transaction.currentEntitlements. Add a restore button or call AppStore.sync() explicitly.
func restorePurchases() async throws {
try await AppStore.sync()
await StoreManager.shared.updateEntitlements()
}
On store views: .storeButton(.visible, for: .restorePurchases)
Verify the legitimacy of the app installation. Use for business model changes or detecting tampered installations (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)])
.onInAppPurchaseStart { product in
return true // Return false to cancel
}
.onInAppPurchaseCompletion { product, result in
if case .success(.verified(let transaction)) = result {
await transaction.finish()
}
}
.inAppPurchaseOptions { product in
[.appAccountToken(userAccountToken)]
}
// 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)
// WRONG: Never finished -- reappears in unfinished queue forever
let transaction = try checkVerified(verification)
unlockFeature(transaction.productID)
// CORRECT: Always finish after delivering content
let transaction = try checkVerified(verification)
unlockFeature(transaction.productID)
await transaction.finish()
// WRONG: Using unverified transaction -- security risk
let transaction = verification.unsafePayloadValue
// CORRECT: Verify before using
let transaction = try checkVerified(verification)
// WRONG: Original StoreKit (deprecated)
let request = SKProductsRequest(productIdentifiers: ["com.app.premium"])
SKPaymentQueue.default().add(payment)
SKStoreReviewController.requestReview()
// CORRECT: StoreKit 2
let products = try await Product.products(for: ["com.app.premium"])
let result = try await product.purchase()
try await AppStore.requestReview(in: windowScene)
// 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)
}
// 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)")
// WRONG: Silently drops pending Ask to Buy
default: break
// CORRECT: Inform user purchase is awaiting approval
case .pending:
showPendingApprovalMessage()
// 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.
// WRONG: No restore option -- App Store rejection risk
SubscriptionStoreView(groupID: "group_id")
// CORRECT
SubscriptionStoreView(groupID: "group_id")
.storeButton(.visible, for: .restorePurchases)
// 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 listener starts at app launch in App inittransaction.finish() called after content delivery.pending purchase result handled for Ask to Buyproduct.displayPrice, never hardcodedSKProduct, SKPaymentQueue)jwsRepresentation if applicablereferences/app-review-guidelines.md for IAP rules (Guideline 3.1.1), subscription display requirements, and rejection prevention.references/storekit-advanced.md for subscription control styles, offer management, testing patterns, and advanced subscription handling.Weekly Installs
386
Repository
GitHub Stars
269
First Seen
Mar 3, 2026
Security Audits
Gen Agent Trust HubPassSocketPassSnykWarn
Installed on
codex381
kimi-cli378
gemini-cli378
amp378
cline378
github-copilot378
React 组合模式指南:Vercel 组件架构最佳实践,提升代码可维护性
105,000 周安装
Sendable when shared across concurrency boundaries