Apple StoreKit 2 开发指南
目录
- StoreKit 2 核心概念与优势
- 基础准备:产品类型与配置
- 核心实战 I:获取商品与购买
- 核心实战 II:交易验证与监听
- 订阅管理:状态、续期与退款
- 深度讲解:恢复购买 (Restore Purchases)
- 营销功能:折扣与优惠 (Offers)
- 测试指南:沙盒 (Sandbox) 与 TestFlight
- 最佳实践与常见坑点
- 总结
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),让你在没有开发者账号、没联网的情况下也能开发。 操作步骤:
- Xcode -> File -> New -> File from Template... -> 搜索 StoreKit Configuration File (或者用快捷键
Command + N). - 不要勾选 "Sync this file with an app in App Store Connect" (除非你已经在 App Store Connect 配置好了商品信息)。
- 建好后,在 Xcode 底部点
+按钮,配置你的商品信息。 - 关键一步:点击 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 有两个关键的数据源:
- Transaction.updates:监听实时的交易流(购买发生时、续订成功时、退款时)。
- 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 时:
- 检查
transaction.revocationDate是否不为 nil。 - 检查
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)")
}
}
}
最佳实践
-
自动恢复: App 启动时调用
updateCustomerProductStatus()(遍历currentEntitlements),不要弹窗,静默让老用户获取权益。 -
手动恢复: 在设置页提供 "Restore Purchases" 按钮,点击后调用
restorePurchases()。 - UI 提示: 恢复成功后,若发现用户确实有购买记录,弹窗提示“已成功恢复高级版权益”;若没有记录,提示“未发现可恢复的购买记录”。
-
多设备同步: 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 沙盒测试流程
-
创建账号: 登录 App Store Connect -> 用户和访问 -> 沙盒 -> 新增测试员。
- 注意:不要在 iOS 设置中登录此账号!
- 登录: 在 App 内点击购买时,系统弹窗要求登录,此时输入沙盒账号。
- 管理订阅: 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)
-
验证失败: 遇到
VerificationResult.unverified怎么办?- 原因: 可能是越狱设备、中间人攻击或者 Xcode 本地配置证书不匹配。
- 处理: 绝对不要解锁权益。提示用户“验证失败,请重试”。
-
App Store Server Notifications:
- 虽然 StoreKit 2 客户端很强,但为了数据准确性(特别是退款、续费失败),建议后端对接 Server Notifications V2。
-
漏单:
- 如果 App 闪退,
transaction.finish()未调用,下次启动监听updates时会再次收到该交易,确保逻辑幂等(重复处理同一笔交易不会出错)。
- 如果 App 闪退,
错误处理最佳实践
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
发布前请对照这张清单:
-
App 启动监听了吗? 确保
listenForTransactions在最早的时机运行。 -
Finish 所有的交易了吗?不管成功还是失败(验证不过),都要调用.finish(),否则队列会堵死。 - 是否处理了
.pending状态(家长控制)? - “恢复购买”按钮是否能正常找回权益?
- 是否正确处理了订阅过期和退款?
- 是否在 TestFlight 环境下验证过真实服务器的商品?
- 不要自己存 Bool 值。 尽量每次启动 App 时通过
Transaction.currentEntitlements动态计算用户是不是 VIP。本地存个 isPro = true 很容易因为卸载重装或跨设备导致数据不一致。 - UI 交互。 购买过程中给个 Loading 转圈圈,不要让用户连续点击或因为网络环境以为卡住了。
10. 总结
StoreKit 2 大大降低了内购开发的门槛。核心记住三点:
- 监听: 全局监听 Transaction.updates。
- 同步: 使用 Transaction.currentEntitlements 获取当前权益。
- 结束: 处理完必须调用 transaction.finish()。
最后,附上一个较为完整的 Demo,地址:StoreKitDemo