iOS In-App Purchase 自动续订订阅完整实现指南

前言

自动续订订阅(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. 创建订阅组

  1. 登录 App Store Connect
  2. 选择您的 App → 订阅订阅组
  3. 点击 + 创建新的订阅组
订阅组结构示例:

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 配置文件(用于本地测试)
  1. File → New → File → StoreKit Configuration File
  2. 添加订阅产品配置
  3. 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: {
   
   
            
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值