阅读视图

发现新文章,点击刷新页面。

Apple StoreKit 2 开发指南

目录

  1. StoreKit 2 核心概念与优势
  2. 基础准备:产品类型与配置
  3. 核心实战 I:获取商品与购买
  4. 核心实战 II:交易验证与监听
  5. 订阅管理:状态、续期与退款
  6. 深度讲解:恢复购买 (Restore Purchases)
  7. 营销功能:折扣与优惠 (Offers)
  8. 测试指南:沙盒 (Sandbox) 与 TestFlight
  9. 最佳实践与常见坑点
  10. 总结

1. StoreKit 2 核心概念与优势

在 StoreKit 2 之前,我们进行内购开发充满了痛苦:复杂的收据验证、晦涩的 API、漏单等... StoreKit 2 利用 Swift 的现代特性(Concurrency)重构了整个框架。

核心优势

StoreKit 2 是 Apple 在 iOS 15+ / macOS 12.0+ 引入的全新内购框架,相比于旧版 StoreKit 具有以下优势:

  • 基于 Swift 并发:使用 async/await 替代回调地狱。
  • 自动交易验证:无需手动解析复杂的 Receipt 文件,系统自动处理 JWS(JSON Web Signature)验证。
  • 交易历史管理:直接通过 API 获取完整的用户购买历史,无需维护复杂的本地数据库。
  • 状态同步:跨设备同步更加顺滑,用户换个手机登录,权益自动同步。

核心概念

概念 说明
Product 商品对象,包含价格、名称、描述等信息
Transaction 交易记录,每次购买产生一个 Transaction
PurchaseResult 购买结果,包含成功、待处理、用户取消等状态
VerificationResult 验证结果,确保交易来自 Apple 服务器
Product.SubscriptionInfo 订阅信息,包含订阅组、续期信息等

流程图解

flowchart LR
    A["App 启动"] --> B["监听交易更新<br/>Transaction Updates"]
    C["用户点击购买"] --> D["获取商品<br/>Products"]
    D --> E["发起购买<br/>Purchase"]
    E --> F{支付结果}
    F -- 成功 --> G["验证交易<br/>Verify"]
    G -- 通过 --> H["发放权益<br/>Unlock Content"]
    H --> I["结束交易<br/>Finish Transaction"]
    F -- 失败/取消 --> J["处理错误 UI"]

    %% 样式定义
    classDef start fill:#E3F2FD,stroke:#2196F3,stroke-width:2px,color:#0D47A1;
    classDef action fill:#FFFFFF,stroke:#90A4AE,stroke-width:2px,color:#37474F;
    classDef decision fill:#FFF8E1,stroke:#FFC107,stroke-width:2px,color:#FF6F00;
    classDef endState fill:#E8F5E9,stroke:#4CAF50,stroke-width:2px,color:#1B5E20;
    classDef error fill:#FFEBEE,stroke:#F44336,stroke-width:2px,color:#B71C1C;

    %% 样式应用
    class A start;
    class B,C,D,E,H action;
    class F decision;
    class I endState;
    class J error;

2. 基础准备:产品类型与配置

在编写代码前,我们首先需要了解 Apple 定义的四种商品类型:

类型 英文名 特点 典型场景
消耗型 Consumable 可重复购买,购买后即消耗 游戏金币、道具
非消耗型 Non-Consumable 一次购买,永久拥有,支持恢复购买 解锁完整版、移除广告、终身会员
自动续期订阅 Auto-Renewing Subscription 按周期扣费,自动续订 视频会员、SaaS 服务
非续期订阅 Non-Renewing Subscription 有效期固定,不自动续费 赛季通行证

环境配置

你可能以为必须先去 App Store Connect 创建商品才能写代码,其实无需这么麻烦,Xcode 提供了一个本地配置文件 (.storekit),让你在没有开发者账号、没联网的情况下也能开发。 操作步骤:

  1. Xcode -> File -> New -> File from Template... -> 搜索 StoreKit Configuration File (或者用快捷键 Command + N).
  2. 不要勾选 "Sync this file with an app in App Store Connect" (除非你已经在 App Store Connect 配置好了商品信息)。
  3. 建好后,在 Xcode 底部点 + 按钮,配置你的商品信息。
  4. 关键一步:点击 Xcode 顶部菜单 Product -> Scheme -> Edit Scheme -> Run -> Options -> StoreKit Configuration,选择你刚才创建的文件。

