WWDC Quick Look 💓 By SwiftGGTeam
Implement proactive in-app purchase restore

Implement proactive in-app purchase restore

观看原视频

Highlight

Apple 通过 StoreKit 2 和原始 StoreKit 提供了主动式应用内购买恢复机制,让应用在启动时自动检测用户的购买状态(新客户、活跃订阅者、非活跃订阅者),无需用户点击”恢复购买”或输入密码,即可个性化展示界面内容。

核心内容

传统体验的痛点

用户在 App Store 下载了你的应用,打开后面对三个选项:购买、登录、恢复购买。对于已经付费的订阅用户,尤其在新设备上,根本不知道该点哪个。

这个流程增加了摩擦。用户可能误操作再次购买,或者因为找不到已购内容而流失。

主动恢复的核心思路

Apple 的解决方案很简单:应用启动时,利用设备上已有的 StoreKit 数据,主动检查用户的交易记录。根据结果把用户分为三类,分别展示不同界面:

新客户:没有购买记录,展示默认的商品推销界面。

活跃订阅者:有有效交易,直接解锁全部功能,隐藏购买按钮。

非活跃订阅者:过去购买过但已过期或被撤销,展示召回优惠。

这个过程完全自动,用户无需任何操作。

三种核心客户状态

03:28

适用于主动恢复的购买类型有三种:非消耗品(non-consumable)、非续期订阅(non-renewing subscription)和自动续期订阅(auto-renewable subscription)。它们都持久保存在用户的交易历史中。

新客户:已登录 Apple ID,但没有任何当前或过去的应用内购买交易。展示默认的商品推销界面,如 Ocean Journal 应用展示月度/年度订阅试用。

已购买且活跃:客户有有效交易,应用必须授予访问权限。界面直接展示已解锁的内容,如海滩实况摄像头,没有任何购买按钮。

非活跃:过去购买过但已过期或被撤销(退款、家庭共享撤销)。对这类用户展示促销优惠或优惠码来召回。对处于计费重试期(billing retry)的用户,提示更新付款信息。

计费重试与宽限期

05:27

当订阅自动续费失败时,Apple 会进入最长 60 天的计费重试期。如果在 App Store Connect 中启用了 Billing Grace Period,用户在重试期间仍能继续使用服务。应用应该在此期间温和地提示用户更新付款信息,而不是中断服务。

详细内容

StoreKit 2 实现三步走

10:07

应用启动后、展示购买按钮前,必须完成三个步骤:

第一步:监听交易更新

11:16

func listenForTransactions() -> Task<Void, Error> {
    return Task.detached {
        for await result in Transaction.updates {
            do {
                let transaction = try self.checkVerified(result)
                await self.updateCustomerProductStatus()
                await transaction.finish()
            } catch {
                print("Transaction failed verification")
            }
        }
    }
}

关键点:

  • Transaction.updates 返回未完成的交易或交易更新
  • 必须先验证交易真实性(checkVerified),再交付内容
  • 交付后必须调用 transaction.finish(),否则交易会持续返回
  • 监听应该在应用启动时立即开始,因为交易可能来自家庭共享、代码兑换、自动续费等

第二步:确定客户产品状态

12:27

func updateCustomerProductStatus() async {
    var purchasedCars: [Product] = []
    var purchasedSubscriptions: [Product] = []
    var purchasedNonRenewableSubscriptions: [Product] = []

    for await result in Transaction.currentEntitlements {
        do {
            let transaction = try checkVerified(result)

            switch transaction.productType {
            case .nonConsumable:
                if let car = cars.first(where: { $0.id == transaction.productID }) {
                    purchasedCars.append(car)
                }
            case .nonRenewable:
                if let nonRenewable = nonRenewables.first(where: { $0.id == transaction.productID }),
                   transaction.productID == "nonRenewing.standard" {
                    let currentDate = Date()
                    let expirationDate = Calendar(identifier: .gregorian).date(
                        byAdding: DateComponents(year: 1),
                        to: transaction.purchaseDate
                    )!
                    if currentDate < expirationDate {
                        purchasedNonRenewableSubscriptions.append(nonRenewable)
                    }
                }
            case .autoRenewable:
                if let subscription = subscriptions.first(where: { $0.id == transaction.productID }) {
                    purchasedSubscriptions.append(subscription)
                }
            default:
                break
            }
        } catch {
            print("Verification failed")
        }
    }

    self.purchasedCars = purchasedCars
    self.purchasedNonRenewableSubscriptions = purchasedNonRenewableSubscriptions
    self.purchasedSubscriptions = purchasedSubscriptions

    subscriptionGroupStatus = try? await subscriptions.first?.subscription?.status.first?.state
}

