从零开始学iOS开发(第四十一篇):StoreKit 2 与应用内购买 —— 让应用实现商业价值

欢迎来到本系列教程的第四十一篇。在前四十篇文章中,你已经学习了从Swift基础到Core Data的全方位iOS开发技能。现在,你能够构建出功能完善、数据持久化的专业应用了。但是,如何让应用产生收入?如何实现高级功能解锁?如何设置订阅服务?

StoreKit 2是苹果最新的应用内购买框架,它简化了购买流程,提供了更现代的Swift API。无论是消耗品(游戏金币)、非消耗品(解锁高级功能)、自动续期订阅(会员服务),StoreKit都能帮你实现。

在这一篇中,你将学到:

  1. StoreKit 2基础

    • 产品配置与获取

    • 购买流程

    • 交易验证

  2. 产品类型

    • 消耗品

    • 非消耗品

    • 自动续期订阅

    • 非续期订阅

  3. 收据验证

    • 本地验证

    • 服务器验证

    • 防作弊策略

  4. 订阅管理

    • 订阅状态检查

    • 续期管理

    • 退订处理

  5. 实战项目:构建带订阅功能的待办应用


一、StoreKit 2基础

1.1 配置应用内购买

步骤1:在App Store Connect中配置产品

  1. 登录App Store Connect

  2. 选择你的应用 → 功能 → 应用内购买

  3. 添加产品(消耗品/非消耗品/订阅)

步骤2:在Xcode中配置

在Signing & Capabilities中添加In-App Purchase能力

1.2 产品管理

swift

复制代码
import StoreKit

// MARK: - 产品管理器
@MainActor
class StoreManager: ObservableObject {
    static let shared = StoreManager()
    
    @Published var products: [Product] = []
    @Published var purchasedProductIDs: Set<String> = []
    @Published var subscriptionStatus: SubscriptionStatus = .notSubscribed
    
    enum SubscriptionStatus {
        case notSubscribed
        case subscribed(expiryDate: Date)
        case expired
        case gracePeriod
    }
    
    private let productIDs = [
        "com.example.app.premium_monthly",
        "com.example.app.premium_yearly",
        "com.example.app.coins_100",
        "com.example.app.coins_500",
        "com.example.app.feature_unlock"
    ]
    
    private var updateListenerTask: Task<Void, Error>?
    
    init() {
        // 监听交易更新
        updateListenerTask = listenForTransactions()
        
        Task {
            await requestProducts()
            await updatePurchasedProducts()
        }
    }
    
    deinit {
        updateListenerTask?.cancel()
    }
    
    // 获取产品列表
    func requestProducts() async {
        do {
            let products = try await Product.products(for: productIDs)
            await MainActor.run {
                self.products = products.sorted { $0.displayName < $1.displayName }
            }
        } catch {
            print("获取产品失败: \(error)")
        }
    }
    
    // 更新已购产品
    func updatePurchasedProducts() async {
        var purchased: Set<String> = []
        
        for await result in Transaction.currentEntitlements {
            if case .verified(let transaction) = result {
                if transaction.revocationDate == nil {
                    purchased.insert(transaction.productID)
                }
            }
        }
        
        await MainActor.run {
            self.purchasedProductIDs = purchased
        }
    }
    
    // 监听交易更新
    private func listenForTransactions() -> Task<Void, Error> {
        return Task.detached {
            for await result in Transaction.updates {
                do {
                    let transaction = try self.checkVerified(result)
                    await self.updatePurchasedProducts()
                    await transaction.finish()
                } catch {
                    print("交易验证失败: \(error)")
                }
            }
        }
    }
    