💡 老鸟经验:建议使用这个本地配置!它不仅能模拟购买成功,还能模拟扣费失败、退款、订阅过期等真实环境很难复现的场景。


3. 核心实战 I:获取商品与购买

我们将创建一个 StoreKitManager 类来管理所有逻辑。

3.1 获取商品信息

import StoreKit

// 定义你的商品 ID 列表
enum ProductID: String, CaseIterable {
    case proMonthly = "com.myapp.pro.monthly" // 订阅
    case removeAds = "com.myapp.remove.ads"   // 非消耗型
    case coins100 = "com.myapp.coins.100"     // 消耗型
}

@MainActor
class StoreKitManager: ObservableObject {
    @Published var products: [Product] = []
    @Published var purchasedProductIDs = Set<String>() // 已买过的 ID (非消耗/订阅)

    // 获取商品列表
    func fetchProducts() async {
        do {
            // 将 String 转换为 Set<String>
            let productIds = Set(ProductID.allCases.map { $0.rawValue })
            // 异步请求商品详情
            let fetchedProducts = try await Product.products(for: productIds)
            // 按价格排序(可选, 看实际需求)
            self.products = fetchedProducts.sorted(by: { $0.price < $1.price })

            // 加载完商品后,立即检查用户当前的购买状态
            await updateCustomerProductStatus()
        } catch {
            print("获取商品失败: \(error)")
        }
    }
}

3.2 商品信息详解

此方法主要方便调试,打印商品信息。

func displayProductInfo(_ product: Product) {
    print("━━━━━━━━━━━━━━━━━━━━━━")
    print("商品 ID: \(product.id)")
    print("名称: \(product.displayName)")
    print("描述: \(product.description)")
    print("价格: \(product.displayPrice)")  // 已格式化的价格字符串
    print("价格数值: \(product.price)")     // Decimal 类型
    print("货币代码: \(product.priceFormatStyle.currencyCode)")
    print("类型: \(product.type)")

    // 订阅专属信息
    if let subscription = product.subscription {
        print("━━━ 订阅信息 ━━━")
        print("订阅组 ID: \(subscription.subscriptionGroupID)")
        print("订阅周期: \(subscription.subscriptionPeriod)")

        // 订阅周期详解
        switch subscription.subscriptionPeriod.unit {
        case .day:
            print("周期单位: \(subscription.subscriptionPeriod.value) 天")
        case .week:
            print("周期单位: \(subscription.subscriptionPeriod.value) 周")
        case .month:
            print("周期单位: \(subscription.subscriptionPeriod.value) 月")
        case .year:
            print("周期单位: \(subscription.subscriptionPeriod.value) 年")
        @unknown default:
            break
        }

        // 介绍性优惠(新用户优惠)
        if let introOffer = subscription.introductoryOffer {
            print("新用户优惠: \(introOffer.displayPrice)")
            print("优惠类型: \(introOffer.paymentMode)")
        }
    }

    print("━━━━━━━━━━━━━━━━━━━━━━")
}

3.2 发起购买流程

StoreKit 2 的购买结果是一个枚举:success, userCancelled, pending

extension StoreKitManager {
    // 购买指定商品
    func purchase(_ product: Product) async throws {
        // 1. 发起购买请求
        let result = try await product.purchase()

        // 2. 处理结果
        switch result {
        case .success(let verification):
            // 购买成功,需要验证签名
            try await handlePurchaseVerification(verification)

        case .userCancelled:
            // 用户点击了取消
            print("User cancelled the purchase")

        case .pending:
            // 交易挂起(例如家长控制需要审批)
            print("Transaction pending")

        @unknown default:
            break
        }
    }

