前言
自动续订订阅(Auto-Renewable Subscriptions)是 iOS 应用最常见的变现模式之一,适用于流媒体服务、云存储、会员权益等场景。相比一次性购买,订阅模式能够为开发者提供稳定的现金流,同时也为用户提供持续更新的服务体验。
本文将从零开始,全面讲解自动续订订阅的实现,涵盖 App Store Connect 配置、客户端代码实现、服务端验证、状态管理等核心环节。
自动续订订阅基础概念
1. 订阅类型对比
| 类型 | 特点 | 适用场景 |
|---|---|---|
| 自动续订订阅 | 自动扣费续订,直到用户取消 | 视频会员、音乐服务、云存储 |
| 非续订订阅 | 固定时长,到期不自动续订 | 赛季通行证、限时服务 |
| 消耗型 | 使用后消失,可重复购买 | 游戏金币、虚拟道具 |
| 非消耗型 | 一次购买,永久拥有 | 去广告、功能解锁 |
2. 订阅生命周期
┌─────────────────────────────────────────────────────────────────────┐
│ 订阅生命周期 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 首次订阅 ──► 免费试用期 ──► 付费周期 ──► 自动续订 ──► ... │
│ │ │ │ │ │
│ │ │ │ ├──► 续订成功 ──► 继续 │
│ │ │ │ │ │
│ │ │ │ ├──► 续订失败 ──► 宽限期 │
│ │ │ │ │ │ │
│ │ │ │ │ └──► 计费重试期 │
│ │ │ │ │ │ │
│ │ │ │ │ └──► 过期 │
│ │ │ │ │ │
│ │ │ │ └──► 用户取消 ──► 到期过期 │
│ │ │ │ │
│ └───────────┴────────────┴──► 退款 ──► 立即失效 │
│ │
└─────────────────────────────────────────────────────────────────────┘
3. 关键术语解释
- 订阅组(Subscription Group):同一组内的订阅互斥,用户只能订阅其中一个
- 服务等级(Service Level):组内订阅的优先级,决定升降级行为
- 宽限期(Grace Period):续订失败后,仍保留服务的宽限时间(最长16天)
- 计费重试期(Billing Retry):Apple 尝试重新扣费的时间段(最长60天)
- Original Transaction ID:订阅链的唯一标识,首次购买时生成
App Store Connect 配置
1. 创建订阅组
- 登录 App Store Connect
- 选择您的 App → 订阅 → 订阅组
- 点击 + 创建新的订阅组
订阅组结构示例:
Premium 会员订阅组
├── 年度会员 (com.yourapp.premium.yearly) - Level 1
├── 季度会员 (com.yourapp.premium.quarterly) - Level 2
└── 月度会员 (com.yourapp.premium.monthly) - Level 3
2. 配置订阅产品
对于每个订阅产品,需要配置:
| 配置项 | 说明 | 示例 |
|---|---|---|
| 产品 ID | 唯一标识符 | com.yourapp.premium.monthly |
| 订阅时长 | 1周到1年 | 1个月 |
| 价格 | 选择价格等级 | 等级6(¥18) |
| 推介促销优惠 | 首次订阅优惠 | 首月免费试用 |
| 促销优惠 | 挽留/获客优惠 | 3个月5折 |
| 优惠代码 | 自定义优惠码 | WELCOME2024 |
3. 设置服务器通知(Server-to-Server Notifications)
App Store Connect → 应用 → App 信息 → App Store Server Notifications
配置 V2 通知端点:
生产环境 URL: https://api.yourapp.com/apple/notifications
沙盒环境 URL: https://api-sandbox.yourapp.com/apple/notifications
4. 获取共享密钥
App Store Connect → 用户和访问 → 共享密钥
共享密钥用于验证收据,请妥善保管!
示例:a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6
客户端实现
1. 项目配置
启用 In-App Purchase 能力
Xcode → Project → Targets → Signing & Capabilities → + Capability → In-App Purchase
StoreKit 配置文件(用于本地测试)
- File → New → File → StoreKit Configuration File
- 添加订阅产品配置
- Scheme → Edit Scheme → Run → Options → StoreKit Configuration
2. StoreKit 1 完整实现
import StoreKit
// MARK: - 订阅产品标识符
struct SubscriptionProducts {
static let monthlyID = "com.yourapp.premium.monthly"
static let quarterlyID = "com.yourapp.premium.quarterly"
static let yearlyID = "com.yourapp.premium.yearly"
static let allProductIDs: Set<String> = [
monthlyID,
quarterlyID,
yearlyID
]
}
// MARK: - 订阅管理器
class SubscriptionManager: NSObject, ObservableObject {
// MARK: - 单例
static let shared = SubscriptionManager()
// MARK: - 发布属性
@Published var products: [SKProduct] = []
@Published var purchasedProductIDs: Set<String> = []
@Published var isSubscribed: Bool = false
@Published var isLoading: Bool = false
@Published var errorMessage: String?
// MARK: - 私有属性
private var productsRequest: SKProductsRequest?
private var purchaseCompletionHandler: ((Result<SKPaymentTransaction, Error>) -> Void)?
private var restoreCompletionHandler: ((Result<[SKPaymentTransaction], Error>) -> Void)?
// MARK: - 初始化
private override init() {
super.init()
startObservingPaymentQueue()
}
deinit {
stopObservingPaymentQueue()
}
// MARK: - 支付队列观察
func startObservingPaymentQueue() {
SKPaymentQueue.default().add(self)
}
func stopObservingPaymentQueue() {
SKPaymentQueue.default().remove(self)
}
// MARK: - 请求产品信息
func fetchProducts() {
guard !isLoading else {
return }
isLoading = true
errorMessage = nil
let request = SKProductsRequest(productIdentifiers: SubscriptionProducts.allProductIDs)
request.delegate = self
request.start()
productsRequest = request
print("🛒 开始请求产品信息...")
}
// MARK: - 购买订阅
func purchase(_ product: SKProduct, completion: @escaping (Result<SKPaymentTransaction, Error>) -> Void) {
guard SKPaymentQueue.canMakePayments() else {
completion(.failure(SubscriptionError.paymentsNotAllowed))
return
}
purchaseCompletionHandler = completion
isLoading = true
let payment = SKPayment(product: product)
SKPaymentQueue.default().add(payment)
print("💳 发起购买: \(product.productIdentifier)")
}
// MARK: - 恢复购买
func restorePurchases(completion: @escaping (Result<[SKPaymentTransaction], Error>) -> Void) {
restoreCompletionHandler = completion
isLoading = true
SKPaymentQueue.default().restoreCompletedTransactions()
print("🔄 开始恢复购买...")
}
// MARK: - 验证收据
func validateReceipt(completion: @escaping (Result<ReceiptValidationResponse, Error>) -> Void) {
guard let receiptURL = Bundle.main.appStoreReceiptURL,
FileManager.default.fileExists(atPath: receiptURL.path),
let receiptData = try? Data(contentsOf: receiptURL) else {
completion(.failure(SubscriptionError.noReceiptFound))
return
}
let receiptString = receiptData.base64EncodedString()
// 发送到您的服务器进行验证
ReceiptValidator.validate(receipt: receiptString) {
result in
DispatchQueue.main.async {
switch result {
case .success(let response):
self.processValidationResponse(response)
completion(.success(response))
case .failure(let error):
completion(.failure(error))
}
}
}
}
// MARK: - 处理验证响应
private func processValidationResponse(_ response: ReceiptValidationResponse) {
guard let latestReceipt = response.latestReceiptInfo?.first else {
isSubscribed = false
return
}
// 检查订阅是否有效
if let expiresDateMs = latestReceipt.expiresDateMs,
let expiresDate = Double(expiresDateMs) {
let expiration = Date(timeIntervalSince1970: expiresDate / 1000)
isSubscribed = expiration > Date()
if isSubscribed {
purchasedProductIDs.insert(latestReceipt.productId)
}
}
}
// MARK: - 获取格式化价格
func formattedPrice(for product: SKProduct) -> String {
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.locale = product.priceLocale
return formatter.string(from: product.price) ?? "\(product.price)"
}
// MARK: - 获取订阅周期描述
func subscriptionPeriodDescription(for product: SKProduct) -> String {
guard let period = product.subscriptionPeriod else {
return "" }
let unit: String
switch period.unit {
case .day: unit = period.numberOfUnits == 1 ? "天" : "\(period.numberOfUnits)天"
case .week: unit = period.numberOfUnits == 1 ? "周" : "\(period.numberOfUnits)周"
case .month: unit = period.numberOfUnits == 1 ? "月" : "\(period.numberOfUnits)个月"
case .year: unit = period.numberOfUnits == 1 ? "年" : "\(period.numberOfUnits)年"
@unknown default: unit = ""
}
return unit
}
// MARK: - 获取免费试用描述
func freeTrialDescription(for product: SKProduct) -> String? {
guard let introPrice = product.introductoryPrice,
introPrice.paymentMode == .freeTrial else {
return nil
}
let period = introPrice.subscriptionPeriod
let unit: String
switch period.unit {
case .day: unit = "\(period.numberOfUnits)天"
case .week: unit = "\(period.numberOfUnits)周"
case .month: unit = "\(period.numberOfUnits)个月"
case .year: unit = "\(period.numberOfUnits)年"
@unknown default: return nil
}
return "免费试用 \(unit)"
}
}
// MARK: - SKProductsRequestDelegate
extension SubscriptionManager: SKProductsRequestDelegate {
func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) {
DispatchQueue.main.async {
self.isLoading = false
self.products = response.products.sorted {
$0.price.compare($1.price) == .orderedAscending }
print("✅ 获取到 \(response.products.count) 个产品")
if !response.invalidProductIdentifiers.isEmpty {
print("⚠️ 无效产品ID: \(response.invalidProductIdentifiers)")
}
}
}
func request(_ request: SKRequest, didFailWithError error: Error) {
DispatchQueue.main.async {
self.isLoading = false
self.errorMessage = error.localizedDescription
print("❌ 请求产品失败: \(error.localizedDescription)")
}
}
}
// MARK: - SKPaymentTransactionObserver
extension SubscriptionManager: SKPaymentTransactionObserver {
func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
for transaction in transactions {
switch transaction.transactionState {
case .purchasing:
print("🔄 购买中: \(transaction.payment.productIdentifier)")
case .purchased:
print("✅ 购买成功: \(transaction.payment.productIdentifier)")
handlePurchased(transaction)
case .failed:
print("❌ 购买失败: \(transaction.error?.localizedDescription ?? "未知错误")")
handleFailed(transaction)
case .restored:
print("🔄 恢复成功: \(transaction.payment.productIdentifier)")
handleRestored(transaction)
case .deferred:
print("⏸ 购买延迟(等待审批): \(transaction.payment.productIdentifier)")
handleDeferred(transaction)
@unknown default:
print("⚠️ 未知交易状态")
}
}
}
func paymentQueueRestoreCompletedTransactionsFinished(_ queue: SKPaymentQueue) {
DispatchQueue.main.async {
self.isLoading = false
print("✅ 恢复购买完成")
let restoredTransactions = queue.transactions.filter {
$0.transactionState == .restored }
self.restoreCompletionHandler?(.success(restoredTransactions))
self.restoreCompletionHandler = nil
}
}
func paymentQueue(_ queue: SKPaymentQueue, restoreCompletedTransactionsFailedWithError error: Error) {
DispatchQueue.main.async {
self.isLoading = false
self.errorMessage = error.localizedDescription
print("❌ 恢复购买失败: \(error.localizedDescription)")
self.restoreCompletionHandler?(.failure(error))
self.restoreCompletionHandler = nil
}
}
// MARK: - 处理购买成功
private func handlePurchased(_ transaction: SKPaymentTransaction) {
// 验证收据
validateReceipt {
[weak self] result in
switch result {
case .success:
self?.purchaseCompletionHandler?(.success(transaction))
case .failure(let error):
self?.purchaseCompletionHandler?(.failure(error))
}
self?.purchaseCompletionHandler = nil
self?.isLoading = false
}
// 完成交易
SKPaymentQueue.default().finishTransaction(transaction)
}
// MARK: - 处理购买失败
private func handleFailed(_ transaction: SKPaymentTransaction) {
DispatchQueue.main.async {
self.isLoading = false
if let error = transaction.error as? SKError {
switch error.code {
case .paymentCancelled:
self.purchaseCompletionHandler?(.failure(SubscriptionError.paymentCancelled))
default:
self.purchaseCompletionHandler?(.failure(error))
}
}
self.purchaseCompletionHandler = nil
}
SKPaymentQueue.default().finishTransaction(transaction)
}
// MARK: - 处理恢复
private func handleRestored(_ transaction: SKPaymentTransaction) {
purchasedProductIDs.insert(transaction.payment.productIdentifier)
SKPaymentQueue.default().finishTransaction(transaction)
}
// MARK: - 处理延迟
private func handleDeferred(_ transaction: SKPaymentTransaction) {
DispatchQueue.main.async {
self.isLoading = false
self.purchaseCompletionHandler?(.failure(SubscriptionError.paymentDeferred))
self.purchaseCompletionHandler = nil
}
}
}
// MARK: - 错误定义
enum SubscriptionError: LocalizedError {
case paymentsNotAllowed
case paymentCancelled
case paymentDeferred
case noReceiptFound
case invalidReceipt
case serverError
var errorDescription: String? {
switch self {
case .paymentsNotAllowed:
return "当前设备不允许应用内购买"
case .paymentCancelled:
return "购买已取消"
case .paymentDeferred:
return "购买需要授权,请等待审批"
case .noReceiptFound:
return "未找到购买凭证"
case .invalidReceipt:
return "购买凭证无效"
case .serverError:
return "服务器验证失败"
}
}
}
// MARK: - 收据验证响应模型
struct ReceiptValidationResponse: Codable {
let status: Int
let latestReceiptInfo: [LatestReceiptInfo]?
let pendingRenewalInfo: [PendingRenewalInfo]?
enum CodingKeys: String, CodingKey {
case status
case latestReceiptInfo = "latest_receipt_info"
case pendingRenewalInfo = "pending_renewal_info"
}
}
struct LatestReceiptInfo: Codable {
let productId: String
let transactionId: String
let originalTransactionId: String
let purchaseDateMs: String
let expiresDateMs: String?
let isTrialPeriod: String?
let isInIntroOfferPeriod: String?
enum CodingKeys: String, CodingKey {
case productId = "product_id"
case transactionId = "transaction_id"
case originalTransactionId = "original_transaction_id"
case purchaseDateMs = "purchase_date_ms"
case expiresDateMs = "expires_date_ms"
case isTrialPeriod = "is_trial_period"
case isInIntroOfferPeriod = "is_in_intro_offer_period"
}
}
struct PendingRenewalInfo: Codable {
let autoRenewProductId: String
let autoRenewStatus: String
let expirationIntent: String?
let gracePeriodExpiresDateMs: String?
enum CodingKeys: String, CodingKey {
case autoRenewProductId = "auto_renew_product_id"
case autoRenewStatus = "auto_renew_status"
case expirationIntent = "expiration_intent"
case gracePeriodExpiresDateMs = "grace_period_expires_date_ms"
}
}
3. 订阅界面实现(SwiftUI)
import SwiftUI
struct SubscriptionView: View {
@StateObject private var subscriptionManager = SubscriptionManager.shared
@State private var selectedProduct: SKProduct?
@State private var showAlert = false
@State private var alertMessage = ""
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationView {
ScrollView {
VStack(spacing: 24) {
// 头部
headerSection
// 功能特性
featuresSection
// 订阅选项
subscriptionOptionsSection
// 订阅按钮
subscribeButton
// 恢复购买
restoreButton
// 法律条款
legalSection
}
.padding()
}
.navigationTitle("升级会员")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("关闭") {
dismiss()
}
}
}
}
.onAppear {
subscriptionManager.fetchProducts()
}
.alert("提示", isPresented: $showAlert) {
Button("确定", role: .cancel) {
}
} message: {

最低0.47元/天 解锁文章

1万+

被折叠的 条评论
为什么被折叠?



