Apple StoreKit 2 开发指南

目录

  1. [StoreKit 2 核心概念与优势](#StoreKit 2 核心概念与优势 "#1-storekit-2-%E6%A0%B8%E5%BF%83%E6%A6%82%E5%BF%B5%E4%B8%8E%E4%BC%98%E5%8A%BF")
  2. 基础准备:产品类型与配置
  3. [核心实战 I:获取商品与购买](#核心实战 I:获取商品与购买 "#3-%E6%A0%B8%E5%BF%83%E5%AE%9E%E6%88%98-i%E8%8E%B7%E5%8F%96%E5%95%86%E5%93%81%E4%B8%8E%E8%B4%AD%E4%B9%B0")
  4. [核心实战 II:交易验证与监听](#核心实战 II:交易验证与监听 "#4-%E6%A0%B8%E5%BF%83%E5%AE%9E%E6%88%98-ii%E4%BA%A4%E6%98%93%E9%AA%8C%E8%AF%81%E4%B8%8E%E7%9B%91%E5%90%AC")
  5. 订阅管理:状态、续期与退款
  6. [深度讲解:恢复购买 (Restore Purchases)](#深度讲解:恢复购买 (Restore Purchases) "#6-%E6%B7%B1%E5%BA%A6%E8%AE%B2%E8%A7%A3%E6%81%A2%E5%A4%8D%E8%B4%AD%E4%B9%B0-restore-purchases")
  7. [营销功能:折扣与优惠 (Offers)](#营销功能:折扣与优惠 (Offers) "#7-%E8%90%A5%E9%94%80%E5%8A%9F%E8%83%BD%E6%8A%98%E6%89%A3%E4%B8%8E%E4%BC%98%E6%83%A0-offers")
  8. [测试指南:沙盒 (Sandbox) 与 TestFlight](#测试指南:沙盒 (Sandbox) 与 TestFlight "#8-%E6%B5%8B%E8%AF%95%E6%8C%87%E5%8D%97%E6%B2%99%E7%9B%92-sandbox-%E4%B8%8E-testflight")
  9. 最佳实践与常见坑点

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["监听交易更新
Transaction Updates"] C["用户点击购买"] --> D["获取商品
Products"] D --> E["发起购买
Purchase"] E --> F{支付结果} F -- 成功 --> G["验证交易
Verify"] G -- 通过 --> H["发放权益
Unlock Content"] H --> I["结束交易
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 获取商品信息

swift 复制代码
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] = []

    // 获取商品列表
    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 })
        } catch {
            print("Failed to fetch products: \(error)")
        }
    }
}

3.2 发起购买流程

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

swift 复制代码
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 启动时立即开始监听,以处理应用在后台或未运行时发生的交易(如订阅自动续期)。

swift 复制代码
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 启动而运行:

swift 复制代码
// 在 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 来查询,它只返回当前有效的权益(过期的、退款的会自动过滤掉)。

swift 复制代码
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 获取订阅详细

swift 复制代码
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)")
                }
            }
        } 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
swift 复制代码
if let date = transaction.revocationDate {
    print("该交易已于 \(date) 被撤销/退款")
    // 移除对应的权益
    removeEntitlement(for: transaction.productID)
}

6. 深度讲解:恢复购买 (Restore Purchases)

苹果审核要求必须有"恢复购买"按钮。

概念与误区

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

示例代码

swift 复制代码
extension StoreKitManager {
    // UI 绑定的"恢复购买"按钮动作
    func restorePurchases() async {
        do {
            // 1. 强制同步 App Store 交易记录
            // 这可能会提示用户输入 Apple ID 密码
            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. 多设备同步 : StoreKit 2 自动处理。只要登录同一个 Apple ID,currentEntitlements 会包含所有设备上的购买。

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

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

7.1 判断是否展示首购优惠

SK2 会自动判断用户是否有资格享受优惠。你不需要手写复杂的逻辑。

swift 复制代码
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 {
            print("原价: \(product.price),优惠价: \(introOffer.price)")
        } else {
            print("原价: \(product.price)")
        }
    }
}

7.2 在购买时应用优惠

通常不需要额外代码,product.purchase() 会自动应用用户符合条件的最佳优惠。如果是 Offer Codes(兑换码),用户通常在 App Store 系统级界面输入,或者你可以提供 UI:

swift 复制代码
// 弹出系统兑换码输入框
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 分钟续订一次,重复 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 时会再次收到该交易,确保逻辑幂等(重复处理同一笔交易不会出错)。

错误处理最佳实践

swift 复制代码
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 转圈圈,不要让用户连续点击或因为网络环境以为卡住了。
相关推荐
2501_9151063218 小时前
iOS App 测试工具全景分析,构建从开发调试到线上监控的多阶段工具链体系
android·测试工具·ios·小程序·uni-app·iphone·webview
Digitally1 天前
如何通过蓝牙将联系人从 iPhone 传输到 Android
android·ios·iphone
90后的晨仔1 天前
2025年11月27日年解决隐私清单导致审核总是提示二进制无效的问题
ios
songgeb1 天前
iOS Audio后台模式下能否执行非Audio逻辑
ios·swift
如此风景1 天前
Swift的Extension简单说明
ios
kk哥88991 天前
iOS开发:关于日志框架
网络·ios·cocoa
Haha_bj2 天前
Swift UI 状态管理
ios·app
iOS阿玮2 天前
打个广告,帮忙招一个iOS开发的扛把子~
uni-app·app·apple
2501_916007472 天前
iOS 应用性能测试的工程化流程,构建从指标采集到问题归因的多工具协同测试体系
android·ios·小程序·https·uni-app·iphone·webview