You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

SwiftUI文档类应用付费墙问题排查及最优实现方案咨询

问题描述

我的应用启动后直接显示Paywall,测试时一切正常,但每次向App Store Connect提交更新时,都会收到反馈:用户购买内购订阅后无法获取高级功能(即付费墙在购买成功后未消失)。

我的用户权限授予方案如下:

  1. 主应用视图权限检查:在DocumentView的.onAppear中通过RevenueCat拉取用户信息,判断是否显示Paywall,并把状态存在UserDefaults里
@main
struct MyApp: App {
    var body: some Scene {
        DocumentView()
    }
}

struct DocumentView: Scene {
    @State var showModal = false

    var body: some Scene {
        DocumentGroup(newDocument: Document()) { file in
            ContentView(document: file.$document, showModal: $showModal)
            .onAppear() {
                Purchases.shared.getCustomerInfo { customerInfo, error in
                    if customerInfo?.entitlements[Constant.entitlementId]?.isActive == true {
                        self.showModal = false
                        UserDefaults.standard.setValue(true, forKey: Constant.userDefaultsAccess)
                    } else {
                        self.showModal = true
                        UserDefaults.standard.setValue(false, forKey: Constant.userDefaultsAccess)
                    }
                }
            }
        }
    }
}
  1. 内容视图Paywall显示逻辑:通过showModal和selection变量控制不同类型的Paywall显示,按钮图标和状态依赖UserDefaults
struct ContentView: View {
    @State private var selection: String? = "NoPro"
    @Binding var showModal: Bool