    // 验证与权益发放
    private func handlePurchaseVerification(_ verification: VerificationResult<Transaction>) async throws {
        switch verification {
        case .unverified(let transaction, let error):
            // 签名验证失败,不要发放权益
            print("Verification failed: \(error)")
            // 建议:结束交易,但不发货
            // 如果不 finish,这笔脏数据会每次启动 App 都发过来,卡在队列里
            await transaction.finish()

        case .verified(let transaction):
            // 验证通过
            print("Purchase verified: \(transaction.productID)")

            // 3. 发放权益(更新本地状态)
            await updateUserEntitlements(transaction)

            // 4. 重要:通知 App Store 交易已完成
            await transaction.finish()
        }
    }
}

4. 核心实战 II:交易验证与监听

StoreKit 2 有两个关键的数据源:

  1. Transaction.updates:监听实时的交易流(购买发生时、续订成功时、退款时)。
  2. Transaction.currentEntitlements:查询用户当前拥有的权益(用于恢复购买)。

4.1 监听交易更新 (Transaction Updates)

最佳实践:必须在 App 启动时立即开始监听,以处理应用在后台或未运行时发生的交易(如订阅自动续期)。

extension StoreKitManager {
    // 启动监听任务
    func listenForTransactions() -> Task<Void, Error> {
        return Task.detached {
            // 遍历异步序列
            for await result in Transaction.updates {
                do {
                    // 收到新交易(续费、购买、恢复)
                    // 这里复用之前的验证逻辑
                    try await self.handlePurchaseVerification(result)
                } catch {
                    print("Transaction update handling failed")
                }
            }
        }
    }
}

确保它随 App 启动而运行:

// 在 App 入口处调用
@main
struct MyApp: App {
    let storeKitManager = StoreKitManager()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .task {
                    // 开启监听
                    await storeKitManager.listenForTransactions()
                }
        }
    }
}

4.2 检查当前权益 (Entitlements)

如何判断用户是不是会员呢? StoreKit 2,你不需要自己存本地数据库,直接调用 Transaction.currentEntitlements 来查询,它只返回当前有效的权益(过期的、退款的会自动过滤掉)。

extension StoreKitManager {
    // 更新用户权益状态
    func updateCustomerProductStatus() async {
        var purchasedIds: [String] = []

        // 遍历当前有效的权益(已自动过滤掉过期订阅、被撤销的交易)
        for await result in Transaction.currentEntitlements {
            if case .verified(let transaction) = result {
                // 检查是否被撤销(退款)
                if transaction.revocationDate == nil {
                    purchasedIds.append(transaction.productID)
                }
            }
        }

        // 更新 UI 状态
        // self.isPro = purchasedIds.contains(ProductID.proMonthly.rawValue)
        print("User has active entitlements: \(purchasedIds)")
    }
}

5. 订阅管理:状态、续期与退款

订阅比一次性购买复杂,因为需要处理过期、宽限期等状态。

5.1 获取订阅详细

extension StoreKitManager {
    func checkSubscriptionStatus() async {
        // 假设我们只关心 proMonthly 这个组的订阅状态
        guard let product = products.first(where: { $0.id == ProductID.proMonthly.rawValue }) else { return }

        guard let subscriptionInfo = product.subscription else { return }

        do {
            // 获取该订阅组的状态
            let statuses = try await subscriptionInfo.status

            for status in statuses {
                switch status.state {
                case .subscribed:
                    print("用户处于订阅期")
                case .expired:
                    print("订阅已过期")
                case .inGracePeriod:
                    print("处于宽限期(扣费失败但Apple暂未关停),应视为已订阅")
                case .revoked:
                    print("订阅被撤销(退款)")
                case .inBillingRetryPeriod:
                    print("扣费重试中,通常应暂停服务")
                default:
                    break
                }

                // 获取续订信息
                if let renewalInfo = try? verify(status.renewalInfo) {
                    print("自动续订状态: \(renewalInfo.willAutoRenew)")
                    print("自动续订时间: \(renewalInfo.autoRenewalDate)")
                }
            }
        } catch {
            print("Error checking subscription status: \(error)")
        }
    }

    // 辅助泛型方法:解包 VerificationResult
    func verify<T>(_ result: VerificationResult<T>) throws -> T {
        switch result {
        case .unverified(_, let error):
            throw error
        case .verified(let safe):
            // ✅ 验证通过,返回解包后的数据
            return safe
        }
    }
}

5.2 识别退款 (Refunds)

