StoreKit — IAP 支付流程
This article is extracted from the chat log with AI. Please identify it with caution.

StoreKit 2 是苹果在 WWDC 2021 (iOS 15+, macOS 12+, watchOS 8+, tvOS 15+) 推出的现代化应用内购买框架。它基于 Swift 的 async/await 语法,大大简化了产品获取、交易处理和凭据验证的流程。

StoreKit 2 核心体系概览 #

StoreKit 2 的工作流程更加简洁和安全:

  1. 配置商品 (App Store Connect): 与之前一样,你需要在 App Store Connect 后台配置你的应用内购买项目,包括订阅组、订阅时长、价格、一次性购买的商品详情等。每个商品都有一个唯一的产品 ID。
  2. 在 App 中获取商品信息 (Product): 你的 App 使用 Product.products(for:) 异步方法从 App Store 请求已配置的商品信息。返回的是 Product 结构体数组。
  3. 用户发起购买 (product.purchase()): 用户选择购买某个 Product 后,你调用其 purchase() 方法发起购买。
  4. 处理交易结果 (PurchaseResult): purchase() 方法返回一个 PurchaseResult,你需要处理不同情况:.success, .userCancelled, .pending
  5. 验证交易 (VerificationResult<Transaction>): 对于成功的购买,PurchaseResult.success 会包含一个 VerificationResult<Transaction>。StoreKit 2 会自动尝试验证交易的 JWS (JSON Web Signature) 签名。你必须检查这个验证结果。
    • JWS (JSON Web Signature): 交易信息以 JWS 格式提供,包含了购买详情并由 App Store签名,可以在设备端进行初步的安全验证。
  6. 解锁内容并完成交易 (transaction.finish()): 验证成功后,向用户交付内容或服务,并调用 transaction.finish() 来从用户的购买队列中移除该交易。非常重要,否则交易会持续出现在 Transaction.updates 中。
  7. 监听未完成的交易和更新 (Transaction.updates): App 启动时及运行期间,需要监听 Transaction.updates 这个异步序列。它可以接收到所有新的、未完成的交易,包括在 App 外发起的购买(如通过 App Store 直接订阅)。
  8. 检查当前授权 (Transaction.currentEntitlements): 通过遍历 Transaction.currentEntitlements 这个异步序列,可以获取用户当前有权访问的所有非消耗型商品和有效订阅的最新已验证交易。这对于恢复购买和在 App 启动时检查用户状态非常有用。
  9. 服务器端逻辑 (推荐):
    • App Store Server API: 用于更强大的凭据验证(尤其是原始的 appTransactiontransaction.jwsRepresentation)、获取用户所有交易历史、管理订阅状态等。
    • App Store Server Notifications V2: 强烈推荐设置服务器接收来自 App Store 的实时通知,例如订阅续订、过期、退款、进入计费重试等事件。这能让你的后端服务与用户的订阅状态保持同步。

StoreKit 2 主要组件和概念 #

  • Product: 代表一个可销售的商品。包含了本地化信息、价格、类型 (.consumable, .nonConsumable, .autoRenewable) 等。
  • Product.products(for: Set<String>): 异步方法,根据一组产品 ID 从 App Store 获取 Product 对象。
  • product.purchase(options: Set<Product.PurchaseOption> = []): 异步方法,发起购买一个 Product。可以传入购买选项,例如促销优惠。
  • PurchaseResult: purchase() 方法的返回类型,是一个枚举,包含 .success(VerificationResult<Transaction>), .userCancelled, .pending
  • Transaction: 代表一个已验证或未验证的交易。包含了产品 ID、购买日期、过期日期(订阅)、JWS 表示 (jwsRepresentation)、appTransaction (App 整体的 JWS 凭据) 等。
  • VerificationResult<SignedType>: 封装了 StoreKit 自动验证 JWS 签名的结果,可以是 .verified(SignedType).unverified(SignedType, VerificationResult<SignedType>.VerificationError)你必须处理 unverified 的情况。
  • Transaction.updates: 一个 AsyncSequence,用于监听新的交易。你的 App 需要持续监听它。
  • Transaction.currentEntitlements: 一个 AsyncSequence,提供用户当前所有有效授权的最新交易(已验证)。
  • AppStore.sync(): 强制与 App Store 同步交易信息。可以用于实现“恢复购买”功能,或者在需要时刷新本地交易状态。
  • transaction.finish(): 标记一个交易已由你的 App 处理完毕(内容已交付)。
  • StoreKitError: StoreKit 2 抛出的特定错误类型。