    // 验证交易
    private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
        switch result {
        case .unverified:
            throw StoreError.failedVerification
        case .verified(let safe):
            return safe
        }
    }
    
    // 购买产品
    func purchase(_ product: Product) async throws {
        let result = try await product.purchase()
        
        switch result {
        case .success(let verification):
            let transaction = try checkVerified(verification)
            await updatePurchasedProducts()
            await transaction.finish()
            
        case .userCancelled:
            throw StoreError.userCancelled
            
        case .pending:
            throw StoreError.pending
            
        default:
            break
        }
    }
    
    // 检查是否已购买
    func isPurchased(_ productID: String) -> Bool {
        return purchasedProductIDs.contains(productID)
    }
    
    // 恢复购买
    func restorePurchases() async throws {
        try await AppStore.sync()
        await updatePurchasedProducts()
    }
}

enum StoreError: LocalizedError {
    case failedVerification
    case userCancelled
    case pending
    case productNotFound
    
    var errorDescription: String? {
        switch self {
        case .failedVerification: return "交易验证失败"
        case .userCancelled: return "购买已取消"
        case .pending: return "交易处理中"
        case .productNotFound: return "未找到产品"
        }
    }
}

二、产品展示与购买

2.1 产品商店视图

swift

复制代码
import SwiftUI
import StoreKit

// MARK: - 商店视图
struct StoreView: View {
    @StateObject private var storeManager = StoreManager.shared
    @State private var isLoading = false
    @State private var errorMessage: String?
    @State private var showingRestoreAlert = false
    
    var body: some View {
        NavigationView {
            List {
                // 订阅产品
                if let monthly = storeManager.products.first(where: { $0.id == "com.example.app.premium_monthly" }),
                   let yearly = storeManager.products.first(where: { $0.id == "com.example.app.premium_yearly" }) {
                    Section(header: Text("会员订阅"), footer: Text("订阅后解锁所有高级功能")) {
                        ProductRow(product: monthly, storeManager: storeManager)
                        ProductRow(product: yearly, storeManager: storeManager)
                    }
                }
                
                // 消耗品
                let consumables = storeManager.products.filter { 
                    $0.type == .consumable && $0.id.hasPrefix("com.example.app.coins")
                }
                if !consumables.isEmpty {
                    Section(header: Text("金币充值"), footer: Text("金币可用于购买虚拟道具")) {
                        ForEach(consumables, id: \.id) { product in
                            ProductRow(product: product, storeManager: storeManager)
                        }
                    }
                }
                
                // 非消耗品
                if let featureUnlock = storeManager.products.first(where: { $0.id == "com.example.app.feature_unlock" }) {
                    Section(header: Text("永久解锁"), footer: Text("一次性购买,永久使用")) {
                        ProductRow(product: featureUnlock, storeManager: storeManager)
                    }
                }
            }
            .navigationTitle("商店")
            .toolbar {
                ToolbarItem(placement: .topBarTrailing) {
                    Button("恢复购买") {
                        Task {
                            await restorePurchases()
                        }
                    }
                }
            }
            .overlay {
                if isLoading {
                    ProgressView()
                        .background(Color.black.opacity(0.3))
                }
            }
            .alert("恢复成功", isPresented: $showingRestoreAlert) {
                Button("确定", role: .cancel) { }
            } message: {
                Text("您的购买已恢复")
            }
            .alert("错误", isPresented: .constant(errorMessage != nil)) {
                Button("确定") { errorMessage = nil }
            } message: {
                Text(errorMessage ?? "")
            }
        }
    }
    
    private func restorePurchases() async {
        isLoading = true
        do {
            try await storeManager.restorePurchases()
            showingRestoreAlert = true
        } catch {
            errorMessage = error.localizedDescription
        }
        isLoading = false
    }
}

// MARK: - 产品行视图
struct ProductRow: View {
    let product: Product
    @ObservedObject var storeManager: StoreManager
    @State private var isPurchasing = false
    