关键点:

  • Transaction.currentEntitlements 返回用户可能有权访问的所有交易
  • 非续期订阅没有内置过期机制,需要手动计算(如购买日期加一年)
  • 自动续期订阅通过 subscription?.status.first?.state 获取续订状态
  • 续订状态包括:.subscribed.expired.revoked.inBillingRetryPeriod.inGracePeriod

第三步:根据状态个性化界面

13:45

if store.purchasedCars.isEmpty &&
   store.purchasedNonRenewableSubscriptions.isEmpty &&
   store.purchasedSubscriptions.isEmpty {
    // 新客户:展示默认推销界面
    VStack {
        Text("SK Demo App")
            .bold()
            .font(.system(size: 50))
        Text("Head over to the shop to get started!")
        NavigationLink { StoreView() } label: { Text("Shop") }
    }
} else {
    // 有购买记录:展示已购内容
    List {
        Section("My Cars") {
            ForEach(store.purchasedCars) { product in
                NavigationLink { ProductDetailView(product: product) } label: {
                    ListCellView(product: product, purchasingEnabled: false)
                }
            }
        }
    }
}

关键点:

  • 没有活跃交易时展示新客户界面
  • 有活跃交易时展示已购内容列表,禁用购买功能
  • 对非活跃订阅者(过期、撤销、计费重试)展示不同的提示文案

原始 StoreKit 实现(iOS 7+)

16:06

对于不支持 StoreKit 2 的旧版本,使用原始 StoreKit + verifyReceipt 端点:

public func getReceipt() {
    if let appStoreReceiptURL = Bundle.main.appStoreReceiptURL,
       FileManager.default.fileExists(atPath: appStoreReceiptURL.path) {
        do {
            let receiptData = try Data(contentsOf: appStoreReceiptURL, options: .alwaysMapped)
            let receiptString = receiptData.base64EncodedString(options: [])
            // 发送到服务器验证
            sendToServer(receiptString)
        } catch {
            print("Couldn't read receipt data: \(error)")
        }
    }
}

关键点:

  • Bundle.main.appStoreReceiptURL 获取应用收据
  • 收据发送到服务器,服务器调用 Apple 的 verifyReceipt 端点验证
  • Sandbox 和 TestFlight 中,收据只在完成购买或恢复后才存在
  • 如果没有收据,按新客户处理

服务端状态同步

05:17

每个交易有唯一的 originalTransactionID,在用户的 Apple ID 和商店中持久存在。建议在你的服务器上用这个 ID 关联用户账户(匿名账户或用户创建的账户均可)。

配合 App Store Server Notifications Version 2,服务器可以实时获知交易状态变化:退款、撤销、续订、计费重试、过期等。Version 2 支持 28 种不同的事件类型和子类型。

最佳实践总结

19:08

  • 保留”恢复购买”按钮作为后备方案,供用户切换 Apple ID 或排查问题时使用
  • 使用 CloudKit 安全同步购买状态数据到用户的多台设备
  • 支持多个产品或订阅组时,分别判断每个产品的客户状态
  • 考虑跨平台购买(如 Web 端)对客户状态的影响
  • 用 Sandbox、TestFlight 和 Xcode StoreKit Testing 全面测试

核心启发

  • 零摩擦订阅体验:健身或学习类应用在启动时自动检测用户是否有活跃订阅。有则直接解锁全部课程,无则展示试用优惠。入口 API:Transaction.currentEntitlements + Product.SubscriptionInfo.RenewalState

  • 流失用户召回系统:音乐或视频应用识别到用户订阅已过期,启动时直接展示专属召回优惠(如首月半价)。入口 API:检查 subscriptionGroupStatus == .expired,然后调用 Product.subscription?.subscriptionOffer 展示促销优惠。

  • 家庭共享自动适配:儿童教育应用检测到通过家庭共享获得访问权限的新设备,自动解锁全部内容,无需家长再次购买。入口 API:Transaction.updates 监听家庭共享授权变化,checkVerified 验证交易后自动交付。

  • 计费问题温和提醒:SaaS 工具类应用识别到用户处于计费重试期,在界面顶部展示非侵入式横幅提示更新付款信息,同时保持服务可用。入口 API:subscriptionGroupStatus == .inBillingRetryPeriod,引导用户到 https://apps.apple.com/account/billing

  • 跨设备状态同步:笔记或文档应用在 iPhone 购买专业版后,iPad 首次打开时自动识别并解锁高级功能。入口 API:StoreKit 2 交易自动同步到同一 Apple ID 的所有设备,Transaction.currentEntitlements 在任何设备上返回一致结果。

关联 Session

评论

GitHub Issues · utterances