实现订阅类型 (Auto-Renewable Subscriptions) with StoreKit 2 #

  1. App Store Connect 配置:

    • 创建订阅组,并在组内定义不同的订阅级别/时长(例如,月度、年度会员)。
    • 为每个订阅产品设置唯一 Product ID、价格、试用期(如果适用)等。
    • 配置 App Store Server Notifications V2 的 URL (如果使用服务器)。
  2. 在 App 中实现:

    • 创建 Store 管理类 (例如 StoreManager):

      import StoreKit
      import SwiftUI // 举例,用于 @MainActor 和 @Published
      
      @MainActor
      class StoreManager: ObservableObject {
          @Published private(set) var subscriptionProducts: [Product] = []
          @Published private(set) var purchasedSubscriptionIDs: Set<String> = [] // 存储当前有效的订阅ID
          @Published private(set) var isSubscriptionActive: Bool = false
      
          private var productsLoaded = false
          private var updates: Task<Void, Never>? = nil // 用于监听交易更新
      
          // 用于演示的 Product ID (替换成你自己的)
          private let subscriptionProductIDs = ["com.yourapp.monthly", "com.yourapp.yearly"]
      
          init() {
              updates = observeTransactionUpdates()
          }
      
          deinit {
              updates?.cancel()
          }
      
          // MARK: - Public Methods
      
          func loadProducts() async {
              guard !productsLoaded else { return }
              do {
                  self.subscriptionProducts = try await Product.products(for: subscriptionProductIDs)
                  productsLoaded = true
                  print("Products loaded: \(self.subscriptionProducts.count)")
              } catch {
                  print("Failed to load products: \(error)")
              }
          }
      
          func purchase(_ product: Product) async throws {
              let result = try await product.purchase()
      
              switch result {
              case .success(let verification):
                  print("Purchase successful, verifying transaction...")
                  let transaction = try await self.handlePurchaseSuccess(verificationResult: verification)
                  // 验证通过,更新UI和用户状态
                  await updateSubscriptionStatus()
                  await transaction.finish() // 重要:完成交易
                  print("Transaction for \(transaction.productID) finished.")
              case .userCancelled:
                  print("Purchase cancelled by user.")
              case .pending:
                  print("Purchase is pending (e.g., Ask to Buy).")
              @unknown default:
                  print("Unknown purchase result.")
              }
          }
      
          func restorePurchases() async {
              print("Attempting to restore purchases...")
              do {
                  try await AppStore.sync()
                  await updateSubscriptionStatus() // 刷新订阅状态
                  print("Purchases restored successfully or sync completed.")
              } catch {
                  print("Failed to restore purchases: \(error)")
              }
          }
      
          func manageSubscriptions() async {
              guard let windowScene = await UIApplication.shared.connectedScenes.first as? UIWindowScene else {
                  print("Could not find a valid window scene.")
                  // Fallback: Open the general subscriptions URL if API not available or scene not found
                  if let url = URL(string: "https://apps.apple.com/account/subscriptions") {
                     await UIApplication.shared.open(url)
                  }
                  return
              }
              do {
                  try await AppStore.showManageSubscriptions(in: windowScene)
              } catch {
                  print("Failed to show manage subscriptions: \(error)")
              }
          }
      
          // MARK: - Private Helpers
      
          private func observeTransactionUpdates() -> Task<Void, Never> {
              Task(priority: .background) { [unowned self] in
                  for await verificationResult in Transaction.updates {
                      do {
                          let transaction = try await self.handlePurchaseSuccess(verificationResult: verificationResult)
                          await self.updateSubscriptionStatus()
                          await transaction.finish()
                          print("Transaction update for \(transaction.productID) processed and finished.")
                      } catch {
                          print("Transaction update failed verification or processing: \(error)")
                      }
                  }
              }
          }
      
          private func handlePurchaseSuccess<T>(verificationResult: VerificationResult<T>) async throws -> T where T == Transaction {
              switch verificationResult {
              case .verified(let transaction):
                  // 交易已由 App Store 签名验证通过
                  print("Transaction verified: \(transaction.productID), Expiration: \(transaction.expirationDate ?? Date.distantFuture)")
                  // 如果是服务器验证,可以在这里将 transaction.jwsRepresentation 发送到服务器
                  // await sendToServer(jws: transaction.jwsRepresentation)
                  return transaction
              case .unverified(let unverifiedTransaction, let error):
                  // 交易未通过 App Store 签名验证,这可能意味着凭据被篡改
                  // 不要解锁内容,记录错误
                  print("Transaction unverified: \(unverifiedTransaction.productID), Error: \(error.localizedDescription)")
                  throw error // 或者一个自定义错误
              }
          }
      
          func updateSubscriptionStatus() async {
              var activeSubs: Set<String> = []
              var currentlyActive = false
      
              for await result in Transaction.currentEntitlements {
                  do {
                      let transaction = try await handlePurchaseSuccess(verificationResult: result)
                      if transaction.productType == .autoRenewable {
                          // 检查订阅是否仍然有效
                          // revocationDate 存在表示用户已收到退款
                          // expirationDate 表示订阅到期时间
                          if transaction.revocationDate == nil && (transaction.expirationDate == nil || transaction.expirationDate! > Date()) {
                              activeSubs.insert(transaction.productID)
                              if subscriptionProductIDs.contains(transaction.productID) {
                                  currentlyActive = true
                              }
                          }
                      }
                  } catch {
                      print("Failed to verify current entitlement transaction: \(error)")
                  }
              }
              self.purchasedSubscriptionIDs = activeSubs
              self.isSubscriptionActive = currentlyActive
              print("Updated subscription status. Active: \(self.isSubscriptionActive), IDs: \(self.purchasedSubscriptionIDs)")
          }
      }
      
    • 在你的 App (例如 App struct 或 SceneDelegate) 中初始化和使用 StoreManager:

      // 在你的 App 主入口
      @main
      struct YourApp: App {
          @StateObject var storeManager = StoreManager()
      
          var body: some Scene {
              WindowGroup {
                  ContentView()
                      .environmentObject(storeManager)
                      .task { // iOS 15+ .task modifier
                          await storeManager.loadProducts()
                          await storeManager.updateSubscriptionStatus() // 检查启动时的状态
                      }
              }
          }
      }
      
    • 在你的 View 中展示产品并处理购买:

      struct SubscriptionView: View {
          @EnvironmentObject var storeManager: StoreManager
          @State private var showManageSubscriptions = false
      
          var body: some View {
              VStack {
                  if storeManager.isSubscriptionActive {
                      Text("You are subscribed!")
                      // 显示管理订阅按钮
                      Button("Manage Subscriptions") {
                          Task {
                              await storeManager.manageSubscriptions()
                          }
                      }
                  } else {
                      Text("Choose a Subscription:")
                      ForEach(storeManager.subscriptionProducts) { product in
                          Button {
                              Task {
                                  do {
                                      try await storeManager.purchase(product)
                                  } catch {
                                      print("Purchase failed: \(error)")
                                      // 向用户显示错误
                                  }
                              }
                          } label: {
                              Text("\(product.displayName) - \(product.displayPrice)")
                          }
                          .padding()
                      }
                  }
      
                  Button("Restore Purchases") {
                      Task {
                          await storeManager.restorePurchases()
                      }
                  }
              }
              .onAppear {
                  Task {
                       // 如果产品列表未加载,确保加载
                      if storeManager.subscriptionProducts.isEmpty {
                          await storeManager.loadProducts()
                      }
                      // 始终在视图出现时检查最新状态
                      await storeManager.updateSubscriptionStatus()
                  }
              }
          }
      }
      
    • JWS 验证:

      • StoreKit 2 会自动进行设备端 JWS 签名验证。VerificationResult.verified 表明签名有效。
      • 对于高度敏感的应用,或需要从服务器授予权限的情况,应将 transaction.jwsRepresentationTransaction.appTransaction.jwsRepresentation(包含所有交易的App级凭据)发送到你的服务器。 你的服务器随后可以使用 Apple 的公钥来独立验证 JWS 签名,并解析其中的声明 (claims) 来获取交易详情,然后更新用户账户。
    • 服务器通知 (App Store Server Notifications V2):

      • 这是跟踪订阅生命周期事件(续订、取消、退款、宽限期等)的最可靠方式。
      • 在 App Store Connect 配置你的服务器 HTTPS 端点。
      • App Store 会向此端点发送签名的 JWS 通知。你的服务器需要验证签名并处理通知内容。