Transaction.updates 收到更新,或遍历 currentEntitlements 时:

  1. 检查 transaction.revocationDate 是否不为 nil。
  2. 检查 transaction.revocationReason
if let date = transaction.revocationDate {
    print("该交易已于 \(date) 被撤销/退款")
    // 移除对应的权益
    removeEntitlement(for: transaction.productID)
}

6. 恢复购买 (Restore Purchases)

恢复购买旨在帮助用户在换新手机或重装 App 后,找回之前购买过的非消耗型商品订阅。 而且苹果审核要求必须有“恢复购买”按钮。

概念与误区

  • SK1 vs SK2: 在旧版 SK1 中,必须调用 restoreCompletedTransactions 触发系统弹窗输入密码。
  • SK2 的机制: Transaction.currentEntitlements 已经包含了用户所有的有效权益。通常情况下,应用启动时刷新这个属性,就等同于“静默恢复”。
  • AppStore.sync(): 这是 StoreKit 2 的“显式恢复”接口。只有当用户在 UI 上点击“恢复购买”按钮时,或者你确信数据未同步时,才调用它。它可能会强制弹出 Apple ID 登录框。

示例代码

extension StoreKitManager {
    // 手动恢复购买 (对应 UI 上的 Restore 按钮)
    func restorePurchases() async {
        do {
            // 1. 强制同步 App Store 交易记录
            // 这可能会通过 FaceID/TouchID 验证用户身份
            try await AppStore.sync()

            // 2. 同步完成后,重新检查权益
            await updateCustomerProductStatus()

            // 3. UI 提示
            print("Restore completed successfully")
        } catch {
            print("Restore failed: \(error)")
        }
    }
}

最佳实践

  1. 自动恢复: App 启动时调用 updateCustomerProductStatus()(遍历 currentEntitlements),不要弹窗,静默让老用户获取权益。
  2. 手动恢复: 在设置页提供 "Restore Purchases" 按钮,点击后调用 restorePurchases()
  3. UI 提示: 恢复成功后,若发现用户确实有购买记录,弹窗提示“已成功恢复高级版权益”;若没有记录,提示“未发现可恢复的购买记录”。
  4. 多设备同步: StoreKit 2 自动处理。只要登录同一个 Apple ID,currentEntitlements 会包含所有设备上的购买。

7. 营销功能:折扣与优惠 (Offers)

想给新用户“首月免费”?或者给老用户“回归半价”? StoreKit 2 支持显示推介促销(Introductory Offers)和促销代码(Offer Codes)。

7.1 优惠类型

  • 首次优惠 (Introductory Offer): 通常是针对新订阅用户的特别折扣(如:免费试用 7 天,首月半价)。
  • 促销优惠 (Promotional Offer): 一般是针对现有或回归用户的限时优惠活动,例如续订折扣、节日促销等。
  • 优惠码 (Offer Codes): 一种需要用户输入兑换码的促销方式,可针对新用户、回流用户或特定人群。

7.2 判断是否展示首购优惠

StoreKit 2 可以直接判断当前用户是否符合推介优惠(比如是否已经用过免费试用)。你不需要手写复杂的逻辑。

// 检查是否有优惠
func checkIntroOffer(for product: Product) async {
    if let subscription = product.subscription,
        let introOffer = subscription.introductoryOffer {

        // 检查用户是否有资格享受这个优惠
        // StoreKit 2 会自动根据用户历史判断 isEligible
        let isEligible = await subscription.isEligibleForIntroOffer

        if isEligible {
            if introOffer.paymentMode == .freeTrial {
                print("免费试用 \(introOffer.period.value) \(introOffer.period.unit.localizedDescription)")
            } else {
                print("首月仅需: \(introOffer.price)")
            }
        } else {
            print("原价: \(product.price)")
        }
    }
}

7.3 购买带优惠的商品

对于 首次优惠(Intro Offer),直接调用 product.purchase() 即可,系统会自动应用。
对于 促销优惠(Promotional Offer),需要在购买参数中加入签名(需要服务器生成签名,较复杂,这里不展开介绍)。 如果是 优惠码 (Offer Codes),用户通常在 App Store 系统级界面输入。这里提供一个方法,可以手动弹出兑换码输入框。

