不用RevenueCat的StoreKit 2:独立开发者的生产实践指南#
在为MeteoHealth——一款面向天气敏感人群的健康App,采用免费增值模式——做订阅变现时,我最先思考的不是"代码怎么写",而是"App和App Store之间是否真的需要一个中间层"。MeteoHealth的基础功能免费,起价$2.99/月的Pro订阅解锁高级分析和延展预报。面对这类需求,行业默认答案是花半小时接入RevenueCat,然后不再操心细节。我刻意选择了另一条路,在生产环境中用纯StoreKit 2跑了半年。这篇文章不是API的理论梳理,而是这个决定实际带来了什么成本和回报的记录:哪些地方比预期简单,哪些地方需要真正投入注意力,以及在什么情况下我会建议你不要照搬我的选择。
为什么我没有接入RevenueCat#
"RevenueCat在月收入$2,500以下免费"这句话是事实,对很多独立开发者来说这一点就足以定论。但MeteoHealth有三个因素让纯StoreKit 2成为更理性的选择:
- 只有一个平台。 App只做iOS,没有Android,没有Web版。RevenueCat最核心的价值——在App Store和Google Play之上提供统一的抽象层——在没有第二个平台需要抽象时根本无从谈起。
- 只有一个订阅、一个分组。 变现模型很简单:一个免费层加一个Pro层。没有价格矩阵,没有需要单独仪表盘支撑的区域定价实验。
- 想掌控entitlement判断逻辑。 我希望Pro功能的访问检查活在自己的代码里,而不是藏在某个第三方SDK的黑盒中——那个黑盒可能在下一次大版本更新时改变行为。
这里有必要把事实和观点分开。事实是:对于单平台、单订阅的iOS应用,StoreKit 2确实能100%覆盖所需的一切——购买、恢复购买、状态检查、优惠码。观点是:如果MeteoHealth有三档定价、一个区域付费墙实验、并且计划做Android版,我会毫不犹豫选择RevenueCat,原因下文会解释。
架构:Product、Transaction与currentEntitlements#
StoreKit 2订阅逻辑的核心不是UI,也不是App Store Connect,而是三个实体:Product(从商店获取的商品描述)、Transaction(经Apple加密签名的购买凭证),以及Transaction.currentEntitlements(此刻用户当前有效权益的集合,无需手动缓存)。
我把这一切整合进一个@MainActor类,它贯穿App的整个生命周期存活,并在后台监听交易变化:
import StoreKit
@MainActor
final class SubscriptionManager: ObservableObject {
@Published private(set) var products: [Product] = []
@Published private(set) var purchasedProductIDs: Set<String> = []
private let productIDs = ["pro.monthly", "pro.yearly"]
private var updatesTask: Task<Void, Never>?
init() {
updatesTask = observeTransactionUpdates()
Task {
await loadProducts()
await refreshEntitlements()
}
}
deinit {
updatesTask?.cancel()
}
func loadProducts() async {
do {
products = try await Product.products(for: productIDs)
} 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):
let transaction = try checkVerified(verification)
await transaction.finish()
await refreshEntitlements()
case .userCancelled, .pending:
break
@unknown default:
break
}
}
func refreshEntitlements() async {
var active: Set<String> = []
for await result in Transaction.currentEntitlements {
guard let transaction = try? checkVerified(result) else { continue }
if transaction.revocationDate == nil {
active.insert(transaction.productID)
}
}
purchasedProductIDs = active
}
private func observeTransactionUpdates() -> Task<Void, Never> {
Task(priority: .background) { [weak self] in
for await result in Transaction.updates {
guard let transaction = try? self?.checkVerified(result) else { continue }
await transaction.finish()
await self?.refreshEntitlements()
}
}
}
private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
switch result {
case .unverified:
throw StoreError.failedVerification
case .verified(let safe):
return safe
}
}
}
enum StoreError: Error {
case failedVerification
}我曾经踩过的一个坑:Transaction.currentEntitlements是一个异步序列,而不是某个瞬时快照。如果在冷启动后、StoreKit还没来得及和App Store同步完成之前就调用refreshEntitlements(),就可能在极短时间内得到一个空的权益集合,并向已付费用户展示付费墙。解决方式不是依赖启动时的单次调用,而是像上面代码那样,让监听Transaction.updates的Task在App整个生命周期内持续存活。
检查订阅状态:宽限期与billing retry#
自己实现时最常见的错误,是只检查.subscribed状态,而对其他所有状态一律切断访问。这会破坏宽限期(grace period)和billing retry机制——这两个状态是Apple专门设计出来,防止因为一张过期的银行卡而丢失付费用户的。
Product.SubscriptionInfo.Status提供了所需的细粒度信息:
extension SubscriptionManager {
/// Returns true if the user should keep Pro access,
/// including grace period and billing retry — not just `.subscribed`.
func hasActiveProAccess(for group: Product.SubscriptionInfo?) async -> Bool {
guard let statuses = try? await group?.status else { return false }
for status in statuses {
switch status.state {
case .subscribed, .inGracePeriod:
return true
case .inBillingRetryPeriod:
// Apple is still retrying the charge — don't cut access yet.
return true
case .expired, .revoked:
continue
default:
continue
}
}
return false
}
}对MeteoHealth来说,这从来不是纸上谈兵:银行卡过期或重新发卡的用户并不少见,"提示用户支付出了问题"和"立刻关闭Pro分析功能"之间的差异,直接影响留存率。由于这款App没有自己的订阅后端,全部逻辑都跑在设备端,不需要一台服务器去重新验证收据。对于单一订阅、单一档位而言,这是一个诚实的取舍:更少的基础设施,更少的故障点。
SubscriptionStoreView:无需自建UI层的付费墙#
在iOS 17之前,付费墙必须手工搭建:自己的布局、自己的加载状态、自己的购买处理逻辑。SubscriptionStoreView去掉了大部分这类重复劳动——只需传入订阅分组ID,价格展示、本地化、恢复购买、加载状态都交给StoreKit处理:
struct PaywallView: View {
let groupID: String
var body: some View {
SubscriptionStoreView(groupID: groupID) {
VStack(spacing: 12) {
Image(systemName: "chart.line.uptrend.xyaxis")
.font(.largeTitle)
Text("Unlock Pro Forecasts")
.font(.title2.bold())
Text("Advanced analytics and extended forecasts for $2.99/mo")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding()
}
.storeButton(.visible, for: .restorePurchases)
.subscriptionStoreControlStyle(.prominentPicker)
.onInAppPurchaseCompletion { product, result in
if case .success(.success) = result {
// refresh entitlements, dismiss paywall, etc.
}
}
}
}我并没有完全放弃自定义——SubscriptionStoreView被我当作基础骨架,营销内容(图标、标题、Pro功能说明)通过上面的闭包传入。唯一遇到的限制是:档位卡片的布局定制被限定在内置的subscriptionStoreControlStyle集合里,如果想做真正定制化的付费墙(复杂的功能对比表、动画效果),仍然需要基于Product和.task(id:)从零搭建视图。
Win-back offer:把流失的订阅用户找回来#
从iOS 18开始,Apple加入了win-back offer——这是面向订阅已经失效的用户,而不是仍在订阅中的用户的一种优惠。它是一种独立的优惠类型,需要在App Store Connect中针对某个具体订阅单独配置,并且和promotional offer不同,只对曾经的订阅用户开放。
extension SubscriptionManager {
func availableWinBackOffer(for product: Product) async -> Product.SubscriptionOffer? {
guard let status = try? await product.subscription?.status.first,
case .expired = status.state else { return nil }
let eligible = await product.subscription?.eligibleWinBackOffers ?? []
return eligible.first
}
func redeem(_ offer: Product.SubscriptionOffer, for product: Product) async throws {
let result = try await product.purchase(options: [.winBackOffer(offer)])
if case .success(let verification) = result {
let transaction = try checkVerified(verification)
await transaction.finish()
await refreshEntitlements()
}
}
}对MeteoHealth这样的免费增值App来说,这解决了一个具体场景:用户为了出行前查看预报,订阅了一个月的Pro,之后取消了订阅——半年后再打开App,看到一个带折扣的回归优惠,而我完全不用自己编写和维护一套挽回机制。前提条件是:该优惠必须事先在App Store Connect中配置好,否则eligibleWinBackOffers永远只会返回空数组。
在Xcode中测试:不花一分真钱验证全流程#
StoreKit 2里最被低估的部分不是API本身,而是配套的工具链。Xcode中的.storekit配置文件可以在本地描述商品、订阅分组、促销优惠和win-back offer,全程不需要打开App Store Connect,也不产生任何真实付款。
我实际在用的功能包括:
- StoreKit Configuration File ——商品的本地描述;通过Xcode在模拟器或真机上运行时,StoreKit的结构会与这个文件同步。
- Xcode里的Transaction Manager ——可以手动撤销一笔交易,验证App能否正确响应用户退款。
- 模拟支付失败与billing retry ——直接在scheme设置中完成,不用等一张真实的Apple ID银行卡过期,就能测试
.inBillingRetryPeriod的处理逻辑。 - 本地测试win-back offer ——在
.storekit文件里配置好优惠,立刻就能看到它在一个已失效的测试账号上如何展示,不需要真的走完一个订阅周期。
实践结论是:从首次购买到取消、宽限期、再到win-back优惠,整条流程都可以在模拟器里花一天时间跑完,不需要向App Store支付一分真实的钱。
StoreKit 2 vs RevenueCat:什么情况下我仍然会推荐RevenueCat#
这里应该坦诚,而不是把某个"正确答案"包装成放之四海而皆准的建议。纯StoreKit 2适合MeteoHealth,恰恰是因为它变现模型简单、架构上只有iOS一个平台。这不是一条普适的建议。
| 判断标准 | StoreKit 2(原生) | RevenueCat |
|---|---|---|
| 成本 | 始终免费 | 月收入$2,500以下免费,之后按比例抽成 |
| 跨平台(iOS + Android + Web) | 需要为每个平台单独实现 | 统一的API和仪表盘覆盖所有平台 |
| 首次订阅上线所需时间 | 几天(Product、Transaction、entitlements) | 几小时 |
| MRR、流失率、转化率分析 | 需要自己搭建 | 开箱即用的仪表盘 |
| 付费墙A/B测试 | 需要手动实现实验 | 内置实验功能 |
| 服务端同步(webhook) | 手动接入App Store Server Notifications | 托管式webhook |
| 对逻辑和API更新的掌控 | 完全掌控,只受Apple自身更新影响 | 依赖SDK的版本迭代 |
我会推荐RevenueCat的情况是:(1)产品覆盖多个平台,不想重复实现订阅逻辑;(2)有多档定价,并计划比每季度更频繁地实验价格与优惠;(3)团队缺乏StoreKit深度经验,上线速度比精细控制更重要;(4)收入低于RevenueCat免费的$2,500/月门槛——这种情况下自己造轮子不会省钱,只是白白花时间。这些条件MeteoHealth一个都不满足,所以纯StoreKit 2至今仍在生产环境中运行——但一旦出现第二个平台,我会重新评估这个决定。
回过头看,我本该更早接入App Store Server Notifications v2——即使没有自己的后端,一个简单的serverless通知处理器也能补上客户端才能延迟感知到的部分场景(比如银行发起的拒付争议)。这些都不改变本文的结论:对于单档位的iOS应用来说,不加中间层的StoreKit 2是一个站得住脚的架构选择——前提是你愿意一次性学会订阅的各种状态,而不是把这份理解外包给第三方SDK。花在学习Transaction、currentEntitlements和SubscriptionStoreView上的时间是值得的:你会清楚地知道某个用户为什么能看到、或看不到Pro功能——而这份理解不会随着下一次iOS大版本更新而消失。