    var body: some View {
        NavigationView {
            VStack {
                ....
            }
        }
        .navigationBarItems(
            trailing:
                HStack {
                    Button(action: {
                        self.selection = "Pro"
                        self.showModal.toggle()
                    }, label: {
                        Image(systemName: UserDefaults.standard.bool(forKey: Constant.userDefaultsAccess) ? "checkmark.seal.fill" : "xmark.seal.fill")
                    })
                    .foregroundColor(UserDefaults.standard.bool(forKey: Constant.userDefaultsAccess) ? .green : .red)
        .sheet(isPresented: $showModal) {
            if self.selection == "Pro" {
                Paywall(showModal: self.$showModal, pro: "Pro")
            } else if self.selection == "NoPro" {
                Paywall(showModal: self.$showModal, pro: "NoPro")
            }
        }
    }
}
  1. Paywall购买后处理:购买成功后修改UserDefaults并关闭弹窗,判断是否显示订阅状态依赖UserDefaults
struct Paywall: View {
    @Binding var showModal: Bool
    @State var pro: String

    var body: some View {
        VStack {
            ....
            if pro == "Pro" {
                HStack {
                    Spacer()
                    VStack {
                        Image(systemName: "xmark.circle.fill")
                            .font(.largeTitle)
                            .padding()
                            .onTapGesture(count: 1, perform: {
                                self.showModal.toggle()
                            })
                        Spacer()
                    }
                }
            }
            
            SubscriptionButton(showModal: $showModal)
        }
        .interactiveDismissDisabled(pro == "Pro" ? false : true)
    }
}

struct SubscriptionButton: View {
    @Binding var showModal: Bool

    var body: some View {
        if UserDefaults.standard.bool(forKey: Constant.userDefaultsAccess) {
            Text("You are already Subscribed!")
        } else {
            Button(action: {
                PurchaseManager.purchase(productId: Constant.productId) {
                    UserDefaults.standard.setValue(true, forKey: Constant.userDefaultsAccess)
                    showModal = false
                }
            }, label: {
                VStack {
                    Text("Subscribe now!")
                }
            })
        }
    }
}
现有实现的问题
  • 依赖本地存储判断权限:把UserDefaults作为权限判断的核心依据,但本地存储无法和RevenueCat服务器实时同步——比如用户在其他设备购买、订阅过期/续订、服务器调整订阅状态时,本地值不会自动更新,直接导致状态判断错误。
  • 权限检查时机单一:只在应用启动时拉取一次用户信息,购买成功后仅修改本地值,没有主动重新拉取服务器最新状态,一旦网络延迟或者回调有误差,就会出现状态不同步。
  • 视图状态和订阅状态脱节:showModal、selection等视图状态完全靠本地变量控制,没有和服务器订阅状态绑定,购买成功后手动修改状态可能因为SwiftUI的更新机制导致UI没有及时刷新。
  • 购买回调不严谨:购买完成后直接修改本地值并关闭弹窗,没有验证购买是否真正成功(比如没检查RevenueCat返回的CustomerInfo),如果支付过程出现异常(比如失败但回调误触发),会错误授予权限。
最优实现方案

核心原则:完全以RevenueCat服务器返回的CustomerInfo作为权限判断唯一标准,用可观察对象管理订阅状态,让UI自动响应状态变化

1. 全局订阅状态管理器

创建一个ObservableObject类,统一处理订阅状态的获取、更新,确保所有视图都使用同一数据源:

import RevenueCat

class SubscriptionManager: ObservableObject {
    static let shared = SubscriptionManager()
    
    @Published var hasActiveSubscription = false
    private let entitlementId = Constant.entitlementId
    
    init() {
        fetchCustomerInfo()
        // 监听购买、恢复订阅的成功回调,自动更新状态
        Purchases.shared.purchaseSuccessHandler = { customerInfo in
            self.updateSubscriptionStatus(with: customerInfo)
        }
        Purchases.shared.restoreSuccessHandler = { customerInfo in
            self.updateSubscriptionStatus(with: customerInfo)
        }
    }
    
    // 拉取最新用户订阅信息
    func fetchCustomerInfo() {
        Purchases.shared.getCustomerInfo { [weak self] customerInfo, error in
            guard let self = self, let customerInfo = customerInfo else { return }
            self.updateSubscriptionStatus(with: customerInfo)
        }
    }
    
    // 更新订阅状态(必须在主线程更新UI)
    private func updateSubscriptionStatus(with customerInfo: CustomerInfo) {
        DispatchQueue.main.async {
            self.hasActiveSubscription = customerInfo.entitlements[self.entitlementId]?.isActive == true
        }
    }
}

2. 改造应用入口逻辑

移除UserDefaults依赖,用SubscriptionManager的状态控制Paywall显示:

@main
struct MyApp: App {
    @StateObject private var subscriptionManager = SubscriptionManager.shared
    
    var body: some Scene {
        DocumentView()
            .environmentObject(subscriptionManager)
    }
}

struct DocumentView: Scene {
    @EnvironmentObject private var subscriptionManager: SubscriptionManager
    @State var showModal = false

    var body: some Scene {
        DocumentGroup(newDocument: Document()) { file in
            ContentView(document: file.$document, showModal: $showModal)
                .onAppear {
                    // 每次视图出现时刷新最新状态
                    subscriptionManager.fetchCustomerInfo()
                }
                .onChange(of: subscriptionManager.hasActiveSubscription) { hasActive in
                    // 订阅状态变化时自动控制Paywall显示
                    showModal = !hasActive
                }
        }
    }
}

3. 简化ContentView的Paywall控制

移除本地状态变量,直接用SubscriptionManager的状态区分不同场景的Paywall:

struct ContentView: View {
    @EnvironmentObject private var subscriptionManager: SubscriptionManager
    @Binding var showModal: Bool
    @State private var showProPaywall = false // 标记用户主动打开的Paywall

    var body: some View {
        NavigationView {
            VStack {
                // 应用主内容
            }
        }
        .navigationBarItems(trailing:
            Button(action: {
                showProPaywall = true
            }) {
                Image(systemName: subscriptionManager.hasActiveSubscription ? "checkmark.seal.fill" : "xmark.seal.fill")
                    .foregroundColor(subscriptionManager.hasActiveSubscription ? .green : .red)
            }
        )
        // 启动时强制显示的Paywall(无关闭按钮)
        .sheet(isPresented: $showModal) {
            Paywall(showModal: $showModal, isProVersion: false)
        }
        // 用户主动打开的Paywall(带关闭按钮)
        .sheet(isPresented: $showProPaywall) {
            Paywall(showModal: $showProPaywall, isProVersion: true)
        }
    }
}

4. 改造Paywall和购买按钮

彻底移除UserDefaults,购买后自动刷新服务器状态:

struct Paywall: View {
    @Binding var showModal: Bool
    let isProVersion: Bool
    @EnvironmentObject private var subscriptionManager: SubscriptionManager

    var body: some View {
        VStack {
            // Paywall内容
            
            // 仅用户主动打开的Paywall显示关闭按钮
            if isProVersion {
                HStack {
                    Spacer()
                    Image(systemName: "xmark.circle.fill")
                        .font(.largeTitle)
                        .padding()
                        .onTapGesture {
                            showModal = false
                        }
                }
            }
            
            SubscriptionButton()
        }
        .interactiveDismissDisabled(!isProVersion)
        .onAppear {
            // 打开Paywall时刷新最新状态
            subscriptionManager.fetchCustomerInfo()
        }
    }
}

struct SubscriptionButton: View {
    @EnvironmentObject private var subscriptionManager: SubscriptionManager

    var body: some View {
        if subscriptionManager.hasActiveSubscription {
            Text("您已订阅")
                .foregroundColor(.green)
        } else {
            Button(action: {
                PurchaseManager.purchase(productId: Constant.productId) { success in
                    if success {
                        // 购买成功后刷新服务器状态,由管理器自动更新UI
                        SubscriptionManager.shared.fetchCustomerInfo()
                    }
                }
            }) {
                Text("立即订阅")
                    .padding()
                    .background(Color.blue)
                    .foregroundColor(.white)
                    .cornerRadius(8)
            }
        }
    }
}

// 优化PurchaseManager,确保返回真实购买结果
class PurchaseManager {
    static func purchase(productId: String, completion: @escaping (Bool) -> Void) {
        Purchases.shared.purchaseProduct(productId) { transaction, customerInfo, error, userCancelled in
            if let error = error || userCancelled {
                completion(false)
                return
            }
            // 验证订阅是否真的激活
            let hasActive = customerInfo?.entitlements[Constant.entitlementId]?.isActive == true
            completion(hasActive)
        }
    }
}

关键改进点

  • 单一数据源:所有权限判断都基于RevenueCat服务器数据,彻底抛弃本地存储作为权限依据
  • 自动状态同步:通过@Published属性让SwiftUI视图自动响应订阅状态变化,无需手动控制弹窗显示
  • 多时机刷新:视图出现、Paywall打开、购买完成后都主动拉取最新状态,确保和服务器一致
  • 严谨的购买验证:购买完成后先验证订阅是否激活,再更新状态,避免错误授权

内容的提问来源于stack exchange,提问作者Adam M.

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.16 01:15:39