    var body: some View {
        HStack {
            VStack(alignment: .leading, spacing: 4) {
                Text(product.displayName)
                    .font(.headline)
                Text(product.description)
                    .font(.caption)
                    .foregroundColor(.secondary)
            }
            
            Spacer()
            
            if storeManager.isPurchased(product.id) {
                Label("已购买", systemImage: "checkmark.circle.fill")
                    .foregroundColor(.green)
                    .font(.caption)
            } else {
                Button {
                    Task {
                        await purchase()
                    }
                } label: {
                    if isPurchasing {
                        ProgressView()
                            .controlSize(.small)
                    } else {
                        Text(product.displayPrice)
                            .font(.headline)
                            .foregroundColor(.blue)
                    }
                }
                .disabled(isPurchasing)
            }
        }
        .padding(.vertical, 8)
    }
    
    private func purchase() async {
        isPurchasing = true
        do {
            try await storeManager.purchase(product)
        } catch {
            print("购买失败: \(error)")
        }
        isPurchasing = false
    }
}

三、订阅管理

3.1 订阅状态检查

swift

复制代码
// MARK: - 订阅管理器扩展
extension StoreManager {
    
    // 检查订阅状态
    func checkSubscriptionStatus() async {
        var isActive = false
        var expiryDate: Date?
        
        for await result in Transaction.currentEntitlements {
            guard case .verified(let transaction) = result else { continue }
            
            if transaction.productID == "com.example.app.premium_monthly" ||
               transaction.productID == "com.example.app.premium_yearly" {
                
                if let expirationDate = transaction.expirationDate {
                    if expirationDate > Date() {
                        isActive = true
                        expiryDate = expirationDate
                    }
                } else if transaction.revocationDate == nil {
                    // 非续期订阅或永久购买
                    isActive = true
                }
            }
        }
        
        await MainActor.run {
            if isActive, let expiry = expiryDate {
                self.subscriptionStatus = .subscribed(expiryDate: expiry)
            } else if isActive {
                self.subscriptionStatus = .subscribed(expiryDate: Date.distantFuture)
            } else {
                self.subscriptionStatus = .notSubscribed
            }
        }
    }
    
    // 获取订阅产品
    var subscriptionProducts: [Product] {
        products.filter { $0.type == .autoRenewable }
    }
    
    // 管理订阅
    func manageSubscriptions() {
        guard let url = URL(string: "https://apps.apple.com/account/subscriptions") else { return }
        UIApplication.shared.open(url)
    }
}

3.2 订阅状态显示视图

swift

复制代码
// MARK: - 订阅状态视图
struct SubscriptionStatusView: View {
    @StateObject private var storeManager = StoreManager.shared
    @State private var subscriptionInfo: String = ""
    
    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            Text("会员状态")
                .font(.headline)
            
            HStack {
                Image(systemName: isPremiumUser ? "crown.fill" : "crown")
                    .foregroundColor(isPremiumUser ? .yellow : .gray)
                
                Text(subscriptionInfo)
                    .font(.subheadline)
                    .foregroundColor(isPremiumUser ? .primary : .secondary)
                
                Spacer()
                
                if isPremiumUser {
                    Button("管理订阅") {
                        storeManager.manageSubscriptions()
                    }
                    .font(.caption)
                } else {
                    NavigationLink("升级会员") {
                        StoreView()
                    }
                    .font(.caption)
                }
            }
            .padding()
            .background(Color(.systemGray6))
            .cornerRadius(10)
        }
        .padding()
        .onAppear {
            updateSubscriptionInfo()
        }
        .onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in
            updateSubscriptionInfo()
        }
    }
    
    var isPremiumUser: Bool {
        if case .subscribed = storeManager.subscriptionStatus {
            return true
        }
        return storeManager.isPurchased("com.example.app.feature_unlock")
    }
    
    private func updateSubscriptionInfo() {
        Task {
            await storeManager.checkSubscriptionStatus()
            
            await MainActor.run {
                switch storeManager.subscriptionStatus {
                case .subscribed(let expiryDate):
                    let formatter = DateFormatter()
                    formatter.dateFormat = "yyyy年MM月dd日"
                    subscriptionInfo = "会员有效期至 \(formatter.string(from: expiryDate))"
                case .expired:
                    subscriptionInfo = "会员已过期"
                case .gracePeriod:
                    subscriptionInfo = "会员宽限期中"
                case .notSubscribed:
                    subscriptionInfo = "非会员用户"
                }
            }
        }
    }
}