// 弹出系统兑换码输入框
func presentCodeRedemptionSheet() {
    if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene {
        Task {
            try? await AppStore.presentOfferCodeRedeemSheet(in: windowScene)
        }
    }
}

8. 测试指南:沙盒 (Sandbox) 与 TestFlight

8.1 沙盒测试流程

  1. 创建账号: 登录 App Store Connect -> 用户和访问 -> 沙盒 -> 新增测试员。
    • 注意:不要在 iOS 设置中登录此账号!
  2. 登录: 在 App 内点击购买时,系统弹窗要求登录,此时输入沙盒账号。
  3. 管理订阅: iOS 设置 -> App Store -> 沙盒账户 -> 管理。

沙盒环境的时间过得很快:

  • 1 个月 = 5 分钟
  • 1 年 = 1 小时
  • 注意: 订阅会自动续期 5-6 次,然后自动取消。这是为了测试完整的生命周期。

8.2 测试场景 Checklist

  • 新购: 首次购买流程是否顺畅。
  • 续期: 保持 App 打开,观察 Transaction.updates 是否收到续订通知。
  • 过期: 等待沙盒订阅自动过期,检查 App 权益是否收回。
  • 中断购买: 点击购买后,在支付界面取消,App 是否处理了 .userCancelled
  • 退款: 在沙盒设置中找不到退款?需要去 Xcode -> Debug -> StoreKit -> Manage Transactions (如果是本地配置) 或通过 App Store Connect 模拟。

8.3 调试技巧

在 Xcode 中使用 .storekit 配置文件时:

  • Debug -> StoreKit -> Manage Transactions: 可以看到所有本地交易。
  • 模拟退款: 选中交易,右键点击 "Refund Transaction"。
  • 模拟 Ask to Buy: 开启 "Enable Ask to Buy" 模拟家长审批流程。

9. 最佳实践与常见坑点

常见坑 (Pitfalls)

  1. 验证失败: 遇到 VerificationResult.unverified 怎么办?
    • 原因: 可能是越狱设备、中间人攻击或者 Xcode 本地配置证书不匹配。
    • 处理: 绝对不要解锁权益。提示用户“验证失败,请重试”。
  2. App Store Server Notifications:
    • 虽然 StoreKit 2 客户端很强,但为了数据准确性(特别是退款、续费失败),建议后端对接 Server Notifications V2。
  3. 漏单:
    • 如果 App 闪退,transaction.finish() 未调用,下次启动监听 updates 时会再次收到该交易,确保逻辑幂等(重复处理同一笔交易不会出错)。

错误处理最佳实践

enum StoreError: Error {
    case failedVerification
    case userCancelled
    case pending
    case unknown
}

// 友好的错误提示
func errorMessage(for error: Error) -> String {
    if let storeError = error as? StoreKitError {
        switch storeError {
        case .userCancelled: return "您取消了购买"
        case .networkError: return "网络连接失败,请检查网络"
        default: return "购买发生未知错误,请稍后重试"
        }
    }
    return error.localizedDescription
}

发布前 Checklist

发布前请对照这张清单:

  1. App 启动监听了吗? 确保 listenForTransactions 在最早的时机运行。
  2. Finish 所有的交易了吗? 不管成功还是失败(验证不过),都要调用 .finish(),否则队列会堵死。
  3. 是否处理了 .pending 状态(家长控制)?
  4. “恢复购买”按钮是否能正常找回权益?
  5. 是否正确处理了订阅过期和退款?
  6. 是否在 TestFlight 环境下验证过真实服务器的商品?
  7. 不要自己存 Bool 值。 尽量每次启动 App 时通过 Transaction.currentEntitlements 动态计算用户是不是 VIP。本地存个 isPro = true 很容易因为卸载重装或跨设备导致数据不一致。
  8. UI 交互。 购买过程中给个 Loading 转圈圈,不要让用户连续点击或因为网络环境以为卡住了。

10. 总结

StoreKit 2 大大降低了内购开发的门槛。核心记住三点:

  1. 监听: 全局监听 Transaction.updates。
  2. 同步: 使用 Transaction.currentEntitlements 获取当前权益。
  3. 结束: 处理完必须调用 transaction.finish()。

最后,附上一个较为完整的 Demo,地址:StoreKitDemo

❌