实现一次性购买类型 (Non-Consumable Products) with StoreKit 2 #

非消耗型商品购买一次后永久拥有。

  1. App Store Connect 配置:

    • 选择“非消耗型商品”类型。
    • 设置 Product ID、价格、本地化信息。
  2. 在 App 中实现:

    • StoreManager 的修改/扩展:
      • 添加一个 nonConsumableProductIDs 数组。
      • 添加 @Published private(set) var purchasedNonConsumableIDs: Set<String> = []
      • 修改 loadProducts() 以同时加载非消耗型产品。
      • 修改 updateCustomerStatus() (或者这里叫 updateEntitlementsStatus) 以同时检查非消耗型产品的授权。
    // StoreManager 扩展 (或合并)
    extension StoreManager {
        // 假设这是非消耗品的ID
        private var nonConsumableProductIDs: Set<String> { ["com.yourapp.premiumfeature"] }
        @Published private(set) var purchasedNonConsumableIDs: Set<String> = []
    
        // 修改 loadProducts 以包含所有类型
        func loadAllProducts() async {
            guard !productsLoaded else { return } // 可以用一个更通用的标志
            do {
                let allIDs = Set(subscriptionProductIDs).union(nonConsumableProductIDs)
                let allStoreProducts = try await Product.products(for: allIDs)
    
                self.subscriptionProducts = allStoreProducts.filter { $0.type == .autoRenewable }
                // 可以再创建一个 @Published var nonConsumableProducts: [Product]
                // self.nonConsumableProducts = allStoreProducts.filter { $0.type == .nonConsumable }
    
                // 为了简化,这里直接更新所有产品列表
                // self.allDisplayableProducts = allStoreProducts (假设你有这样一个变量)
    
                productsLoaded = true
                print("All products loaded.")
            } catch {
                print("Failed to load products: \(error)")
            }
        }
    
        // 修改 updateSubscriptionStatus 为更通用的 updateEntitlementStatus
        func updateEntitlementStatus() async {
            var activeSubs: Set<String> = []
            var purchasedNonCons: Set<String> = []
            var currentlySubscribed = false
    
            print("Updating entitlement status...")
            for await result in Transaction.currentEntitlements {
                do {
                    let transaction = try await handlePurchaseSuccess(verificationResult: result) // handlePurchaseSuccess 保持通用
    
                    switch transaction.productType {
                    case .autoRenewable:
                        if transaction.revocationDate == nil && (transaction.expirationDate == nil || transaction.expirationDate! > Date()) {
                            activeSubs.insert(transaction.productID)
                            if subscriptionProductIDs.contains(transaction.productID) {
                                 currentlySubscribed = true
                            }
                        }
                    case .nonConsumable:
                        // 非消耗品一旦购买就永久有效,除非被撤销 (revoked)
                        if transaction.revocationDate == nil {
                             purchasedNonCons.insert(transaction.productID)
                        }
                    default:
                        break
                    }
                } catch {
                    print("Failed to verify current entitlement transaction: \(error)")
                }
            }
            self.purchasedSubscriptionIDs = activeSubs
            self.isSubscriptionActive = currentlySubscribed
            self.purchasedNonConsumableIDs = purchasedNonCons
    
            print("Updated entitlements. Active Subs: \(self.isSubscriptionActive), Purchased Non-Consumables: \(self.purchasedNonConsumableIDs)")
        }
    
        // 购买方法保持不变,因为 Product.purchase() 是通用的
        // restorePurchases 方法也保持不变,AppStore.sync() 会同步所有类型的交易
    }
    
    • ContentView 或其他相关视图中:
      • 根据 storeManager.purchasedNonConsumableIDs 来解锁相应功能。
      • restorePurchases() 对于非消耗品同样重要。Transaction.currentEntitlements 会返回已购买的非消耗品交易。
    • 持久化:
      • 对于非消耗品,一旦通过 Transaction.currentEntitlements 或购买流程确认用户拥有该产品,你需要在本地(例如 UserDefaults, Keychain)或服务器记录此状态,以便离线访问或快速检查,而不仅仅依赖每次都查询 currentEntitlements。但 currentEntitlements 仍然是验证的最终真实来源。