四、收据验证

4.1 本地收据验证

swift

复制代码
// MARK: - 收据验证服务
class ReceiptValidator {
    static let shared = ReceiptValidator()
    
    // 获取收据数据
    func getReceiptData() -> Data? {
        guard let receiptURL = Bundle.main.appStoreReceiptURL,
              let receiptData = try? Data(contentsOf: receiptURL) else {
            return nil
        }
        return receiptData
    }
    
    // 本地验证(仅检查收据是否存在)
    func hasValidReceipt() -> Bool {
        return getReceiptData() != nil
    }
    
    // 解析收据(简化版)
    func parseReceipt() -> [String: Any]? {
        guard let receiptData = getReceiptData() else { return nil }
        
        let receiptString = receiptData.base64EncodedString()
        // 实际应该发送到服务器验证
        return ["receipt": receiptString]
    }
    
    // 验证特定产品是否已购买
    func isProductPurchased(productID: String) -> Bool {
        // 使用StoreKit 2的Transaction.currentEntitlements
        // 实际实现已在StoreManager中
        return StoreManager.shared.isPurchased(productID)
    }
}

4.2 服务器验证

swift

复制代码
// MARK: - 服务器验证服务
class ServerReceiptValidator {
    private let verifyURL = "https://buy.itunes.apple.com/verifyReceipt"
    private let sandboxURL = "https://sandbox.itunes.apple.com/verifyReceipt"
    
    // 发送收据到服务器验证
    func verifyReceipt(receiptData: Data, completion: @escaping (Result<[String: Any], Error>) -> Void) {
        let receiptString = receiptData.base64EncodedString()
        
        var request = URLRequest(url: URL(string: verifyURL)!)
        request.httpMethod = "POST"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        
        let body: [String: Any] = [
            "receipt-data": receiptString,
            "password": "YOUR_SHARED_SECRET", // 在App Store Connect中获取
            "exclude-old-transactions": true
        ]
        
        request.httpBody = try? JSONSerialization.data(withJSONObject: body)
        
        URLSession.shared.dataTask(with: request) { data, response, error in
            if let error = error {
                completion(.failure(error))
                return
            }
            
            guard let data = data else {
                completion(.failure(NSError(domain: "No data", code: -1)))
                return
            }
            
            do {
                let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] ?? [:]
                
                // 检查状态码
                if let status = json["status"] as? Int {
                    if status == 21007 {
                        // 沙盒环境,重试沙盒URL
                        self.verifySandbox(receiptData: receiptData, completion: completion)
                        return
                    }
                }
                
                completion(.success(json))
            } catch {
                completion(.failure(error))
            }
        }.resume()
    }
    
    private func verifySandbox(receiptData: Data, completion: @escaping (Result<[String: Any], Error>) -> Void) {
        let receiptString = receiptData.base64EncodedString()
        
        var request = URLRequest(url: URL(string: sandboxURL)!)
        request.httpMethod = "POST"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        
        let body: [String: Any] = [
            "receipt-data": receiptString,
            "password": "YOUR_SHARED_SECRET"
        ]
        
        request.httpBody = try? JSONSerialization.data(withJSONObject: body)
        
        URLSession.shared.dataTask(with: request) { data, response, error in
            if let error = error {
                completion(.failure(error))
                return
            }
            
            guard let data = data else {
                completion(.failure(NSError(domain: "No data", code: -1)))
                return
            }
            
            do {
                let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] ?? [:]
                completion(.success(json))
            } catch {
                completion(.failure(error))
            }
        }.resume()
    }
}

