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
- What’s new with in-app purchases — StoreKit、Server API 和 Server Notifications Version 2 的最新更新
- Explore in-app purchase integration and migration — 迁移到最新的 App Store Server API 和 Server Notifications
- Reducing involuntary subscriber churn — 减少非自愿订阅流失的最佳实践
评论
GitHub Issues · utterances