通用最佳实践 (StoreKit 2) #

  • 始终 await transaction.finish(): 对于通过 Transaction.updatesproduct.purchase() 成功处理的每个交易,必须调用 finish()
  • 处理 VerificationResult.unverified: 不要信任未经验证的交易。记录它,但不要解锁内容。
  • 监听 Transaction.updates: 这是捕获所有新交易的关键,包括 App 外发生的购买。
  • 使用 Transaction.currentEntitlements: 在 App 启动时和恢复购买后,用它来确定用户当前有权访问的内容。
  • 服务器端验证 (JWS) 仍然是黄金标准: 虽然设备端 JWS 验证很方便,但对于需要权威授权的应用,应将 JWS 发送到你的服务器进行独立验证和业务逻辑处理。
  • App Store Server Notifications V2: 对于订阅,这是保持服务器数据与 App Store 同步的最佳方式。
  • 测试: 大量使用 Sandbox 环境进行测试。测试各种场景:新购买、恢复、订阅续订(沙盒中续订速度加快)、取消、中断购买等。
  • 清晰的 UI/UX: 明确告知用户购买内容、价格、订阅条款。提供易于找到的恢复购买按钮。
  • 错误处理: StoreKit 2 的 async/await 使得错误处理更加直观,使用 do-catch 妥善处理各种 StoreKitError 和其他潜在错误。

通过使用 StoreKit 2 的这些现代化 API,你可以更轻松、更安全地在你的 App 中实现应用内购买功能。记得根据你的具体需求调整 StoreManager 类和 UI 逻辑。

本文共 3678 字,创建于 May 10, 2025
相关标签: Xcode, ByAI, StoreKit, Gemini