五、实战:带订阅功能的待办应用

5.1 高级功能视图

swift

复制代码
// MARK: - 高级功能视图(需要订阅解锁)
struct PremiumFeatureView: View {
    @StateObject private var storeManager = StoreManager.shared
    
    var body: some View {
        NavigationView {
            if isPremiumUser {
                // 已解锁的高级内容
                PremiumContentView()
            } else {
                // 锁定状态,显示升级页面
                LockedContentView()
            }
        }
    }
    
    var isPremiumUser: Bool {
        if case .subscribed = storeManager.subscriptionStatus {
            return true
        }
        return storeManager.isPurchased("com.example.app.feature_unlock")
    }
}

// MARK: - 锁定内容视图
struct LockedContentView: View {
    @State private var showingStore = false
    
    var body: some View {
        VStack(spacing: 30) {
            Image(systemName: "lock.fill")
                .font(.system(size: 70))
                .foregroundColor(.gray)
            
            Text("高级功能")
                .font(.title)
                .bold()
            
            Text("订阅会员或永久解锁,享受所有高级功能")
                .multilineTextAlignment(.center)
                .foregroundColor(.secondary)
                .padding(.horizontal)
            
            VStack(alignment: .leading, spacing: 16) {
                FeatureRow(icon: "icloud", text: "iCloud同步")
                FeatureRow(icon: "chart.line.uptrend.xyaxis", text: "数据统计图表")
                FeatureRow(icon: "bell", text: "智能提醒")
                FeatureRow(icon: "paintpalette", text: "主题定制")
            }
            .padding()
            .background(Color(.systemGray6))
            .cornerRadius(12)
            .padding(.horizontal)
            
            Button("查看会员方案") {
                showingStore = true
            }
            .buttonStyle(.borderedProminent)
            .controlSize(.large)
        }
        .padding()
        .sheet(isPresented: $showingStore) {
            StoreView()
        }
    }
}

struct FeatureRow: View {
    let icon: String
    let text: String
    
    var body: some View {
        HStack(spacing: 12) {
            Image(systemName: icon)
                .foregroundColor(.blue)
                .frame(width: 24)
            Text(text)
                .font(.subheadline)
            Spacer()
        }
    }
}

// MARK: - 高级内容视图
struct PremiumContentView: View {
    var body: some View {
        VStack(spacing: 20) {
            Image(systemName: "crown.fill")
                .font(.system(size: 50))
                .foregroundColor(.yellow)
            
            Text("会员专属内容")
                .font(.largeTitle)
                .bold()
            
            Text("感谢您的支持!以下是您的高级功能")
                .foregroundColor(.secondary)
            
            List {
                PremiumFeatureRow(icon: "icloud", title: "iCloud同步", description: "在多设备间同步数据")
                PremiumFeatureRow(icon: "chart.line.uptrend.xyaxis", title: "数据统计", description: "查看详细支出图表")
                PremiumFeatureRow(icon: "bell", title: "智能提醒", description: "按时提醒待办事项")
                PremiumFeatureRow(icon: "paintpalette", title: "主题定制", description: "自定义应用外观")
            }
            .listStyle(.insetGrouped)
        }
        .padding(.top)
    }
}

struct PremiumFeatureRow: View {
    let icon: String
    let title: String
    let description: String
    
    var body: some View {
        HStack(spacing: 16) {
            Image(systemName: icon)
                .font(.title2)
                .foregroundColor(.blue)
                .frame(width: 30)
            
            VStack(alignment: .leading, spacing: 4) {
                Text(title)
                    .font(.headline)
                Text(description)
                    .font(.caption)
                    .foregroundColor(.secondary)
            }
        }
        .padding(.vertical, 4)
    }
}

5.2 设置页面集成

swift

复制代码
// MARK: - 设置页面
struct SettingsView: View {
    @StateObject private var storeManager = StoreManager.shared
    @AppStorage("isProUser") private var isProUser = false
    @State private var subscriptionInfo = ""
    
    var body: some View {
        Form {
            Section("会员状态") {
                SubscriptionStatusView()
            }
            
            Section("高级功能") {
                NavigationLink("会员专属功能") {
                    PremiumFeatureView()
                }
            }
            
            Section("支持我们") {
                Button("评价应用") {
                    openAppStore()
                }
                
                Button("推荐给朋友") {
                    shareApp()
                }
                
                NavigationLink("隐私政策") {
                    PrivacyPolicyView()
                }
            }
        }
        .navigationTitle("设置")
        .onAppear {
            updateSubscriptionInfo()
        }
    }
    
    private func updateSubscriptionInfo() {
        Task {
            await storeManager.checkSubscriptionStatus()
        }
    }
    
    private func openAppStore() {
        let appID = "YOUR_APP_ID"
        guard let url = URL(string: "https://apps.apple.com/app/id\(appID)") else { return }
        UIApplication.shared.open(url)
    }
    
    private func shareApp() {
        let appURL = "https://apps.apple.com/app/idYOUR_APP_ID"
        let activityVC = UIActivityViewController(activityItems: [appURL], applicationActivities: nil)
        
        if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
           let rootVC = windowScene.windows.first?.rootViewController {
            rootVC.present(activityVC, animated: true)
        }
    }
}

struct PrivacyPolicyView: View {
    var body: some View {
        ScrollView {
            Text("隐私政策内容...")
                .padding()
        }
        .navigationTitle("隐私政策")
    }
}

六、总结

StoreKit 2让应用内购买变得更加简单和安全:

类型 特点 使用场景
消耗品 可多次购买,会消耗 游戏币、道具
非消耗品 一次性购买,永久拥有 解锁功能、移除广告
自动续期订阅 定期扣费,自动续期 会员服务、订阅内容
非续期订阅 定期购买,不自动续期 优惠券、限时通行证

最佳实践

swift

复制代码
struct IAPBestPractices {
    /*
    1. 产品配置
       - 产品ID使用反向域名格式
       - 提供清晰的描述和截图
       - 设置合适的定价等级
       
    2. 用户体验
       - 在合适的时机提示购买
       - 购买前展示产品价值
       - 提供恢复购买功能
       
    3. 安全考虑
       - 服务器验证收据
       - 定期检查订阅状态
       - 处理退款和投诉
       
    4. 测试
       - 使用沙盒环境测试
       - 测试订阅续期场景
       - 测试恢复购买功能
    */
}

StoreKit让您的应用能够实现商业价值。掌握这些技能后,您将能够构建出可持续盈利的应用!💰

相关推荐
流年如夢2 小时前
顺序表(LeetCode)
c语言·数据结构·leetcode·职场和发展
浅念-14 小时前
刷穿LeetCode:BFS 解决 Flood Fill 算法
数据结构·c++·算法·leetcode·职场和发展·bfs·宽度优先
我命由我1234516 小时前
程序员的心理学学习笔记 - 空杯心态
经验分享·笔记·学习·职场和发展·求职招聘·职场发展·学习方法
Hesionberger21 小时前
LeetCode 78:子集生成全攻略
java·开发语言·数据结构·python·算法·leetcode·职场和发展
天真小巫21 小时前
如何找到喜欢做的事(实践版)
职场和发展
逻辑驱动的ken1 天前
Java高频面试考点场景题23
java·开发语言·数据库·面试·职场和发展·哈希算法
可爱の小公举1 天前
Redis面试高频考点全解析
人工智能·学习·职场和发展·ai编程
_风中无我。1 天前
深圳行,面试笔记!
笔记·面试·职场和发展
knight_9___2 天前
LLM工具调用面试篇6
人工智能·python·面试·职场和发展·llm·agent